diff --git a/approot/admin-scansettings.xml b/approot/admin-scansettings.xml index 15f84aa7..0e760a7a 100644 --- a/approot/admin-scansettings.xml +++ b/approot/admin-scansettings.xml @@ -42,6 +42,24 @@ ${extra-tags-to-scan-info} +
+ + ${artist-tag-delimiter class="form-control"} +
+ ${artist-tag-delimiter-info} +
+
+
+ + ${default-tag-delimiter class="form-control"} +
+ ${default-tag-delimiter-info} +
+
${save-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"}
diff --git a/approot/messages.xml b/approot/messages.xml index 485ab554..420c6f03 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -64,7 +64,9 @@ Root directory +Delimiter to be used for splitting artist names (only if the file does not contain any multi-valued tag) Daily +Delimiter to be used for splitting other tags (only if the file does not contain any multi-valued tag) Extra tags to scan (use ; as separator) Hourly Scan now! @@ -77,6 +79,7 @@ Similarity engine Tag-based None +The tag delimiter must not consist solely of spaces Update period Update start time Weekly diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index 0333e61d..68522632 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -64,7 +64,9 @@ Répertoire racine +Délimiteur à utiliser pour séparer les noms d'artistes (uniquement si le fichier ne contient pas de tag à valeurs multiples) Tous les jours +Délimiteur à utiliser pour séparer les autres tags (uniquement si le fichier ne contient pas de tag à valeurs multiples) Tags supplémentaires à scanner (utiliser ; comme séparateur) Toutes les heures Scanner maintenant ! @@ -77,6 +79,7 @@ Moteur de similarité Basé sur les tags Aucun +Le délimiteur de tag ne doit pas comporter uniquement des espaces Périodicité des mises à jour Heure de départ de la mise à jour Toutes les semaines diff --git a/approot/messages_it.xml b/approot/messages_it.xml index 0db644b8..6f2d731b 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -64,7 +64,9 @@ Cartella principale +Delimitatore da utilizzare per separare i nomi degli artisti (solo se il file non contiene alcun tag con valori multipli) Giornaliera +Delimitatore da utilizzare per separare gli altri tag (solo se il file non contiene alcun tag con valori multipli) Tag aggiuntivi da scansionare (usa ; come separatore) Ogni ora Scansiona ora! @@ -77,6 +79,7 @@ Motore di similarità Basato su tag Nessuno +Il delimitatore del tag non deve consistere esclusivamente di spazi Frequenza di aggiornamento Orario di aggiornamento Settimanale diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index 466c745e..2a06a2a1 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -64,8 +64,10 @@ + 每日 + 每小时 立即扫描! 每月 diff --git a/src/libs/av/include/av/IAudioFile.hpp b/src/libs/av/include/av/IAudioFile.hpp index 1ce66e57..c4368b78 100644 --- a/src/libs/av/include/av/IAudioFile.hpp +++ b/src/libs/av/include/av/IAudioFile.hpp @@ -84,6 +84,7 @@ namespace Av public: virtual ~IAudioFile() = default; + // Keys are forced to be in upper case using MetadataMap = std::unordered_map; virtual const std::filesystem::path& getPath() const = 0; diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index 1956022a..7ac08ba5 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -215,7 +215,7 @@ CREATE TABLE IF NOT EXISTS "track_backup" ( session.getDboSession().execute("ALTER TABLE starred_release RENAME COLUMN scrobbling_state TO sync_state"); session.getDboSession().execute("ALTER TABLE starred_track RENAME COLUMN scrobbler TO backend"); session.getDboSession().execute("ALTER TABLE starred_track RENAME COLUMN scrobbling_state TO sync_state"); - + session.getDboSession().execute("UPDATE user SET feedback_backend = scrobbling_backend"); } @@ -359,7 +359,7 @@ CREATE TABLE IF NOT EXISTS "track_backup" ( constraint "fk_track_media_library" foreign key ("media_library_id") references "media_library" ("id") on delete set null deferrable initially deferred ))"); -// Migrate data, with the new media_library_id field set to 1 + // Migrate data, with the new media_library_id field set to 1 session.getDboSession().execute(R"(INSERT INTO track_backup SELECT id, @@ -394,6 +394,13 @@ SELECT session.getDboSession().execute("ALTER TABLE track_backup RENAME TO track"); } + void migrateFromV51(Session& session) + { + // Add custom artist tag delimiters, no need to rescan since it has no effect when empty + session.getDboSession().execute("ALTER TABLE scan_settings ADD artist_tag_delimiters TEXT NOT NULL DEFAULT ''"); + session.getDboSession().execute("ALTER TABLE scan_settings ADD default_tag_delimiters TEXT NOT NULL DEFAULT ''"); + } + void doDbMigration(Session& session) { static const std::string outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -422,6 +429,7 @@ SELECT {48, migrateFromV48}, {49, migrateFromV49}, {50, migrateFromV50}, + {51, migrateFromV51}, }; { diff --git a/src/libs/database/impl/Migration.hpp b/src/libs/database/impl/Migration.hpp index 61452001..0d2d0d80 100644 --- a/src/libs/database/impl/Migration.hpp +++ b/src/libs/database/impl/Migration.hpp @@ -26,7 +26,7 @@ namespace Database class Session; using Version = std::size_t; - static constexpr Version LMS_DATABASE_VERSION{ 51 }; + static constexpr Version LMS_DATABASE_VERSION{ 52 }; class VersionInfo { public: diff --git a/src/libs/database/impl/ScanSettings.cpp b/src/libs/database/impl/ScanSettings.cpp index b11b9db9..5c8d35b6 100644 --- a/src/libs/database/impl/ScanSettings.cpp +++ b/src/libs/database/impl/ScanSettings.cpp @@ -46,7 +46,7 @@ namespace Database std::vector ScanSettings::getAudioFileExtensions() const { - const auto extensions{ StringUtils::splitString(_audioFileExtensions, " ") }; + const auto extensions{ StringUtils::splitString(_audioFileExtensions, ' ') }; std::vector res(std::cbegin(extensions), std::cend(extensions)); std::sort(std::begin(res), std::end(res)); @@ -57,7 +57,17 @@ namespace Database std::vector ScanSettings::getExtraTagsToScan() const { - return StringUtils::splitString(_extraTagsToScan, ";"); + return StringUtils::splitString(_extraTagsToScan, ';'); + } + + std::vector ScanSettings::getArtistTagDelimiters() const + { + return StringUtils::splitEscapedStrings(_artistTagDelimiters, ';', '\\'); + } + + std::vector ScanSettings::getDefaultTagDelimiters() const + { + return StringUtils::splitEscapedStrings(_defaultTagDelimiters, ';', '\\'); } void ScanSettings::setExtraTagsToScan(const std::vector& extraTags) @@ -69,6 +79,26 @@ namespace Database _extraTagsToScan = std::move(newTagsToScan); } + void ScanSettings::setArtistTagDelimiters(std::span delimiters) + { + std::string tagDelimiters{ StringUtils::escapeAndJoinStrings(delimiters, ';', '\\') }; + if (tagDelimiters != _artistTagDelimiters) + { + _artistTagDelimiters.swap(tagDelimiters); + incScanVersion(); + } + } + + void ScanSettings::setDefaultTagDelimiters(std::span delimiters) + { + std::string tagDelimiters{ StringUtils::escapeAndJoinStrings(delimiters, ';', '\\') }; + if (tagDelimiters != _defaultTagDelimiters) + { + _defaultTagDelimiters.swap(tagDelimiters); + incScanVersion(); + } + } + void ScanSettings::incScanVersion() { _scanVersion += 1; diff --git a/src/libs/database/include/database/ScanSettings.hpp b/src/libs/database/include/database/ScanSettings.hpp index 715e127c..8337a224 100644 --- a/src/libs/database/include/database/ScanSettings.hpp +++ b/src/libs/database/include/database/ScanSettings.hpp @@ -20,6 +20,7 @@ #pragma once #include +#include #include #include #include @@ -62,18 +63,22 @@ namespace Database static pointer get(Session& session); // Getters - std::size_t getScanVersion() const { return _scanVersion; } - Wt::WTime getUpdateStartTime() const { return _startTime; } - UpdatePeriod getUpdatePeriod() const { return _updatePeriod; } - std::vector getExtraTagsToScan() const; - std::vector getAudioFileExtensions() const; - SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; } + std::size_t getScanVersion() const { return _scanVersion; } + Wt::WTime getUpdateStartTime() const { return _startTime; } + UpdatePeriod getUpdatePeriod() const { return _updatePeriod; } + std::vector getExtraTagsToScan() const; + std::vector getAudioFileExtensions() const; + SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; } + std::vector getArtistTagDelimiters() const; + std::vector getDefaultTagDelimiters() const; // Setters void setUpdateStartTime(Wt::WTime t) { _startTime = t; } void setUpdatePeriod(UpdatePeriod p) { _updatePeriod = p; } void setExtraTagsToScan(const std::vector& extraTags); void setSimilarityEngineType(SimilarityEngineType type) { _similarityEngineType = type; } + void setArtistTagDelimiters(std::span delimiters); + void setDefaultTagDelimiters(std::span delimiters); void incScanVersion(); template @@ -85,6 +90,8 @@ namespace Database Wt::Dbo::field(a, _audioFileExtensions, "audio_file_extensions"); Wt::Dbo::field(a, _similarityEngineType, "similarity_engine_type"); Wt::Dbo::field(a, _extraTagsToScan, "extra_tags_to_scan"); + Wt::Dbo::field(a, _artistTagDelimiters, "artist_tag_delimiters"); + Wt::Dbo::field(a, _defaultTagDelimiters, "default_tag_delimiters"); } private: @@ -94,6 +101,7 @@ namespace Database SimilarityEngineType _similarityEngineType{ SimilarityEngineType::Clusters }; std::string _audioFileExtensions{ ".alac .mp3 .ogg .oga .aac .m4a .m4b .flac .wav .wma .aif .aiff .ape .mpc .shn .opus .wv" }; std::string _extraTagsToScan; + std::string _artistTagDelimiters; + std::string _defaultTagDelimiters; }; } // namespace Database - diff --git a/src/libs/metadata/CMakeLists.txt b/src/libs/metadata/CMakeLists.txt index b2502aa3..8a11a7cf 100644 --- a/src/libs/metadata/CMakeLists.txt +++ b/src/libs/metadata/CMakeLists.txt @@ -4,9 +4,9 @@ if(BUILD_TESTING) endif() add_library(lmsmetadata SHARED - impl/AvFormatParser.cpp - impl/Factory.cpp - impl/TagLibParser.cpp + impl/AvFormatTagReader.cpp + impl/Parser.cpp + impl/TagLibTagReader.cpp impl/Utils.cpp ) diff --git a/src/libs/metadata/impl/AvFormatParser.cpp b/src/libs/metadata/impl/AvFormatParser.cpp deleted file mode 100644 index 6784843d..00000000 --- a/src/libs/metadata/impl/AvFormatParser.cpp +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright (C) 2013 Emeric Poupon - * - * This file is part of LMS. - * - * LMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LMS. If not, see . - */ - -#include "AvFormatParser.hpp" - -#include -#include - -#include "av/IAudioFile.hpp" -#include "utils/ILogger.hpp" -#include "utils/String.hpp" -#include "Utils.hpp" - -namespace MetaData -{ - namespace - { - template - std::optional findFirstValueOfAs(const Av::IAudioFile::MetadataMap& metadataMap, std::initializer_list tags) - { - auto it = std::find_first_of(std::cbegin(metadataMap), std::cend(metadataMap), std::cbegin(tags), std::cend(tags), [](const auto& it, const auto& str) { return it.first == str; }); - if (it == std::cend(metadataMap)) - return std::nullopt; - - return StringUtils::readAs(StringUtils::stringTrim(it->second)); - } - - template <> - std::optional> findFirstValueOfAs(const Av::IAudioFile::MetadataMap& metadataMap, std::initializer_list tags) - { - std::optional str{ findFirstValueOfAs(metadataMap, tags) }; - if (!str) - return std::nullopt; - - const std::vector strUuids{ StringUtils::splitString(*str, "/") }; - std::vector res; - - for (std::string_view strUuid : strUuids) - { - std::optional uuid{ UUID::fromString(strUuid) }; - if (!uuid) - return std::nullopt; - - res.push_back(std::move(*uuid)); - } - - return res; - } - - std::vector getReleaseArtists(const Av::IAudioFile::MetadataMap& metadataMap) - { - std::vector res; - - auto name{ findFirstValueOfAs(metadataMap, {"ALBUM_ARTIST"}) }; - if (!name) - return res; - - auto mbid{ findFirstValueOfAs(metadataMap, {"MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID"}) }; - - return { Artist {mbid, *name, std::nullopt} }; - } - - std::vector getArtists(const Av::IAudioFile::MetadataMap& metadataMap) - { - std::vector artists; - - std::vector artistNames; - if (metadataMap.find("ARTISTS") != metadataMap.end()) - { - artistNames = StringUtils::splitString(metadataMap.find("ARTISTS")->second, "/;"); - } - else if (metadataMap.find("ARTIST") != metadataMap.end()) - { - artistNames = { metadataMap.find("ARTIST")->second }; - } - - auto artistMBIDs{ findFirstValueOfAs>(metadataMap, {"MUSICBRAINZ ARTIST ID", "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ/ARTIST ID"}) }; - - for (std::size_t i{}; i < artistNames.size(); ++i) - { - if (artistMBIDs && artistNames.size() == artistMBIDs->size()) - artists.emplace_back(Artist{ (*artistMBIDs)[i], artistNames[i], std::nullopt }); - else - artists.emplace_back(Artist{ std::nullopt, artistNames[i], std::nullopt }); - } - - return artists; - } - - std::optional getRelease(const Av::IAudioFile::MetadataMap& metadataMap) - { - std::optional res; - - std::optional releaseName{ findFirstValueOfAs(metadataMap, {"ALBUM", "TALB", "WM/ALBUMTITLE"}) }; - if (!releaseName) - return res; - - res.emplace(); - res->name = std::move(*releaseName); - res->mbid = findFirstValueOfAs(metadataMap, { "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ/ALBUM ID" }); - res->artists = getReleaseArtists(metadataMap); - res->mediumCount = findFirstValueOfAs(metadataMap, { "TOTALDISCS", "DISCTOTAL" }); - if (!res->mediumCount) - { - // mediumCount may be encoded as position/count - if (const auto value{ findFirstValueOfAs(metadataMap, {"TPOS", "DISC", "DISK", "DISCNUMBER", "WM/PARTOFSET"}) }) - { - // Expecting 'Number/Total' - const std::vector strings{ StringUtils::splitString(*value, "/") }; - if (strings.size() == 2) - res->mediumCount = StringUtils::readAs(strings[1]); - } - } - - return res; - } - - std::optional getMedium(const Av::IAudioFile::MetadataMap& metadataMap) - { - std::optional res; - res.emplace(); - - res->type = findFirstValueOfAs(metadataMap, { "TMED", "MEDIA", "WM/MEDIA" }).value_or(""); - res->name = findFirstValueOfAs(metadataMap, { "TSST", "DISCSUBTITLE", "SETSUBTITLE" }).value_or(""); - res->trackCount = findFirstValueOfAs(metadataMap, { "TOTALTRACKS", "TRACKTOTAL" }); - if (!res->trackCount) - { - // totalTracks may be encoded as "position/count" - if (const auto value{ findFirstValueOfAs(metadataMap, {"TRCK", "TRACK", "TRACKNUMBER", "TRKN", "WM/TRACKNUMBER"}) }) - { - // Expecting 'Number/Total' - const std::vector strings{ StringUtils::splitString(*value, "/") }; - if (strings.size() == 2) - res->trackCount = StringUtils::readAs(strings[1]); - } - } - - // position may be encoded in TPOS/DISC/DISK as "position/count". Expecting 'Number[/Total]' - res->position = findFirstValueOfAs(metadataMap, { "TPOS", "DISC", "DISK", "DISCNUMBER", "WM/PARTOFSET" }); - res->release = getRelease(metadataMap); - - if (res->type.empty() - && res->name.empty() - && !res->trackCount - && !res->position - && !res->release - && !res->replayGain) - { - res.reset(); - } - - return res; - } - } - - std::optional AvFormatParser::parse(const std::filesystem::path& p, bool debug) - { - Track track; - - try - { - const auto mediaFile{ Av::parseAudioFile(p) }; - - Av::ContainerInfo info{ mediaFile->getContainerInfo() }; - track.duration = info.duration; - track.bitrate = info.bitrate; - track.hasCover = mediaFile->hasAttachedPictures(); - - MetaData::Tags tags; - - const Av::IAudioFile::MetadataMap metadataMap{ mediaFile->getMetaData() }; - - track.artists = getArtists(metadataMap); - track.medium = getMedium(metadataMap); - - for (const auto& [tag, value] : metadataMap) - { - if (debug) - std::cout << "TAG = " << tag << ", VAL = " << value << std::endl; - - if (tag == "TITLE") - track.title = value; - else if (tag == "TRACK") - { - // Expecting 'Number/Total' - track.position = StringUtils::readAs(value); - } - else if (tag == "DATE" - || tag == "YEAR" - || tag == "WM/YEAR") - { - track.date = Utils::parseDate(value); - } - else if (tag == "TDOR" // Original release time (ID3v2 2.4) - || tag == "TORY") // Original release year - { - track.originalDate = Utils::parseDate(value); - } - else if (tag == "ACOUSTID ID") - { - track.acoustID = UUID::fromString(value); - } - else if (tag == "MUSICBRAINZ RELEASE TRACK ID" - || tag == "MUSICBRAINZ_RELEASETRACKID") - { - track.mbid = UUID::fromString(value); - } - else if (tag == "MUSICBRAINZ_TRACKID" - || tag == "MUSICBRAINZ/TRACK ID") - { - track.recordingMBID = UUID::fromString(value); - } - else if (std::find(std::cbegin(_userExtraTags), std::cend(_userExtraTags), tag) != std::cend(_userExtraTags)) - { - const std::vector tagValues{ StringUtils::splitString(value, "/,;") }; - - if (!tagValues.empty()) - { - std::vector values; - std::transform(std::cbegin(tagValues), std::cend(tagValues), std::inserter(values, std::begin(values)), [](std::string_view v) { return std::string{ v }; }); - track.userExtraTags[tag] = std::move(values); - } - } - } - } - catch (Av::Exception& e) - { - return std::nullopt; - } - - return track; - } - -} // namespace MetaData diff --git a/src/libs/metadata/impl/AvFormatTagReader.cpp b/src/libs/metadata/impl/AvFormatTagReader.cpp new file mode 100644 index 00000000..2a9eb4b9 --- /dev/null +++ b/src/libs/metadata/impl/AvFormatTagReader.cpp @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2013 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "AvFormatTagReader.hpp" + +#include +#include + +#include "av/IAudioFile.hpp" +#include "metadata/Exception.hpp" +#include "utils/ILogger.hpp" +#include "utils/String.hpp" +#include "Utils.hpp" + +namespace MetaData +{ + namespace + { + + // Mapping to internal taglib names and/or common alternative custom names + static const std::unordered_map> tagMapping + { + { TagType::AcoustID, { "ACOUSTID_ID", "ACOUSTID ID" } }, + { TagType::Album, { "ALBUM", "TALB", "WM/ALBUMTITLE" } }, + { TagType::AlbumArtist, { "ALBUMARTIST", "ALBUM_ARTIST" } }, + { TagType::AlbumArtistSortOrder, { "ALBUMARTISTSORT", "TSO2" } }, + { TagType::AlbumArtists, { "ALBUMARTISTS" } }, + { TagType::AlbumArtistsSortOrder, { "ALBUMARTISTSSORT" } }, + { TagType::AlbumSortOrder, { "ALBUMSORT" } }, + { TagType::Arranger, { "ARRANGER" } }, + { TagType::Artist, { "ARTIST" } }, + { TagType::ArtistSortOrder, { "ARTISTSORT", "ARTIST-SORT", "WM/ARTISTSORTORDER" } }, + { TagType::Artists, { "ARTISTS", "WM/ARTISTS" } }, + { TagType::ASIN, { "ASIN" } }, + { TagType::Barcode, { "BARCODE", "WM/BARCODE" } }, + { TagType::BPM, { "BPM" } }, + { TagType::CatalogNumber, { "CATALOGNUMBER", "WM/CATALOGNO" } }, + { TagType::Comment, { "COMMENT" } }, + { TagType::Compilation, { "COMPILATION", "TCMP" } }, + { TagType::Composer, { "COMPOSER" } }, + { TagType::Composers, { "COMPOSERS" } }, + { TagType::ComposerSortOrder, { "COMPOSERSORT", "TSOC" } }, + { TagType::ComposersSortOrder, { "COMPOSERSSORT" } }, + { TagType::Conductor, { "CONDUCTOR" } }, + { TagType::ConductorSortOrder, { "CONDUCTORSORT" } }, + { TagType::Conductors, { "CONDUCTORS" } }, + { TagType::ConductorsSortOrder, { "CONDUCTORSSORT" } }, + { TagType::Copyright, { "COPYRIGHT" } }, + { TagType::CopyrightURL, { "COPYRIGHTURL" } }, + { TagType::Date, { "DATE", "YEAR", "WM/YEAR" } }, + { TagType::Director, { "DIRECTOR" } }, + { TagType::DiscNumber, { "TPOS", "DISC", "DISK", "DISCNUMBER", "WM/PARTOFSET" } }, + { TagType::DiscSubtitle, { "TSST", "DISCSUBTITLE", "SETSUBTITLE" } }, + { TagType::EncodedBy, { "ENCODEDBY" } }, + { TagType::Engineer, { "ENGINEER" } }, + { TagType::GaplessPlayback, { "GAPLESSPLAYBACK" } }, + { TagType::Genre, { "GENRE" } }, + { TagType::Grouping, { "GROUPING", "WM/CONTENTGROUPDESCRIPTION" } }, + { TagType::InitialKey, { "INITIALKEY" } }, + { TagType::ISRC, { "ISRC", "WM/ISRC", "TSRC" } }, + { TagType::Language, { "LANGUAGE" } }, + { TagType::License, { "LICENSE" } }, + { TagType::Lyricist, { "LYRICIST" } }, + { TagType::LyricistSortOrder, { "LYRICISTSORT" } }, + { TagType::Lyricists, { "LYRICISTS" } }, + { TagType::LyricistsSortOrder, { "LYRICISTSSORT" } }, + { TagType::Lyrics, { "LYRICS" } }, + { TagType::Media, { "TMED", "MEDIA", "WM/MEDIA" } }, + { TagType::MixDJ, { "DJMIXER" } }, + { TagType::Mixer, { "MIXER" } }, + { TagType::MixerSortOrder, { "MIXERSORT" } }, + { TagType::Mixers, { "MIXERS" } }, + { TagType::MixersSortOrder, { "MIXERSSORT" } }, + { TagType::Mood, { "MOOD" } }, + { TagType::Movement, { "MOVEMENT", "MOVEMENTNAME" } }, + { TagType::MovementCount, { "MOVEMENTCOUNT" } }, + { TagType::MovementNumber, { "MOVEMENTNUMBER" } }, + { TagType::MusicBrainzArtistID, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" } }, + { TagType::MusicBrainzDiscID, { "MUSICBRAINZ_DISCID", "MUSICBRAINZ DISC ID", "MUSICBRAINZ/DISC ID" } }, + { TagType::MusicBrainzOriginalArtistID, { "MUSICBRAINZ_ORIGINALARTISTID", "MUSICBRAINZ ORIGINAL ARTIST ID", "MUSICBRAINZ/ORIGINAL ARTIST ID" } }, + { TagType::MusicBrainzOriginalReleaseID, { "MUSICBRAINZ_ORIGINALRELEASEID", "MUSICBRAINZ ORIGINAL RELEASE ID", "MUSICBRAINZ/ORIGINAL RELEASE ID" } }, + { TagType::MusicBrainzRecordingID, { "MUSICBRAINZ_TRACKID", "MUSICBRAINZ TRACK ID", "MUSICBRAINZ/TRACK ID" } }, + { TagType::MusicBrainzReleaseArtistID, { "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID" } }, + { TagType::MusicBrainzReleaseGroupID, { "MUSICBRAINZ_RELEASEGROUPID", "MUSICBRAINZ RELEASE GROUP ID", "MUSICBRAINZ/RELEASE GROUP ID" } }, + { TagType::MusicBrainzReleaseID, { "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ/ALBUM ID" } }, + { TagType::MusicBrainzTrackID, { "MUSICBRAINZ_RELEASETRACKID", "MUSICBRAINZ RELEASE TRACK ID", "MUSICBRAINZ/RELEASE TRACK ID" } }, + { TagType::MusicBrainzWorkID, { "MUSICBRAINZ_WORKID", "MUSICBRAINZ WORK ID", "MUSICBRAINZ/WORK ID" } }, + { TagType::OriginalArtist, { "ORIGINALARTIST" } }, + { TagType::OriginalFilename, { "ORIGINALFILENAME" } }, + { TagType::OriginalReleaseDate, { "ORIGINALDATE", "TDOR", "WM/ORIGINALRELEASETIME" } }, + { TagType::OriginalReleaseYear, { "ORIGINALYEAR", "TORY", "WM/ORIGINALRELEASEYEAR" } }, + { TagType::Podcast, { "PODCAST" } }, + { TagType::PodcastURL, { "PODCASTURL" } }, + { TagType::Producer, { "PRODUCER" } }, + { TagType::ProducerSortOrder, { "PRODUCERSORTORDER" } }, + { TagType::Producers, { "PRODUCERS" } }, + { TagType::ProducersSortOrder, { "PRODUCERSSORTORDER" } }, + { TagType::RecordLabel, { "LABEL", "PUBLISHER" } }, + { TagType::ReleaseCountry, { "RELEASECOUNTRY" } }, + { TagType::ReleaseDate, { "RELEASEDATE" } }, + { TagType::ReleaseStatus, { "RELEASESTATUS" } }, + { TagType::ReleaseType, { "RELEASETYPE", "MUSICBRAINZ_ALBUMTYPE", "MUSICBRAINZ ALBUM TYPE", "MUSICBRAINZ/ALBUM TYPE" } }, + { TagType::Remixer, { "REMIXER", "MODIFIEDBY", "MIXARTIST" } }, + { TagType::RemixerSortOrder, { "REMIXERSORTORDER", "MIXARTISTSORTORDER" } }, + { TagType::Remixers, { "REMIXERS" } }, + { TagType::RemixersSortOrder, { "REMIXERSSORTORDER", "MIXARTISTSSORTORDER" } }, + { TagType::ReplayGainAlbumGain, { "REPLAYGAIN_ALBUM_GAIN" } }, + { TagType::ReplayGainAlbumPeak, { "REPLAYGAIN_ALBUM_PEAK" } }, + { TagType::ReplayGainAlbumRange, { "REPLAYGAIN_ALBUM_RANGE" } }, + { TagType::ReplayGainReferenceLoudness, { "REPLAYGAIN_REFERENCE_LOUDNESS" } }, + { TagType::ReplayGainTrackGain, { "REPLAYGAIN_TRACK_GAIN" } }, + { TagType::ReplayGainTrackPeak, { "REPLAYGAIN_TRACK_PEAK" } }, + { TagType::ReplayGainTrackRange, { "REPLAYGAIN_TRACK_RANGE" } }, + { TagType::Script, { "SCRIPT", "WM/SCRIPT" } }, + { TagType::ShowWorkAndMovement, { "SHOWWORKMOVEMENT", "SHOWMOVEMENT" } }, + { TagType::Subtitle, { "SUBTITLE" } }, + { TagType::TotalDiscs, { "DISCTOTAL", "TOTALDISCS"} }, + { TagType::TotalTracks, { "TRACKTOTAL", "TOTALTRACKS" } }, + { TagType::TrackNumber, { "TRCK", "TRACK", "TRACKNUMBER", "TRKN", "WM/TRACKNUMBER" } }, + { TagType::TrackTitle, { "TITLE" } }, + { TagType::TrackTitleSortOrder, { "TITLESORT" } }, + { TagType::WorkTitle, { "WORK" } }, + { TagType::Writer, { "WRITER" } }, + }; + } + + AvFormatTagReader::AvFormatTagReader(const std::filesystem::path& p, bool debug) + { + try + { + const auto audioFile{ Av::parseAudioFile(p) }; + + _containerInfo = audioFile->getContainerInfo(); + _metaDataMap = audioFile->getMetaData(); + _hasEmbeddedCover = audioFile->hasAttachedPictures(); + + if (debug && Service::get()->isSeverityActive(Severity::DEBUG)) + { + for (const auto& [key, value] : _metaDataMap) + LMS_LOG(METADATA, DEBUG, "Key = '" << key << "', value = '" << value << "'"); + } + } + catch (Av::Exception& e) + { + throw ParseException{}; + } + } + + void AvFormatTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const + { + auto itTagNames{ tagMapping.find(tag) }; + if (itTagNames == std::cend(tagMapping)) + return; + + for (const std::string& tagName : itTagNames->second) + { + bool visited{}; + + visitTagValues(tagName, [&](std::string_view value) + { + visited = true; + visitor(value); + }); + + if (visited) + break; + } + } + + void AvFormatTagReader::visitTagValues(std::string_view key, TagValueVisitor visitor) const + { + auto itValues{ _metaDataMap.find(std::string{ key }) }; + if (itValues == std::cend(_metaDataMap)) + return; + + visitor(itValues->second); + } + + void AvFormatTagReader::visitPerformerTags(PerformerVisitor visitor) const + { + visitTagValues("PERFORMER", [&](std::string_view value) + { + visitor("", value); + }); + } +} // namespace MetaData diff --git a/src/libs/metadata/impl/AvFormatTagReader.hpp b/src/libs/metadata/impl/AvFormatTagReader.hpp new file mode 100644 index 00000000..d2039eb4 --- /dev/null +++ b/src/libs/metadata/impl/AvFormatTagReader.hpp @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2018 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include + +#include "av/IAudioFile.hpp" +#include "metadata/IParser.hpp" +#include "ITagReader.hpp" + +namespace MetaData +{ + class AvFormatTagReader : public ITagReader + { + public: + AvFormatTagReader(const std::filesystem::path& path, bool debug); + + private: + AvFormatTagReader(const AvFormatTagReader&) = delete; + AvFormatTagReader& operator=(const AvFormatTagReader&) = delete; + + bool hasMultiValuedTags() const override { return false; /* not supported */} + void visitTagValues(TagType tag, TagValueVisitor visitor) const override; + void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override; + void visitPerformerTags(PerformerVisitor visitor) const override; + bool hasEmbeddedCover() const override { return _hasEmbeddedCover; } + + std::chrono::milliseconds getDuration() const override { return _containerInfo.duration; } + std::size_t getBitrate() const override { return _containerInfo.bitrate; } + std::size_t getBitsPerSample() const override { return 0; } + std::size_t getSampleRate() const override { return 0; } + + Av::IAudioFile::MetadataMap _metaDataMap; + Av::ContainerInfo _containerInfo; + bool _hasEmbeddedCover{}; + }; +} // namespace MetaData + diff --git a/src/libs/metadata/impl/Factory.cpp b/src/libs/metadata/impl/Factory.cpp deleted file mode 100644 index d6d1d725..00000000 --- a/src/libs/metadata/impl/Factory.cpp +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2022 Emeric Poupon - * - * This file is part of LMS. - * - * LMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LMS. If not, see . - */ - -#include "metadata/IParser.hpp" - -#include "utils/Exception.hpp" -#include "utils/ILogger.hpp" - -#include "AvFormatParser.hpp" -#include "TagLibParser.hpp" -#include "Utils.hpp" - -namespace MetaData -{ - std::unique_ptr - createParser(ParserType parserType, ParserReadStyle parserReadStyle) - { - switch (parserType) - { - case ParserType::TagLib: - LMS_LOG(METADATA, INFO, "Creating TagLib parser with read style = " << Utils::readStyleToString(parserReadStyle)); - return std::make_unique(parserReadStyle); - case ParserType::AvFormat: - LMS_LOG(METADATA, INFO, "Creating AvFormat parser"); - return std::make_unique(); - } - - throw LmsException {"Unhandled parser type"}; - } -} - diff --git a/src/libs/metadata/impl/ITagReader.hpp b/src/libs/metadata/impl/ITagReader.hpp new file mode 100644 index 00000000..ef0dbf14 --- /dev/null +++ b/src/libs/metadata/impl/ITagReader.hpp @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2018 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +namespace MetaData +{ + // using picard internal names + // see https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html + enum class TagType + { + AcoustID, + AcoustIDFingerprint, + Album, + AlbumArtist, + AlbumArtists, // non standard + AlbumArtistSortOrder, + AlbumArtistsSortOrder, // non standard + AlbumSortOrder, + Arranger, + Artist, + ArtistSortOrder, + Artists, + ASIN, + Barcode, + BPM, + CatalogNumber, + Comment, + Compilation, + Composer, + ComposerSortOrder, + Composers, // non standard + ComposersSortOrder, // non standard + Conductor, + ConductorSortOrder, // non standard + Conductors, // non standard + ConductorsSortOrder, // non standard + Copyright, + CopyrightURL, // non standard + Date, + Director, + DiscNumber, + DiscSubtitle, + EncodedBy, + EncoderSettings, + Engineer, + GaplessPlayback, + Genre, + Grouping, + InitialKey, + ISRC, + Language, + License, + Lyricist, + LyricistSortOrder, // non standard + Lyricists, // non standard + LyricistsSortOrder, // non standard + Lyrics, + Media, + MixDJ, + Mixer, + MixerSortOrder, // non standard + Mixers, // non standard + MixersSortOrder, // non standard + Mood, + Movement, + MovementCount, + MovementNumber, + MusicBrainzArtistID, + MusicBrainzDiscID, + MusicBrainzOriginalArtistID, + MusicBrainzOriginalReleaseID, + MusicBrainzRecordingID, + MusicBrainzReleaseArtistID, + MusicBrainzReleaseGroupID, + MusicBrainzReleaseID, + MusicBrainzTrackID, + MusicBrainzWorkID, + MusicIPFingerprint, + MusicIPPUID, + OriginalAlbum, + OriginalArtist, + OriginalFilename, + OriginalReleaseDate, + OriginalReleaseYear, + Podcast, + PodcastURL, + Producer, + ProducerSortOrder, // non standard + Producers, // non standard + ProducersSortOrder, // non standard + Rating, + RecordLabel, + ReleaseCountry, + ReleaseDate, + ReleaseStatus, + ReleaseType, + Remixer, + RemixerSortOrder, + Remixers, + RemixersSortOrder, + ReplayGainAlbumGain, + ReplayGainAlbumPeak, + ReplayGainAlbumRange, + ReplayGainReferenceLoudness, + ReplayGainTrackGain, + ReplayGainTrackPeak, + ReplayGainTrackRange, + Script, + ShowName, + ShowNameSortOrder, + ShowWorkAndMovement, + Subtitle, + TotalDiscs, + TotalTracks, + TrackNumber, + TrackTitle, + TrackTitleSortOrder, + Website, + WorkTitle, + Writer, + }; + + class ITagReader + { + public: + virtual ~ITagReader() = default; + + virtual bool hasMultiValuedTags() const = 0; + + using TagValueVisitor = std::function; + virtual void visitTagValues(TagType tag, TagValueVisitor visitor) const = 0; + virtual void visitTagValues(std::string_view tag, TagValueVisitor visitor) const = 0; + + using PerformerVisitor = std::function; + virtual void visitPerformerTags(PerformerVisitor visitor) const = 0; + + virtual bool hasEmbeddedCover() const = 0; + + virtual std::chrono::milliseconds getDuration() const = 0; + virtual std::size_t getBitrate() const = 0; + virtual std::size_t getBitsPerSample() const = 0; + virtual std::size_t getSampleRate() const = 0; + }; +} // namespace MetaData diff --git a/src/libs/metadata/impl/Parser.cpp b/src/libs/metadata/impl/Parser.cpp new file mode 100644 index 00000000..616cd303 --- /dev/null +++ b/src/libs/metadata/impl/Parser.cpp @@ -0,0 +1,396 @@ +/* + * Copyright (C) 2016 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "Parser.hpp" + +#include + +#include "metadata/Exception.hpp" +#include "utils/ILogger.hpp" +#include "utils/String.hpp" + +#include "AvFormatTagReader.hpp" +#include "TagLibTagReader.hpp" +#include "Utils.hpp" + +namespace MetaData +{ + namespace + { + void visitTagValues(const ITagReader& tagReader, std::string_view tagType, std::span tagDelimiters, ITagReader::TagValueVisitor visitor) + { + tagReader.visitTagValues(tagType, [&](std::string_view value) + { + auto visitTagIfNonEmpty{ [&](std::string_view tag) + { + tag = StringUtils::stringTrim(tag); + if (!tag.empty()) + visitor(tag); + } }; + + if (!tagReader.hasMultiValuedTags()) + { + for (std::string_view tagDelimiter : tagDelimiters) + { + if (value.find(tagDelimiter) != std::string_view::npos) + { + for (std::string_view splitTag : StringUtils::splitString(value, tagDelimiter)) + visitTagIfNonEmpty(splitTag); + return; + } + } + } + + visitTagIfNonEmpty(value); + + }); + } + + template + std::vector getTagValuesFirstMatchAs(const ITagReader& tagReader, std::initializer_list tagTypes, std::span tagDelimiters) + { + std::vector res; + + for (const TagType tagType : tagTypes) + { + auto addTagIfNonEmpty{ [&res](std::string_view tag) + { + tag = StringUtils::stringTrim(tag); + if (!tag.empty()) + { + std::optional val{ StringUtils::readAs(tag) }; + if (val) + res.emplace_back(std::move(*val)); + } + } }; + + tagReader.visitTagValues(tagType, [&](std::string_view value) + { + if (!tagReader.hasMultiValuedTags()) + { + for (std::string_view tagDelimiter : tagDelimiters) + { + if (value.find(tagDelimiter) != std::string_view::npos) + { + for (std::string_view splitTag : StringUtils::splitString(value, tagDelimiter)) + addTagIfNonEmpty(splitTag); + + return; + } + } + } + + // no delimiter found, or no delimiter to be used + addTagIfNonEmpty(value); + }); + + if (!res.empty()) + break; + } + + return res; + } + + template + std::optional getTagValueFirstMatchAs(const ITagReader& tagReader, std::initializer_list tagTypes) + { + std::optional res; + std::vector values{ getTagValuesFirstMatchAs(tagReader, tagTypes, {} /* don't expect multiple values here */) }; + if (!values.empty()) + res = std::move(values.front()); + + return res; + } + + template + std::vector getTagValuesAs(const ITagReader& tagReader, TagType tagType, std::span tagDelimiters) + { + return getTagValuesFirstMatchAs(tagReader, { tagType }, tagDelimiters); + } + + template + std::optional getTagValueAs(const ITagReader& tagReader, TagType tagType) + { + return getTagValueFirstMatchAs(tagReader, { tagType }); + } + + std::vector getArtists(const ITagReader& tagReader, + std::initializer_list artistTagNames, + std::initializer_list artistSortTagNames, + std::initializer_list artistMBIDTagNames, + std::span artistTagDelimiters + ) + { + std::vector artistNames{ getTagValuesFirstMatchAs(tagReader, artistTagNames, artistTagDelimiters) }; + if (artistNames.empty()) + return {}; + + std::vector artistSortNames{ getTagValuesFirstMatchAs(tagReader, artistSortTagNames, artistTagDelimiters) }; + std::vector artistMBIDs{ getTagValuesFirstMatchAs(tagReader, artistMBIDTagNames, artistTagDelimiters) }; + + std::vector artists; + artists.reserve(artistNames.size()); + + for (std::size_t i{}; i < artistNames.size(); ++i) + { + Artist& artist{ artists.emplace_back(std::move(artistNames[i])) }; + + if (artistNames.size() == artistSortNames.size()) + artist.sortName = std::move(artistSortNames[i]); + if (artistNames.size() == artistMBIDs.size()) + artist.mbid = std::move(artistMBIDs[i]); + } + + return artists; + } + + PerformerContainer getPerformerArtists(const ITagReader& tagReader) + { + PerformerContainer performers; + + tagReader.visitPerformerTags([&](std::string_view role, std::string_view name) + { + // picard stores like this: (see https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#performer) + // We consider we may hit both styles for the same track + if (role.empty()) + { + // "PERFORMER" "artist (role)" + Utils::PerformerArtist performer{ Utils::extractPerformerAndRole(name) }; + StringUtils::capitalize(performer.role); + performers[performer.role].push_back(std::move(performer.artist)); + } + else + { + // "PERFORMER:role", "artist" (MP3) + std::string roleCapitalized{ StringUtils::stringToLower(role) }; + StringUtils::capitalize(roleCapitalized); + performers[roleCapitalized].push_back(Artist{ name }); + } + }); + + return performers; + } + } + + std::unique_ptr createParser(ParserBackend parserBackend, ParserReadStyle parserReadStyle) + { + return std::make_unique(parserBackend, parserReadStyle); + } + + Parser::Parser(ParserBackend parserBackend, ParserReadStyle readStyle) + : _parserBackend{ parserBackend } + , _readStyle{ readStyle } + { + switch (_parserBackend) + { + case ParserBackend::TagLib: + LMS_LOG(METADATA, INFO, "Using TagLib parser with read style = " << Utils::readStyleToString(readStyle)); + break; + + case ParserBackend::AvFormat: + LMS_LOG(METADATA, INFO, "Using AvFormat parser"); + break; + } + } + + std::unique_ptr Parser::parse(const std::filesystem::path& p, bool debug) + { + try + { + std::unique_ptr tagReader; + switch (_parserBackend) + { + case ParserBackend::TagLib: + tagReader = std::make_unique(p, _readStyle, debug); + break; + + case ParserBackend::AvFormat: + tagReader = std::make_unique(p, debug); + break; + } + if (!tagReader) + throw ParseException{ "Unhandled parser backend" }; + + return parse(*tagReader); + } + catch (const Exception& e) + { + LMS_LOG(METADATA, ERROR, "File '" << p.string() << "': parsing failed"); + throw ParseException{}; + } + } + + std::unique_ptr Parser::parse(const ITagReader& tagReader) + { + auto track{ std::make_unique() }; + + processAudioProperties(tagReader, *track); + processTags(tagReader, *track); + + return track; + } + + void Parser::processAudioProperties(const ITagReader& tagReader, Track& track) + { + track.duration = tagReader.getDuration(); + track.bitrate = tagReader.getBitrate(); + } + + void Parser::processTags(const ITagReader& tagReader, Track& track) + { + track.hasCover = tagReader.hasEmbeddedCover(); + + track.title = getTagValueAs(tagReader, TagType::TrackTitle).value_or(""); + track.mbid = getTagValueAs(tagReader, TagType::MusicBrainzTrackID); + track.recordingMBID = getTagValueAs(tagReader, TagType::MusicBrainzRecordingID); + track.acoustID = getTagValueAs(tagReader, TagType::AcoustID); + track.position = getTagValueAs(tagReader, TagType::TrackNumber); // May parse 'Number/Total', that's fine + if (auto dateStr = getTagValueAs(tagReader, TagType::Date)) + { + if (const Wt::WDate date{ Utils::parseDate(*dateStr) }; date.isValid()) + { + track.date = date; + track.year = date.year(); + } + else + { + track.year = Utils::parseYear(*dateStr); + } + } + if (auto dateStr = getTagValueAs(tagReader, TagType::OriginalReleaseDate)) + { + if (const Wt::WDate date{ Utils::parseDate(*dateStr) }; date.isValid()) + { + track.originalDate = date; + track.originalYear = date.year(); + } + else + { + track.originalYear = Utils::parseYear(*dateStr); + } + } + if (auto dateStr = getTagValueAs(tagReader, TagType::OriginalReleaseYear)) + { + track.originalYear = Utils::parseYear(*dateStr); + } + + track.copyright = getTagValueAs(tagReader, TagType::Copyright).value_or(""); + track.copyrightURL = getTagValueAs(tagReader, TagType::CopyrightURL).value_or(""); + track.replayGain = getTagValueAs(tagReader, TagType::ReplayGainTrackGain); + track.artistDisplayName = getTagValueAs(tagReader, TagType::Artist).value_or(""); // TODO join on artists if present + + for (const std::string& userExtraTag : _userExtraTags) + { + visitTagValues(tagReader, userExtraTag, _defaultTagDelimiters, [&](std::string_view value) + { + value = StringUtils::stringTrim(value); + if (!value.empty()) + track.userExtraTags[userExtraTag].push_back(std::string{ value }); + }); + } + + track.genres = getTagValuesAs(tagReader, TagType::Genre, _defaultTagDelimiters); + track.moods = getTagValuesAs(tagReader, TagType::Mood, _defaultTagDelimiters); + track.groupings = getTagValuesAs(tagReader, TagType::Grouping, _defaultTagDelimiters); + track.labels = getTagValuesAs(tagReader, TagType::RecordLabel, _defaultTagDelimiters); + track.languages = getTagValuesAs(tagReader, TagType::Language, _defaultTagDelimiters); + + std::vector artistDelimiters{}; + + track.medium = getMedium(tagReader); + track.artists = getArtists(tagReader, { TagType::Artists, TagType::Artist }, { TagType::ArtistSortOrder }, { TagType::MusicBrainzArtistID }, _artistTagDelimiters); + track.conductorArtists = getArtists(tagReader, { TagType::Conductors, TagType::Conductor }, { TagType::ConductorsSortOrder, TagType::ConductorSortOrder }, {}, _artistTagDelimiters); + track.composerArtists = getArtists(tagReader, { TagType::Composers, TagType::Composer }, { TagType::ComposersSortOrder, TagType::ComposerSortOrder }, {}, _artistTagDelimiters); + track.lyricistArtists = getArtists(tagReader, { TagType::Lyricists, TagType::Lyricist }, { TagType::LyricistsSortOrder, TagType::LyricistSortOrder }, {}, _artistTagDelimiters); + track.mixerArtists = getArtists(tagReader, { TagType::Mixers, TagType::Mixer }, { TagType::MixersSortOrder, TagType::MixerSortOrder }, {}, _artistTagDelimiters); + track.producerArtists = getArtists(tagReader, { TagType::Producers, TagType::Producer }, { TagType::ProducersSortOrder, TagType::ProducerSortOrder }, {}, _artistTagDelimiters); + track.remixerArtists = getArtists(tagReader, { TagType::Remixers, TagType::Remixer }, { TagType::RemixersSortOrder, TagType::RemixerSortOrder }, {}, _artistTagDelimiters); + track.performerArtists = getPerformerArtists(tagReader); // artistDelimiters not supported + + // If a file has date but no year, set it + if (!track.year && track.date.isValid()) + track.year = track.date.year(); + + // If a file has originalDate but no originalYear, set it + if (!track.originalYear && track.originalDate.isValid()) + track.originalYear = track.originalDate.year(); + } + + + std::optional Parser::getMedium(const ITagReader& tagReader) + { + std::optional medium; + medium.emplace(); + + medium->media = getTagValueAs(tagReader, TagType::Media).value_or(""); + medium->name = getTagValueAs(tagReader, TagType::DiscSubtitle).value_or(""); + medium->trackCount = getTagValueAs(tagReader, TagType::TotalTracks); + if (!medium->trackCount) + { + // totalTracks may be encoded as "position/count" + if (const auto value{ getTagValueAs(tagReader, TagType::TrackNumber) }) + { + // Expecting 'Number/Total' + const std::vector strings{ StringUtils::splitString(*value, '/') }; + if (strings.size() == 2) + medium->trackCount = StringUtils::readAs(strings[1]); + } + } + // Expecting 'Number[/Total]' + medium->position = getTagValueAs(tagReader, TagType::DiscNumber); + medium->release = getRelease(tagReader); + medium->replayGain = getTagValueAs(tagReader, TagType::ReplayGainAlbumGain); + + if (medium->isDefault()) + medium.reset(); + + return medium; + } + + std::optional Parser::getRelease(const ITagReader& tagReader) + { + std::optional release; + + auto releaseName{ getTagValueAs(tagReader, TagType::Album) }; + if (!releaseName) + return release; + + release.emplace(); + release->name = std::move(*releaseName); + release->artistDisplayName = getTagValueAs(tagReader, TagType::AlbumArtist).value_or(""); // TODO try to join albumartists if present + release->mbid = getTagValueAs(tagReader, TagType::MusicBrainzReleaseID); + release->artists = getArtists(tagReader, { TagType::AlbumArtists, TagType::AlbumArtist }, { TagType::AlbumArtistsSortOrder, TagType::AlbumArtistSortOrder }, { TagType::MusicBrainzReleaseArtistID }, _artistTagDelimiters); + release->mediumCount = getTagValueAs(tagReader, TagType::TotalDiscs); + if (!release->mediumCount) + { + // mediumCount may be encoded as "position/count" + if (const auto value{ getTagValueAs(tagReader, TagType::DiscNumber) }) + { + // Expecting 'Number/Total' + const std::vector strings{ StringUtils::splitString(*value, '/') }; + if (strings.size() == 2) + release->mediumCount = StringUtils::readAs(strings[1]); + } + } + + release->releaseTypes = getTagValuesAs(tagReader, TagType::ReleaseType, _defaultTagDelimiters); + + return release; + } +} // namespace MetaData \ No newline at end of file diff --git a/src/libs/metadata/impl/Parser.hpp b/src/libs/metadata/impl/Parser.hpp new file mode 100644 index 00000000..6979beba --- /dev/null +++ b/src/libs/metadata/impl/Parser.hpp @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2018 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "metadata/IParser.hpp" +#include "ITagReader.hpp" + +namespace MetaData +{ + class Parser : public IParser + { + public: + Parser(ParserBackend parserBackend = ParserBackend::TagLib, ParserReadStyle readStyle = ParserReadStyle::Average); + + std::unique_ptr parse(const std::filesystem::path& p, bool debug = false) override; + std::unique_ptr parse(const ITagReader& reader); + + private: + void setUserExtraTags(std::span extraTags) override { _userExtraTags.assign(std::cbegin(extraTags), std::cend(extraTags)); } + void setArtistTagDelimiters(std::span delimiters) override { _artistTagDelimiters.assign(std::cbegin(delimiters), std::cend(delimiters)); } + void setDefaultTagDelimiters(std::span delimiters) override { _defaultTagDelimiters.assign(std::cbegin(delimiters), std::cend(delimiters)); } + + void processAudioProperties(const ITagReader& reader, Track& track); + void processTags(const ITagReader& reader, Track& track); + + std::optional getMedium(const ITagReader& tagReader); + std::optional getRelease(const ITagReader& tagReader); + + const ParserBackend _parserBackend; + const ParserReadStyle _readStyle; + + std::vector _userExtraTags; + std::vector _artistTagDelimiters; + std::vector _defaultTagDelimiters; + }; +} // namespace MetaData + diff --git a/src/libs/metadata/impl/TagLibParser.cpp b/src/libs/metadata/impl/TagLibParser.cpp deleted file mode 100644 index 2bfe2f1d..00000000 --- a/src/libs/metadata/impl/TagLibParser.cpp +++ /dev/null @@ -1,521 +0,0 @@ -/* - * Copyright (C) 2016 Emeric Poupon - * - * This file is part of LMS. - * - * LMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LMS. If not, see . - */ - -#include "TagLibParser.hpp" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "utils/IConfig.hpp" -#include "utils/Exception.hpp" -#include "utils/ILogger.hpp" -#include "utils/Service.hpp" -#include "utils/String.hpp" -#include "Utils.hpp" - -namespace MetaData -{ - namespace - { - // TODO use string_views here for values - using TagMap = std::map>; - - template - std::vector getPropertyValuesFirstMatchAs(const TagMap& tags, std::initializer_list keys) - { - std::vector res; - - for (std::string_view key : keys) - { - const auto itValues{ tags.find(std::string {key}) }; - if (itValues == std::cend(tags)) - continue; - - const std::vector& values{ itValues->second }; - if (values.empty()) - continue; - - res.reserve(values.size()); - - for (const auto& value : values) - { - std::optional val{ StringUtils::readAs(value) }; - if (!val) - continue; - - res.emplace_back(std::move(*val)); - } - - break; - } - - return res; - } - - template - std::optional getPropertyValueFirstMatchAs(const TagMap& tags, std::initializer_list keys) - { - std::optional res; - std::vector values{ getPropertyValuesFirstMatchAs(tags, keys) }; - if (!values.empty()) - res = std::move(values.front()); - - return res; - } - - template - std::vector getPropertyValuesAs(const TagMap& tags, std::string_view key) - { - return getPropertyValuesFirstMatchAs(tags, { key }); - } - - template - std::optional getPropertyValueAs(const TagMap& tags, std::string_view key) - { - return getPropertyValueFirstMatchAs(tags, { key }); - } - - std::vector splitAndTrimString(std::string_view str, std::string_view delimiters) - { - std::vector strings{ StringUtils::splitString(str, delimiters) }; - for (std::string_view& s : strings) - s = StringUtils::stringTrim(s); - - return strings; - } - - std::vector getArtists(const TagMap& tags, - std::initializer_list artistTagNames, - std::initializer_list artistSortTagNames, - std::initializer_list artistMBIDTagNames - ) - { - const std::vector artistNames{ getPropertyValuesFirstMatchAs(tags, artistTagNames) }; - if (artistNames.empty()) - return {}; - - std::vector artists; - artists.reserve(artistNames.size()); - std::transform(std::cbegin(artistNames), std::cend(artistNames), std::back_inserter(artists), - [&](std::string_view name) { return Artist{ name }; }); - - { - const std::vector artistSortNames{ getPropertyValuesFirstMatchAs(tags, artistSortTagNames) }; - if (artistSortNames.size() == artists.size()) - { - for (std::size_t i{}; i < artistSortNames.size(); ++i) - artists[i].sortName = artistSortNames[i]; - } - } - - { - const std::vector artistsMBID{ getPropertyValuesFirstMatchAs(tags, artistMBIDTagNames) }; - - if (artistNames.size() == artistsMBID.size()) - { - for (std::size_t i{}; i < artistsMBID.size(); ++i) - artists[i].mbid = artistsMBID[i]; - } - } - - - return artists; - } - - PerformerContainer getPerformerArtists(const TagMap& tags, std::initializer_list artistTagNames) - { - PerformerContainer performers; - - // picard stores like this: (see https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#performer) - // We may hit both styles for the same track - // PERFORMER: artist (role) - if (const std::vector artistNames{ getPropertyValuesFirstMatchAs(tags, artistTagNames) }; !artistNames.empty()) - { - for (std::string_view entry : artistNames) - { - Utils::PerformerArtist performer{ Utils::extractPerformerAndRole(entry) }; - StringUtils::capitalize(performer.role); - performers[performer.role].push_back(std::move(performer.artist)); - } - } - // PERFORMER:role (MP3) - for (const auto& [key, values] : tags) - { - if (key.find("PERFORMER:") == 0) - { - std::string performerStr{ key }; - std::string role; - if (const std::size_t rolePos{ performerStr.find(':') }; rolePos != std::string::npos) - { - role = StringUtils::stringToLower(performerStr.substr(rolePos + 1, performerStr.size() - rolePos + 1)); - StringUtils::capitalize(role); - } - - for (const auto& value : values) - performers[role].push_back(Artist{ value }); - } - } - - return performers; - } - - std::optional getRelease(const TagMap& tags) - { - std::optional release; - - auto releaseName{ getPropertyValueAs(tags, "ALBUM") }; - if (!releaseName) - return release; - - release.emplace(); - release->name = std::move(*releaseName); - release->artistDisplayName = getPropertyValueAs(tags, "ALBUMARTIST").value_or(""); - release->mbid = getPropertyValueFirstMatchAs(tags, { "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ/ALBUM ID" }); - release->artists = getArtists(tags, { "ALBUMARTISTS", "ALBUMARTIST" }, { "ALBUMARTISTSSORT", "ALBUMARTISTSORT" }, { "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID" }); - release->mediumCount = getPropertyValueAs(tags, "DISCTOTAL"); - if (!release->mediumCount) - { - // mediumCount may be encoded as "position/count" - if (const auto value{ getPropertyValueAs(tags, "DISCNUMBER") }) - { - // Expecting 'Number/Total' - const std::vector strings{ StringUtils::splitString(*value, "/") }; - if (strings.size() == 2) - release->mediumCount = StringUtils::readAs(strings[1]); - } - } - - release->releaseTypes = getPropertyValuesFirstMatchAs(tags, { "MUSICBRAINZ_ALBUMTYPE", "RELEASETYPE", "MUSICBRAINZ ALBUM TYPE", "MUSICBRAINZ/ALBUM TYPE" }); - - return release; - } - - std::optional getMedium(const TagMap& tags) - { - std::optional medium; - medium.emplace(); - - medium->type = getPropertyValueAs(tags, "MEDIA").value_or(""); - medium->name = getPropertyValueFirstMatchAs(tags, { "DISCSUBTITLE", "SETSUBTITLE" }).value_or(""); - medium->trackCount = getPropertyValueAs(tags, "TRACKTOTAL"); - if (!medium->trackCount) - { - // totalTracks may be encoded as "position/count" - if (const auto value{ getPropertyValueAs(tags, "TRACKNUMBER") }) - { - // Expecting 'Number/Total' - const std::vector strings{ StringUtils::splitString(*value, "/") }; - if (strings.size() == 2) - medium->trackCount = StringUtils::readAs(strings[1]); - } - } - // Expecting 'Number[/Total]' - medium->position = getPropertyValueAs(tags, "DISCNUMBER"); - medium->release = getRelease(tags); - medium->replayGain = getPropertyValueAs(tags, "REPLAYGAIN_ALBUM_GAIN"); - - if (medium->type.empty() - && medium->name.empty() - && !medium->trackCount - && !medium->position - && !medium->release - && !medium->replayGain) - { - medium.reset(); - } - - return medium; - } - - TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(ParserReadStyle readStyle) - { - switch (readStyle) - { - case ParserReadStyle::Fast: return TagLib::AudioProperties::ReadStyle::Fast; - case ParserReadStyle::Average: return TagLib::AudioProperties::ReadStyle::Average; - case ParserReadStyle::Accurate: return TagLib::AudioProperties::ReadStyle::Accurate; - } - - throw LmsException{ "Cannot convert read style" }; - } - - TagMap constructTagMap(const TagLib::PropertyMap& properties) - { - TagMap tagMap; - - for (const auto& [propertyName, propertyValues] : properties) - { - std::vector& values{ tagMap[propertyName.upper().to8Bit(true)] }; - for (const TagLib::String& propertyValue : propertyValues) - { - std::string trimedValue{ StringUtils::stringTrim(propertyValue.to8Bit(true)) }; - if (!trimedValue.empty()) - values.emplace_back(std::move(trimedValue)); - } - } - - return tagMap; - } - - void mergeTagMaps(TagMap& dst, TagMap&& src) - { - for (auto&& [tag, values] : src) - { - if (dst.find(tag) == std::cend(dst)) - dst[tag] = std::move(values); - } - } - - } - - TagLibParser::TagLibParser(ParserReadStyle readStyle) - : _readStyle{ readStyleToTagLibReadStyle(readStyle) } - { - } - - void TagLibParser::processTag(Track& track, const std::string& tag, const std::vector& values, bool debug) - { - if (debug) - std::cout << "[" << tag << "] = " << StringUtils::joinStrings(values, "*SEP*") << std::endl; - - if (tag.empty() || values.empty()) - return; - - std::string_view value{ values.front() }; - - if (tag == "TITLE") - track.title = value; - else if (tag == "MUSICBRAINZ_RELEASETRACKID" - || tag == "MUSICBRAINZ RELEASE TRACK ID" - || tag == "MUSICBRAINZ/RELEASE TRACK ID") - { - track.mbid = UUID::fromString(value); - } - else if (tag == "MUSICBRAINZ_TRACKID" - || tag == "MUSICBRAINZ TRACK ID" - || tag == "MUSICBRAINZ/TRACK ID") - track.recordingMBID = UUID::fromString(value); - else if (tag == "ACOUSTID_ID") - track.acoustID = UUID::fromString(value); - else if (tag == "TRACKNUMBER") - { - // Expecting 'Number/Total' - track.position = StringUtils::readAs(value); - } - else if (tag == "DATE") - { - if (const Wt::WDate date{ Utils::parseDate(value) }; date.isValid()) - track.date = date; - else if (!track.year) - track.year = Utils::parseYear(value); - } - else if (tag == "YEAR") - track.year = Utils::parseYear(value); - else if (tag == "ORIGINALDATE") - { - if (const Wt::WDate date{ Utils::parseDate(value) }; date.isValid()) - track.originalDate = date; - else if (!track.originalYear) - track.originalYear = Utils::parseYear(value); - } - else if (tag == "ORIGINALYEAR") - track.originalYear = Utils::parseYear(value); - else if (tag == "METADATA_BLOCK_PICTURE") - track.hasCover = true; - else if (tag == "COPYRIGHT") - track.copyright = value; - else if (tag == "COPYRIGHTURL") - track.copyrightURL = value; - else if (tag == "REPLAYGAIN_TRACK_GAIN") - track.replayGain = StringUtils::readAs(value); - else if (tag == "ARTIST") - track.artistDisplayName = value; - else if (std::find(std::cbegin(_userExtraTags), std::cend(_userExtraTags), tag) != std::cend(_userExtraTags)) - { - std::vector tagValues; - for (std::string_view valueList : values) - { - const std::vector splittedValues{ splitAndTrimString(valueList, "/,;") }; // handle possibily bad split tags - for (std::string_view value : splittedValues) - tagValues.push_back(std::string{ value }); - } - - if (!tagValues.empty()) - track.userExtraTags[tag] = std::move(tagValues); - } - } - - std::optional TagLibParser::parse(const std::filesystem::path& p, bool debug) - { - TagLib::FileRef f{ p.string().c_str(), - true, // read audio properties - _readStyle }; - - if (f.isNull()) - { - LMS_LOG(METADATA, ERROR, "File '" << p.string() << "': parsing failed"); - return std::nullopt; - } - - Track track; - - if (const TagLib::AudioProperties* properties{ f.audioProperties() }) - { - track.duration = std::chrono::milliseconds{ properties->lengthInMilliseconds() }; - track.bitrate = static_cast(properties->bitrate() * 1000); - } - else - { - LMS_LOG(METADATA, INFO, "File '" << p.string() << "': no audio properties"); - return std::nullopt; - } - - TagMap tags{ constructTagMap(f.file()->properties()) }; - - auto getAPETags = [&](const TagLib::APE::Tag* apeTag) - { - if (!apeTag) - return; - - mergeTagMaps(tags, constructTagMap(apeTag->properties())); - }; - - // Not that good embedded pictures handling - - // WMA - if (TagLib::ASF::File * asfFile{ dynamic_cast(f.file()) }) - { - const TagLib::ASF::Tag* tag{ asfFile->tag() }; - if (tag) - { - if (tag->attributeListMap().contains("WM/Picture")) - track.hasCover = true; - - for (const auto& [name, attributeList] : tag->attributeListMap()) - { - std::string strName{ StringUtils::stringToUpper(name.to8Bit(true)) }; - if (strName.find("WM/") == 0 || tags.find(strName) != std::cend(tags)) - continue; - - std::vector attributes; - for (const auto& attribute : attributeList) - { - if (attribute.type() == TagLib::ASF::Attribute::AttributeTypes::UnicodeType) - attributes.emplace_back(attribute.toString().to8Bit(true)); - } - - if (!attributes.empty()) - { - if (debug) - std::cout << "ASF property: '" << name << "'" << std::endl; - - tags.emplace(strName, std::move(attributes)); - } - } - } - } - // MP3 - else if (TagLib::MPEG::File * mp3File{ dynamic_cast(f.file()) }) - { - if (mp3File->ID3v2Tag()) - { - const auto& frameListMap{ mp3File->ID3v2Tag()->frameListMap() }; - - if (!frameListMap["APIC"].isEmpty()) - track.hasCover = true; - if (!frameListMap["TSST"].isEmpty()) - tags["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString().to8Bit(true) }; - } - - getAPETags(mp3File->APETag()); - } - //MP4 - else if (TagLib::MP4::File * mp4File{ dynamic_cast(f.file()) }) - { - TagLib::MP4::Item coverItem{ mp4File->tag()->item("covr") }; - TagLib::MP4::CoverArtList coverArtList{ coverItem.toCoverArtList() }; - if (!coverArtList.isEmpty()) - track.hasCover = true; - } - // MPC - else if (TagLib::MPC::File * mpcFile{ dynamic_cast(f.file()) }) - { - getAPETags(mpcFile->APETag()); - } - // WavPack - else if (TagLib::WavPack::File * wavPackFile{ dynamic_cast(f.file()) }) - { - getAPETags(wavPackFile->APETag()); - } - // FLAC - else if (TagLib::FLAC::File * flacFile{ dynamic_cast(f.file()) }) - { - if (!flacFile->pictureList().isEmpty()) - track.hasCover = true; - } - else if (TagLib::Ogg::Vorbis::File * vorbisFile{ dynamic_cast(f.file()) }) - { - if (!vorbisFile->tag()->pictureList().isEmpty()) - track.hasCover = true; - } - else if (TagLib::Ogg::Opus::File * opusFile{ dynamic_cast(f.file()) }) - { - if (!opusFile->tag()->pictureList().isEmpty()) - track.hasCover = true; - } - - track.medium = getMedium(tags); - track.artists = getArtists(tags, { "ARTISTS", "ARTIST" }, { "ARTISTSORT" }, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" }); - track.conductorArtists = getArtists(tags, { "CONDUCTORS", "CONDUCTOR" }, { "CONDUCTORSSORT", "CONDUCTORSORT" }, {}); - track.composerArtists = getArtists(tags, { "COMPOSERS", "COMPOSER" }, { "COMPOSERSSORT", "COMPOSERSORT" }, {}); - track.lyricistArtists = getArtists(tags, { "LYRICISTS", "LYRICIST" }, { "LYRICISTSSORT", "LYRICISTSORT" }, {}); - track.mixerArtists = getArtists(tags, { "MIXERS", "MIXER" }, { "MIXERSSORT", "MIXERSORT" }, {}); - track.producerArtists = getArtists(tags, { "PRODUCERS", "PRODUCER" }, { "PRODUCERSSORT", "PRODUCERSORT" }, {}); - track.remixerArtists = getArtists(tags, { "REMIXERS", "REMIXER", "ModifiedBy" }, { "REMIXERSSORT", "REMIXERSORT" }, {}); - track.performerArtists = getPerformerArtists(tags, { "PERFORMERS", "PERFORMER" }); - - for (const auto& [tag, values] : tags) - processTag(track, tag, values, debug); - - // If a file has date but no year, set it - if (!track.year && track.date.isValid()) - track.year = track.date.year(); - - // If a file has originalDate but no originalYear, set it - if (!track.originalYear && track.originalDate.isValid()) - track.originalYear = track.originalDate.year(); - - return track; - } - -} // namespace MetaData - diff --git a/src/libs/metadata/impl/TagLibParser.hpp b/src/libs/metadata/impl/TagLibParser.hpp deleted file mode 100644 index 1fd27202..00000000 --- a/src/libs/metadata/impl/TagLibParser.hpp +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2018 Emeric Poupon - * - * This file is part of LMS. - * - * LMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LMS. If not, see . - */ - -#pragma once - -#include -#include "metadata/IParser.hpp" - -namespace TagLib -{ - class StringList; -} - -namespace MetaData -{ - -// Parse that makes use of AvFormat -class TagLibParser : public IParser -{ - public: - TagLibParser(ParserReadStyle readStyle); - - private: - std::optional parse(const std::filesystem::path& p, bool debug = false) override; - void processTag(Track& track, const std::string& tag, const std::vector& values, bool debug); - - const TagLib::AudioProperties::ReadStyle _readStyle; -}; - -} // namespace MetaData - diff --git a/src/libs/metadata/impl/TagLibTagReader.cpp b/src/libs/metadata/impl/TagLibTagReader.cpp new file mode 100644 index 00000000..334fbc15 --- /dev/null +++ b/src/libs/metadata/impl/TagLibTagReader.cpp @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2016 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "TagLibTagReader.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "metadata/Exception.hpp" +#include "utils/ILogger.hpp" +#include "utils/String.hpp" + +namespace MetaData +{ + namespace + { + class ParsingFailedException : public Exception {}; + + // Mapping to internal taglib names and/or common alternative custom names + static const std::unordered_map> tagMapping + { + { TagType::AcoustID, { "ACOUSTID_ID", "ACOUSTID ID" } }, + { TagType::Album, { "ALBUM" } }, + { TagType::AlbumArtist, { "ALBUMARTIST" } }, + { TagType::AlbumArtistSortOrder, { "ALBUMARTISTSORT" } }, + { TagType::AlbumArtists, { "ALBUMARTISTS" } }, + { TagType::AlbumArtistsSortOrder, { "ALBUMARTISTSSORT" } }, + { TagType::AlbumSortOrder, { "ALBUMSORT" } }, + { TagType::Arranger, { "ARRANGER" } }, + { TagType::Artist, { "ARTIST" } }, + { TagType::ArtistSortOrder, { "ARTISTSORT" } }, + { TagType::Artists, { "ARTISTS" } }, + { TagType::ASIN, { "ASIN" } }, + { TagType::Barcode, { "BARCODE" } }, + { TagType::BPM, { "BPM" } }, + { TagType::CatalogNumber, { "CATALOGNUMBER" } }, + { TagType::Comment, { "COMMENT" } }, + { TagType::Compilation, { "COMPILATION" } }, + { TagType::Composer, { "COMPOSER" } }, + { TagType::Composers, { "COMPOSERS" } }, + { TagType::ComposerSortOrder, { "COMPOSERSORT" } }, + { TagType::ComposersSortOrder, { "COMPOSERSSORT" } }, + { TagType::Conductor, { "CONDUCTOR" } }, + { TagType::ConductorSortOrder, { "CONDUCTORSORT" } }, + { TagType::Conductors, { "CONDUCTORS" } }, + { TagType::ConductorsSortOrder, { "CONDUCTORSSORT" } }, + { TagType::Copyright, { "COPYRIGHT" } }, + { TagType::CopyrightURL, { "COPYRIGHTURL" } }, + { TagType::Date, { "DATE", "YEAR" } }, + { TagType::Director, { "DIRECTOR" } }, + { TagType::DiscNumber, { "DISCNUMBER", "DISC" } }, + { TagType::DiscSubtitle, { "DISCSUBTITLE", "SETSUBTITLE" } }, + { TagType::EncodedBy, { "ENCODEDBY" } }, + { TagType::Engineer, { "ENGINEER" } }, + { TagType::GaplessPlayback, { "GAPLESSPLAYBACK" } }, + { TagType::Genre, { "GENRE" } }, + { TagType::Grouping, { "GROUPING" } }, + { TagType::InitialKey, { "INITIALKEY" } }, + { TagType::ISRC, { "ISRC" } }, + { TagType::Language, { "LANGUAGE" } }, + { TagType::License, { "LICENSE" } }, + { TagType::Lyricist, { "LYRICIST" } }, + { TagType::LyricistSortOrder, { "LYRICISTSORT" } }, + { TagType::Lyricists, { "LYRICISTS" } }, + { TagType::LyricistsSortOrder, { "LYRICISTSSORT" } }, + { TagType::Lyrics, { "LYRICS" } }, + { TagType::Media, { "MEDIA" } }, + { TagType::MixDJ, { "DJMIXER" } }, + { TagType::Mixer, { "MIXER" } }, + { TagType::MixerSortOrder, { "MIXERSORT" } }, + { TagType::Mixers, { "MIXERS" } }, + { TagType::MixersSortOrder, { "MIXERSSORT" } }, + { TagType::Mood, { "MOOD" } }, + { TagType::Movement, { "MOVEMENT", "MOVEMENTNAME" } }, + { TagType::MovementCount, { "MOVEMENTCOUNT" } }, + { TagType::MovementNumber, { "MOVEMENTNUMBER" } }, + { TagType::MusicBrainzArtistID, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" } }, + { TagType::MusicBrainzDiscID, { "MUSICBRAINZ_DISCID", "MUSICBRAINZ DISC ID", "MUSICBRAINZ/DISC ID" } }, + { TagType::MusicBrainzOriginalArtistID, { "MUSICBRAINZ_ORIGINALARTISTID", "MUSICBRAINZ ORIGINAL ARTIST ID", "MUSICBRAINZ/ORIGINAL ARTIST ID" } }, + { TagType::MusicBrainzOriginalReleaseID, { "MUSICBRAINZ_ORIGINALRELEASEID", "MUSICBRAINZ ORIGINAL RELEASE ID", "MUSICBRAINZ/ORIGINAL RELEASE ID" } }, + { TagType::MusicBrainzRecordingID, { "MUSICBRAINZ_TRACKID", "MUSICBRAINZ TRACK ID", "MUSICBRAINZ/TRACK ID" } }, + { TagType::MusicBrainzReleaseArtistID, { "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID" } }, + { TagType::MusicBrainzReleaseGroupID, { "MUSICBRAINZ_RELEASEGROUPID", "MUSICBRAINZ RELEASE GROUP ID", "MUSICBRAINZ/RELEASE GROUP ID" } }, + { TagType::MusicBrainzReleaseID, { "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ/ALBUM ID" } }, + { TagType::MusicBrainzTrackID, { "MUSICBRAINZ_RELEASETRACKID", "MUSICBRAINZ RELEASE TRACK ID", "MUSICBRAINZ/RELEASE TRACK ID" } }, + { TagType::MusicBrainzWorkID, { "MUSICBRAINZ_WORKID", "MUSICBRAINZ WORK ID", "MUSICBRAINZ/WORK ID" } }, + { TagType::OriginalArtist, { "ORIGINALARTIST" } }, + { TagType::OriginalFilename, { "ORIGINALFILENAME" } }, + { TagType::OriginalReleaseDate, { "ORIGINALDATE" } }, + { TagType::OriginalReleaseYear, { "ORIGINALYEAR" } }, + { TagType::Podcast, { "PODCAST" } }, + { TagType::PodcastURL, { "PODCASTURL" } }, + { TagType::Producer, { "PRODUCER" } }, + { TagType::ProducerSortOrder, { "PRODUCERSORTORDER" } }, + { TagType::Producers, { "PRODUCERS" } }, + { TagType::ProducersSortOrder, { "PRODUCERSSORTORDER" } }, + { TagType::RecordLabel, { "LABEL" } }, + { TagType::ReleaseCountry, { "RELEASECOUNTRY" } }, + { TagType::ReleaseDate, { "RELEASEDATE" } }, + { TagType::ReleaseStatus, { "RELEASESTATUS" } }, + { TagType::ReleaseType, { "RELEASETYPE", "MUSICBRAINZ_ALBUMTYPE", "MUSICBRAINZ ALBUM TYPE", "MUSICBRAINZ/ALBUM TYPE" } }, + { TagType::Remixer, { "REMIXER", "MODIFIEDBY", "MIXARTIST" } }, + { TagType::RemixerSortOrder, { "REMIXERSORTORDER", "MIXARTISTSORTORDER" } }, + { TagType::Remixers, { "REMIXERS" } }, + { TagType::RemixersSortOrder, { "REMIXERSSORTORDER", "MIXARTISTSSORTORDER" } }, + { TagType::ReplayGainAlbumGain, { "REPLAYGAIN_ALBUM_GAIN" } }, + { TagType::ReplayGainAlbumPeak, { "REPLAYGAIN_ALBUM_PEAK" } }, + { TagType::ReplayGainAlbumRange, { "REPLAYGAIN_ALBUM_RANGE" } }, + { TagType::ReplayGainReferenceLoudness, { "REPLAYGAIN_REFERENCE_LOUDNESS" } }, + { TagType::ReplayGainTrackGain, { "REPLAYGAIN_TRACK_GAIN" } }, + { TagType::ReplayGainTrackPeak, { "REPLAYGAIN_TRACK_PEAK" } }, + { TagType::ReplayGainTrackRange, { "REPLAYGAIN_TRACK_RANGE" } }, + { TagType::Script, { "SCRIPT" } }, + { TagType::ShowWorkAndMovement, { "SHOWWORKMOVEMENT", "SHOWMOVEMENT" } }, + { TagType::Subtitle, { "SUBTITLE" } }, + { TagType::TotalDiscs, { "DISCTOTAL", "TOTALDISCS"} }, + { TagType::TotalTracks, { "TRACKTOTAL", "TOTALTRACKS" } }, + { TagType::TrackNumber, { "TRACKNUMBER" } }, + { TagType::TrackTitle, { "TITLE" } }, + { TagType::TrackTitleSortOrder, { "TITLESORT" } }, + { TagType::WorkTitle, { "WORK" } }, + { TagType::Writer, { "WRITER" } }, + }; + + TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(ParserReadStyle readStyle) + { + switch (readStyle) + { + case ParserReadStyle::Fast: return TagLib::AudioProperties::ReadStyle::Fast; + case ParserReadStyle::Average: return TagLib::AudioProperties::ReadStyle::Average; + case ParserReadStyle::Accurate: return TagLib::AudioProperties::ReadStyle::Accurate; + } + + throw LmsException{ "Cannot convert read style" }; + } + + void mergeTagMaps(TagLib::PropertyMap& dst, TagLib::PropertyMap&& src) + { + for (auto&& [tag, values] : src) + { + if (dst.find(tag) == std::cend(dst)) + dst[tag] = std::move(values); + } + } + } + + TagLibTagReader::TagLibTagReader(const std::filesystem::path& p, ParserReadStyle parserReadStyle, bool debug) + : _file{ p.string().c_str() + , true // read audio properties + , readStyleToTagLibReadStyle(parserReadStyle) } + { + if (_file.isNull()) + { + LMS_LOG(METADATA, ERROR, "File '" << p.string() << "': parsing failed"); + throw ParsingFailedException{}; + } + + if (!_file.audioProperties()) + { + LMS_LOG(METADATA, ERROR, "File '" << p.string() << "': no audio properties"); + throw ParsingFailedException{}; + } + + _propertyMap = _file.file()->properties(); + + // Some tags may not be known by TagLib + auto getAPETags = [&](const TagLib::APE::Tag* apeTag) + { + if (!apeTag) + return; + + mergeTagMaps(_propertyMap, apeTag->properties()); + }; + + // Not that good embedded pictures handling + // + get some extra tags that may not be known by taglib + + // WMA + if (TagLib::ASF::File * asfFile{ dynamic_cast(_file.file()) }) + { + if (const TagLib::ASF::Tag * tag{ asfFile->tag() }) + { + if (tag->attributeListMap().contains("WM/Picture")) + _hasEmbeddedCover = true; + + for (const auto& [name, attributeList] : tag->attributeListMap()) + { + if (attributeList.isEmpty()) + continue; + + std::string strName{ StringUtils::stringToUpper(name.to8Bit(true)) }; + if (strName.find("WM/") == 0 || _propertyMap.find(strName) != std::cend(_propertyMap)) + continue; + + TagLib::StringList attributes; + for (const TagLib::ASF::Attribute& attribute : attributeList) + { + if (attribute.type() == TagLib::ASF::Attribute::AttributeTypes::UnicodeType) + attributes.append(attribute.toString()); + } + + if (!attributes.isEmpty()) + _propertyMap[strName] = std::move(attributes); + } + } + } + // MP3 + else if (TagLib::MPEG::File * mp3File{ dynamic_cast(_file.file()) }) + { + if (mp3File->ID3v2Tag()) + { + const auto& frameListMap{ mp3File->ID3v2Tag()->frameListMap() }; + + if (!frameListMap["APIC"].isEmpty()) + _hasEmbeddedCover = true; + + if (!frameListMap["TSST"].isEmpty()) + _propertyMap["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString().to8Bit(true) }; + } + + getAPETags(mp3File->APETag()); + } + //MP4 + else if (TagLib::MP4::File * mp4File{ dynamic_cast(_file.file()) }) + { + TagLib::MP4::Item coverItem{ mp4File->tag()->item("covr") }; + TagLib::MP4::CoverArtList coverArtList{ coverItem.toCoverArtList() }; + if (!coverArtList.isEmpty()) + _hasEmbeddedCover = true; + } + // MPC + else if (TagLib::MPC::File * mpcFile{ dynamic_cast(_file.file()) }) + { + getAPETags(mpcFile->APETag()); + } + // WavPack + else if (TagLib::WavPack::File * wavPackFile{ dynamic_cast(_file.file()) }) + { + getAPETags(wavPackFile->APETag()); + } + // FLAC + else if (TagLib::FLAC::File * flacFile{ dynamic_cast(_file.file()) }) + { + if (!flacFile->pictureList().isEmpty()) + _hasEmbeddedCover = true; + } + else if (TagLib::Ogg::Vorbis::File * vorbisFile{ dynamic_cast(_file.file()) }) + { + if (!vorbisFile->tag()->pictureList().isEmpty()) + _hasEmbeddedCover = true; + } + else if (TagLib::Ogg::Opus::File * opusFile{ dynamic_cast(_file.file()) }) + { + if (!opusFile->tag()->pictureList().isEmpty()) + _hasEmbeddedCover = true; + } + + if (debug && Service::get()->isSeverityActive(Severity::DEBUG)) + { + for (const auto& [key, values] : _propertyMap) + { + for (const auto& value : values) + LMS_LOG(METADATA, DEBUG, "Key = '" << key << "', value = '" << value.to8Bit(true) << "'"); + } + } + + _hasMultiValuedTags = std::any_of(std::cbegin(_propertyMap), std::cend(_propertyMap), [](const auto& entry) { return entry.second.size() > 1; }); + } + + void TagLibTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const + { + auto itTagNames{ tagMapping.find(tag) }; + if (itTagNames == std::cend(tagMapping)) + return; + + for (const std::string& tagName : itTagNames->second) + { + bool visited{}; + + visitTagValues(tagName, [&](std::string_view value) + { + visited = true; + visitor(value); + }); + + if (visited) + break; + } + } + + void TagLibTagReader::visitTagValues(std::string_view tag, TagValueVisitor visitor) const + { + TagLib::String key{ tag.data() /* assume null terminated */, TagLib::String::Type::UTF8 }; + + auto itValues{ _propertyMap.find(key) }; + if (itValues == std::cend(_propertyMap)) + return; + + for (const TagLib::String& value : itValues->second) + visitor(value.to8Bit(true)); + } + + void TagLibTagReader::visitPerformerTags(PerformerVisitor visitor) const + { + visitTagValues("PERFORMER", [&](std::string_view value) + { + visitor("", value); + }); + + for (const auto& [key, values] : _propertyMap) + { + if (key.startsWith("PERFORMER:")) // startsWith is not case sensitive + { + std::string performerStr{ key.to8Bit(true) }; + const std::size_t rolePos{ performerStr.find(':') }; + assert(rolePos != std::string::npos); + + std::string_view role{ std::string_view{ performerStr }.substr(rolePos + 1) }; + for (const TagLib::String& value : values) + { + const std::string name{ value.to8Bit(true) }; + visitor(role, name); + } + } + } + } + + std::chrono::milliseconds TagLibTagReader::getDuration() const + { + return std::chrono::milliseconds{ _file.audioProperties()->lengthInMilliseconds() }; + } + + std::size_t TagLibTagReader::getBitrate() const + { + return static_cast(_file.audioProperties()->bitrate() * 1000); + } + + std::size_t TagLibTagReader::getBitsPerSample() const + { + return 0; // TODO + } + + std::size_t TagLibTagReader::getSampleRate() const + { + return static_cast(_file.audioProperties()->sampleRate()); + } +} // namespace MetaData diff --git a/src/libs/metadata/impl/TagLibTagReader.hpp b/src/libs/metadata/impl/TagLibTagReader.hpp new file mode 100644 index 00000000..faf535a3 --- /dev/null +++ b/src/libs/metadata/impl/TagLibTagReader.hpp @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2018 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include + +#include +#include + +#include "metadata/IParser.hpp" +#include "ITagReader.hpp" + +namespace MetaData +{ + class TagLibTagReader : public ITagReader + { + public: + TagLibTagReader(const std::filesystem::path& path, ParserReadStyle parserReadStyle, bool debug); + + private: + TagLibTagReader(const TagLibTagReader&) = delete; + TagLibTagReader& operator=(const TagLibTagReader&) = delete; + + bool hasMultiValuedTags() const override { return _hasMultiValuedTags; } + void visitTagValues(TagType tag, TagValueVisitor visitor) const override; + void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override; + void visitPerformerTags(PerformerVisitor visitor) const override; + bool hasEmbeddedCover() const override { return _hasEmbeddedCover; } + + std::chrono::milliseconds getDuration() const override; + std::size_t getBitrate() const override; + std::size_t getBitsPerSample() const override; + std::size_t getSampleRate() const override; + + TagLib::FileRef _file; + TagLib::PropertyMap _propertyMap; // case-insensitive keys + bool _hasEmbeddedCover{}; + bool _hasMultiValuedTags{}; + }; +} // namespace MetaData diff --git a/src/libs/metadata/impl/AvFormatParser.hpp b/src/libs/metadata/include/metadata/Exception.hpp similarity index 71% rename from src/libs/metadata/impl/AvFormatParser.hpp rename to src/libs/metadata/include/metadata/Exception.hpp index 009d4c2e..5559b18f 100644 --- a/src/libs/metadata/impl/AvFormatParser.hpp +++ b/src/libs/metadata/include/metadata/Exception.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018 Emeric Poupon + * Copyright (C) 2024 Emeric Poupon * * This file is part of LMS. * @@ -19,17 +19,19 @@ #pragma once -#include "metadata/IParser.hpp" +#include "utils/Exception.hpp" namespace MetaData { + class Exception : public LmsException + { + public: + using LmsException::LmsException; + }; -// Parse that makes use of AvFormat -class AvFormatParser : public IParser -{ - public: - std::optional parse(const std::filesystem::path& p, bool debug = false) override; -}; - -} // namespace MetaData - + class ParseException : public Exception + { + public: + using Exception::Exception; + }; +} \ No newline at end of file diff --git a/src/libs/metadata/include/metadata/IParser.hpp b/src/libs/metadata/include/metadata/IParser.hpp index cbd51aa4..6dfc6e10 100644 --- a/src/libs/metadata/include/metadata/IParser.hpp +++ b/src/libs/metadata/include/metadata/IParser.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,8 @@ namespace MetaData Artist(std::string_view _name) : name{ _name } {} Artist(std::optional _mbid, std::string_view _name, std::optional _sortName) : mbid{ std::move(_mbid) }, name{ _name }, sortName{ std::move(_sortName) } {} + + bool operator<=>(const Artist&) const = default; }; using PerformerContainer = std::map>; @@ -56,16 +59,26 @@ namespace MetaData std::vector artists; std::optional mediumCount; std::vector releaseTypes; + + bool operator<=>(const Release&) const = default; }; struct Medium { - std::string type; // CD, etc. + std::string media; // CD, etc. std::string name; std::optional release; std::optional position; // in release std::optional trackCount; std::optional replayGain; + + bool operator<=>(const Medium&) const = default; + + bool isDefault() const + { + static Medium defaultMedium; + return *this == defaultMedium; + } }; struct Track @@ -75,9 +88,10 @@ namespace MetaData std::string title; std::optional medium; std::optional position; // in medium - std::vector grouping; + std::vector groupings; std::vector genres; std::vector moods; + std::vector labels; std::vector languages; Tags userExtraTags; std::chrono::milliseconds duration{}; @@ -107,15 +121,14 @@ namespace MetaData public: virtual ~IParser() = default; - virtual std::optional parse(const std::filesystem::path& p, bool debug = false) = 0; + virtual std::unique_ptr parse(const std::filesystem::path& p, bool debug = false) = 0; - void setUserExtraTags(const std::vector& extraTags) { _userExtraTags = std::vector(extraTags.cbegin(), extraTags.cend()); } - - protected: - std::vector _userExtraTags; + virtual void setUserExtraTags(std::span extraTags) = 0; + virtual void setArtistTagDelimiters(std::span delimiters) = 0; + virtual void setDefaultTagDelimiters(std::span delimiters) = 0; }; - enum class ParserType + enum class ParserBackend { TagLib, AvFormat, @@ -127,5 +140,5 @@ namespace MetaData Average, Accurate, }; - std::unique_ptr createParser(ParserType parserType, ParserReadStyle parserReadStyle); + std::unique_ptr createParser(ParserBackend parserBackend, ParserReadStyle parserReadStyle); } // namespace MetaData diff --git a/src/libs/metadata/test/CMakeLists.txt b/src/libs/metadata/test/CMakeLists.txt index b1c57bee..964ddd4f 100644 --- a/src/libs/metadata/test/CMakeLists.txt +++ b/src/libs/metadata/test/CMakeLists.txt @@ -2,6 +2,7 @@ include(GoogleTest) add_executable(test-metadata Metadata.cpp + Parser.cpp Utils.cpp ) diff --git a/src/libs/metadata/test/Parser.cpp b/src/libs/metadata/test/Parser.cpp new file mode 100644 index 00000000..4fbcced0 --- /dev/null +++ b/src/libs/metadata/test/Parser.cpp @@ -0,0 +1,265 @@ +/* + * Copyright (C) 2024 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include + +#include "TestTagReader.hpp" +#include "Parser.hpp" + +namespace MetaData +{ + TEST(Parser, generalTest) + { + Parser parser; + TestTagReader testTags{ + { + { TagType::AcoustID, { "e987a441-e134-4960-8019-274eddacc418" } }, + { TagType::Album, { "MyAlbum" } }, + { TagType::Artist, { "MyArtist1 & MyArtist2" } }, + { TagType::Artists, { "MyArtist1", "MyArtist2" } }, + { TagType::ArtistSortOrder, { "MyArtist1SortName", "MyArtist2SortName" } }, + { TagType::AlbumArtist, { "MyAlbumArtist1 & MyAlbumArtist2" } }, + { TagType::AlbumArtists, { "MyAlbumArtist1", "MyAlbumArtist2" } }, + { TagType::AlbumArtistsSortOrder, { "MyAlbumArtist1SortName", "MyAlbumArtist2SortName" } }, + { TagType::Composer, { "MyComposer1", "MyComposer2" } }, + { TagType::ComposerSortOrder, { "MyComposerSortOrder1", "MyComposerSortOrder2" } }, + { TagType::Conductor, { "MyConductor1", "MyConductor2" } }, + { TagType::Copyright, { "MyCopyright" } }, + { TagType::CopyrightURL, { "MyCopyrightURL" } }, + { TagType::Date, { "2020/03/04" } }, + { TagType::DiscNumber, { "2" } }, + { TagType::DiscSubtitle, { "MySubtitle" } }, + { TagType::Genre, { "Genre1", "Genre2" } }, + { TagType::Grouping, { "Grouping1", "Grouping2" } }, + { TagType::Media, { "CD" } }, + { TagType::Mixer, { "MyMixer1", "MyMixer2" } }, + { TagType::Mood, { "Mood1", "Mood2" } }, + { TagType::MusicBrainzArtistID, { "9d2e0c8c-8c5e-4372-a061-590955eaeaae", "5e2cf87f-c8d7-4504-8a86-954dc0840229" } }, + { TagType::MusicBrainzTrackID, { "0afb190a-6735-46df-a16d-199f48206e4a" } }, + { TagType::MusicBrainzReleaseArtistID, { "6fbf097c-1487-43e8-874b-50dd074398a7", "5ed3d6b3-2aed-4a03-828c-3c4d4f7406e1" } }, + { TagType::MusicBrainzReleaseID, { "3fa39992-b786-4585-a70e-85d5cc15ef69" } }, + { TagType::MusicBrainzRecordingID, { "bd3fc666-89de-4ac8-93f6-2dbf028ad8d5" } }, + { TagType::Producer, { "MyProducer1", "MyProducer2" } }, + { TagType::Remixer, { "MyRemixer1", "MyRemixer2" } }, + { TagType::RecordLabel, { "Label1", "Label2" } }, + { TagType::Language, { "Language1", "Language2" } }, + { TagType::Lyricist, { "MyLyricist1", "MyLyricist2" } }, + { TagType::OriginalReleaseDate, { "2019/02/03" } }, + { TagType::ReleaseType, {"Album", "Compilation"} }, + { TagType::ReplayGainTrackGain, {"-0.33"} }, + { TagType::ReplayGainAlbumGain, {"-0.5"} }, + { TagType::TrackTitle, {"MyTitle"} }, + { TagType::TrackNumber, { "7" } }, + { TagType::TotalTracks, { "12" } }, + { TagType::TotalDiscs, { "3" } }, + } + , + { + { "RoleA", { "MyPerformer1ForRoleA", "MyPerformer2ForRoleA" } }, + { "RoleB", { "MyPerformer1ForRoleB", "MyPerformer2ForRoleB" } } + }, + { + { "MY_AWESOME_TAG_A", { "MyTagValue1ForTagA", "MyTagValue2ForTagA" } }, + { "MY_AWESOME_TAG_B", { "MyTagValue1ForTagB", "MyTagValue2ForTagB" } } + } + }; + + static_cast(parser).setUserExtraTags(std::vector{ "MY_AWESOME_TAG_A", "MY_AWESOME_TAG_B", "MY_AWESOME_MISSING_TAG" }); + + std::unique_ptr track{ parser.parse(testTags) }; + + EXPECT_EQ(track->acoustID, UUID::fromString("e987a441-e134-4960-8019-274eddacc418")); + EXPECT_EQ(track->artistDisplayName, "MyArtist1 & MyArtist2"); + ASSERT_EQ(track->artists.size(), 2); + EXPECT_EQ(track->artists[0].name, "MyArtist1"); + EXPECT_EQ(track->artists[0].sortName, "MyArtist1SortName"); + EXPECT_EQ(track->artists[0].mbid, UUID::fromString("9d2e0c8c-8c5e-4372-a061-590955eaeaae")); + EXPECT_EQ(track->artists[1].name, "MyArtist2"); + EXPECT_EQ(track->artists[1].sortName, "MyArtist2SortName"); + EXPECT_EQ(track->artists[1].mbid, UUID::fromString("5e2cf87f-c8d7-4504-8a86-954dc0840229")); + EXPECT_EQ(track->bitrate, TestTagReader::trackBitrate); + ASSERT_EQ(track->composerArtists.size(), 2); + EXPECT_EQ(track->composerArtists[0].name, "MyComposer1"); + EXPECT_EQ(track->composerArtists[0].sortName, "MyComposerSortOrder1"); + EXPECT_EQ(track->composerArtists[1].name, "MyComposer2"); + EXPECT_EQ(track->composerArtists[1].sortName, "MyComposerSortOrder2"); + ASSERT_EQ(track->conductorArtists.size(), 2); + EXPECT_EQ(track->conductorArtists[0].name, "MyConductor1"); + EXPECT_EQ(track->conductorArtists[1].name, "MyConductor2"); + EXPECT_EQ(track->copyright, "MyCopyright"); + EXPECT_EQ(track->copyrightURL, "MyCopyrightURL"); + ASSERT_TRUE(track->date.isValid()); + EXPECT_EQ(track->date.year(), 2020); + EXPECT_EQ(track->date.month(), 3); + EXPECT_EQ(track->date.day(), 4); + EXPECT_EQ(track->duration, TestTagReader::trackDuration); + EXPECT_FALSE(track->hasCover); + ASSERT_EQ(track->genres.size(), 2); + EXPECT_EQ(track->genres[0], "Genre1"); + EXPECT_EQ(track->genres[1], "Genre2"); + ASSERT_EQ(track->groupings.size(), 2); + EXPECT_EQ(track->groupings[0], "Grouping1"); + EXPECT_EQ(track->groupings[1], "Grouping2"); + ASSERT_EQ(track->labels.size(), 2); + EXPECT_EQ(track->labels[0], "Label1"); + EXPECT_EQ(track->labels[1], "Label2"); + ASSERT_EQ(track->languages.size(), 2); + EXPECT_EQ(track->languages[0], "Language1"); + EXPECT_EQ(track->languages[1], "Language2"); + ASSERT_EQ(track->lyricistArtists.size(), 2); + EXPECT_EQ(track->lyricistArtists[0].name, "MyLyricist1"); + EXPECT_EQ(track->lyricistArtists[1].name, "MyLyricist2"); + ASSERT_TRUE(track->mbid.has_value()); + EXPECT_EQ(track->mbid.value(), UUID::fromString("0afb190a-6735-46df-a16d-199f48206e4a")); + ASSERT_EQ(track->mixerArtists.size(), 2); + EXPECT_EQ(track->mixerArtists[0].name, "MyMixer1"); + EXPECT_EQ(track->mixerArtists[1].name, "MyMixer2"); + ASSERT_EQ(track->moods.size(), 2); + EXPECT_EQ(track->moods[0], "Mood1"); + EXPECT_EQ(track->moods[1], "Mood2"); + ASSERT_TRUE(track->originalDate.isValid()); + EXPECT_EQ(track->originalDate.year(), 2019); + EXPECT_EQ(track->originalDate.month(), 2); + EXPECT_EQ(track->originalDate.day(), 3); + ASSERT_TRUE(track->originalYear.has_value()); + EXPECT_EQ(track->originalYear.value(), 2019); + ASSERT_TRUE(track->performerArtists.contains("Rolea")); + ASSERT_EQ(track->performerArtists["Rolea"].size(), 2); + EXPECT_EQ(track->performerArtists["Rolea"][0].name, "MyPerformer1ForRoleA"); + EXPECT_EQ(track->performerArtists["Rolea"][1].name, "MyPerformer2ForRoleA"); + ASSERT_EQ(track->performerArtists["Roleb"].size(), 2); + EXPECT_EQ(track->performerArtists["Roleb"][0].name, "MyPerformer1ForRoleB"); + EXPECT_EQ(track->performerArtists["Roleb"][1].name, "MyPerformer2ForRoleB"); + ASSERT_TRUE(track->position.has_value()); + EXPECT_EQ(track->position.value(), 7); + ASSERT_EQ(track->producerArtists.size(), 2); + EXPECT_EQ(track->producerArtists[0].name, "MyProducer1"); + EXPECT_EQ(track->producerArtists[1].name, "MyProducer2"); + ASSERT_TRUE(track->recordingMBID.has_value()); + EXPECT_EQ(track->recordingMBID.value(), UUID::fromString("bd3fc666-89de-4ac8-93f6-2dbf028ad8d5")); + ASSERT_TRUE(track->replayGain.has_value()); + EXPECT_FLOAT_EQ(track->replayGain.value(), -0.33); + ASSERT_EQ(track->remixerArtists.size(), 2); + EXPECT_EQ(track->remixerArtists[0].name, "MyRemixer1"); + EXPECT_EQ(track->remixerArtists[1].name, "MyRemixer2"); + EXPECT_EQ(track->title, "MyTitle"); + ASSERT_EQ(track->userExtraTags["MY_AWESOME_TAG_A"].size(), 2); + EXPECT_EQ(track->userExtraTags["MY_AWESOME_TAG_A"][0], "MyTagValue1ForTagA"); + EXPECT_EQ(track->userExtraTags["MY_AWESOME_TAG_A"][1], "MyTagValue2ForTagA"); + ASSERT_EQ(track->userExtraTags["MY_AWESOME_TAG_B"].size(), 2); + EXPECT_EQ(track->userExtraTags["MY_AWESOME_TAG_B"][0], "MyTagValue1ForTagB"); + EXPECT_EQ(track->userExtraTags["MY_AWESOME_TAG_B"][1], "MyTagValue2ForTagB"); + ASSERT_TRUE(track->year.has_value()); + EXPECT_EQ(track->year.value(), 2020); + + // Medium + ASSERT_TRUE(track->medium.has_value()); + EXPECT_EQ(track->medium->media, "CD"); + EXPECT_EQ(track->medium->name, "MySubtitle"); + ASSERT_TRUE(track->medium->position.has_value()); + EXPECT_EQ(track->medium->position.value(), 2); + ASSERT_TRUE(track->medium->replayGain.has_value()); + EXPECT_FLOAT_EQ(track->medium->replayGain.value(), -0.5); + ASSERT_TRUE(track->medium->trackCount.has_value()); + EXPECT_EQ(track->medium->trackCount.value(), 12); + + // Release + ASSERT_TRUE(track->medium->release.has_value()); + EXPECT_EQ(track->medium->release->artistDisplayName, "MyAlbumArtist1 & MyAlbumArtist2"); + ASSERT_EQ(track->medium->release->artists.size(), 2); + EXPECT_EQ(track->medium->release->artists[0].name, "MyAlbumArtist1"); + EXPECT_EQ(track->medium->release->artists[0].sortName, "MyAlbumArtist1SortName"); + EXPECT_EQ(track->medium->release->artists[0].mbid, UUID::fromString("6fbf097c-1487-43e8-874b-50dd074398a7")); + EXPECT_EQ(track->medium->release->artists[1].name, "MyAlbumArtist2"); + EXPECT_EQ(track->medium->release->artists[1].sortName, "MyAlbumArtist2SortName"); + EXPECT_EQ(track->medium->release->artists[1].mbid, UUID::fromString("5ed3d6b3-2aed-4a03-828c-3c4d4f7406e1")); + ASSERT_TRUE(track->medium->release->mbid.has_value()); + EXPECT_EQ(track->medium->release->mbid.value(), UUID::fromString("3fa39992-b786-4585-a70e-85d5cc15ef69")); + EXPECT_EQ(track->medium->release->mediumCount, 3); + EXPECT_EQ(track->medium->release->name, "MyAlbum"); + { + std::vector expectedReleaseTypes{ "Album", "Compilation" }; + EXPECT_EQ(track->medium->release->releaseTypes, expectedReleaseTypes); + } + } + + TEST(Parser, trim) + { + const TestTagReader testTags{ + { + { TagType::Genre, { "Genre1 ", " Genre2", " Genre3 " } }, + } + }; + + std::unique_ptr track{ Parser{}.parse(testTags) }; + + ASSERT_EQ(track->genres.size(), 3); + EXPECT_EQ(track->genres[0], "Genre1"); + EXPECT_EQ(track->genres[1], "Genre2"); + EXPECT_EQ(track->genres[2], "Genre3"); + } + + TEST(Parser, customDelimiters) + { + const TestTagReader testTags{ + { + { TagType::Genre, { "Genre1; Genre2" } }, + { TagType::Language, { " Lang1 ; Lang2 ; " } }, + { TagType::Artist, { " This / is ; One Artist \\ Other Artist " } }, + } + }; + + Parser parser; + static_cast(parser).setDefaultTagDelimiters(std::vector{ " ; " }); + static_cast(parser).setArtistTagDelimiters(std::vector{ " \\ ", " / " }); + std::unique_ptr track{ parser.parse(testTags) }; + + ASSERT_EQ(track->genres.size(), 1); + EXPECT_EQ(track->genres[0], "Genre1; Genre2"); + ASSERT_EQ(track->languages.size(), 2); + EXPECT_EQ(track->languages[0], "Lang1"); + EXPECT_EQ(track->languages[1], "Lang2"); + ASSERT_EQ(track->artists.size(), 2); + EXPECT_EQ(track->artists[0].name, "This / is ; One Artist"); + EXPECT_EQ(track->artists[1].name, "Other Artist"); + EXPECT_EQ(track->artistDisplayName, "This / is ; One Artist \\ Other Artist"); + } + + TEST(Parser, customDelimiters_notWithMultiValuedTags) + { + const TestTagReader testTags{ + { + { TagType::Genre, { "Genre1 ; Genre2" } }, + { TagType::Language, { "Lang1", "Lang2" } }, + } + }; + + Parser parser; + static_cast(parser).setDefaultTagDelimiters(std::vector{ " ; " }); + std::unique_ptr track{ parser.parse(testTags) }; + + ASSERT_EQ(track->genres.size(), 1); + EXPECT_EQ(track->genres[0], "Genre1 ; Genre2"); + ASSERT_EQ(track->languages.size(), 2); + EXPECT_EQ(track->languages[0], "Lang1"); + EXPECT_EQ(track->languages[1], "Lang2"); + } +} diff --git a/src/libs/metadata/test/TestTagReader.hpp b/src/libs/metadata/test/TestTagReader.hpp new file mode 100644 index 00000000..23d8b8ae --- /dev/null +++ b/src/libs/metadata/test/TestTagReader.hpp @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2024 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include + +#include "Parser.hpp" + +namespace MetaData +{ + namespace + { + template + bool tagMapHasMultiValuedTags(const TagMapType& m) + { + return std::any_of(std::cbegin(m), std::cend(m), [](const auto& tagPair) + { + return tagPair.second.size() > 1; + }); + } + } + + class TestTagReader : public ITagReader + { + public: + static constexpr std::chrono::milliseconds trackDuration{ 180 }; + static constexpr std::size_t trackBitrate{ 128000 }; + static constexpr std::size_t trackBitsPerSample{ 16 }; + static constexpr std::size_t trackSampleRate{ 44000 }; + + using Tags = std::unordered_map>; + using Performers = std::unordered_map>; + using ExtraUserTags = std::unordered_map>; + TestTagReader(Tags&& tags, Performers&& performers = {}, ExtraUserTags&& extraUserTags = {}) + : _tags{ std::move(tags) } + , _performers{ std::move(performers) } + , _extraUserTags{ std::move(extraUserTags) } + { + _hasMultiValuedTags = tagMapHasMultiValuedTags(_tags) + || tagMapHasMultiValuedTags(_performers) + || tagMapHasMultiValuedTags(_extraUserTags); + } + + bool hasMultiValuedTags() const override + { + return _hasMultiValuedTags; + } + + void visitTagValues(TagType tag, TagValueVisitor visitor) const override + { + auto itValues{ _tags.find(tag) }; + if (itValues != std::cend(_tags)) + { + for (std::string_view value : itValues->second) + visitor(value); + } + } + void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override + { + auto itValues{ _extraUserTags.find(tag) }; + if (itValues == std::cend(_extraUserTags)) + return; + + for (std::string_view value : itValues->second) + visitor(value); + } + + void visitPerformerTags(PerformerVisitor visitor) const override + { + for (const auto& [role, names] : _performers) + { + for (const auto& name : names) + visitor(role, name); + } + } + + bool hasEmbeddedCover() const override { return false; }; + + std::chrono::milliseconds getDuration() const override { return trackDuration; } + std::size_t getBitrate() const override { return trackBitrate; } + std::size_t getBitsPerSample() const override { return trackBitsPerSample; } + std::size_t getSampleRate() const override { return trackSampleRate; } + + private: + const Tags _tags; + const Performers _performers; + const ExtraUserTags _extraUserTags; + bool _hasMultiValuedTags; + }; +} \ No newline at end of file diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp index 47f2b467..843b8792 100644 --- a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp +++ b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp @@ -19,7 +19,6 @@ #include "ScanStepScanFiles.hpp" -#include "metadata/IParser.hpp" #include "database/Artist.hpp" #include "database/Cluster.hpp" #include "database/Db.hpp" @@ -29,15 +28,17 @@ #include "database/Track.hpp" #include "database/TrackFeatures.hpp" #include "database/TrackArtistLink.hpp" +#include "metadata/Exception.hpp" +#include "metadata/IParser.hpp" #include "utils/Exception.hpp" #include "utils/IConfig.hpp" #include "utils/ILogger.hpp" #include "utils/Path.hpp" -using namespace Database; - namespace Scanner { + using namespace Database; + namespace { Artist::pointer createArtist(Session& session, const MetaData::Artist& artistInfo) @@ -179,25 +180,34 @@ namespace Scanner return Release::pointer{}; } - std::vector getOrCreateClusters(Session& session, const MetaData::Tags& tags) + std::vector getOrCreateClusters(Session& session, const MetaData::Track& track) { std::vector clusters; - for (const auto& [tag, values] : tags) + auto getOrCreateClusters {[&](std::string tag, std::span values) { auto clusterType = ClusterType::find(session, tag); if (!clusterType) clusterType = session.create(tag); - for (const auto& clusterName : values) + for (const auto& value : values) { - auto cluster = clusterType->getCluster(clusterName); + auto cluster{ clusterType->getCluster(value) }; if (!cluster) - cluster = session.create(clusterType, clusterName); + cluster = session.create(clusterType, value); clusters.push_back(cluster); } - } + }}; + + // TODO: migrate these fields in dedicated tables in DB + getOrCreateClusters("GENRE", track.genres); + getOrCreateClusters("MOOD", track.genres); + getOrCreateClusters("LANGUAGE", track.languages); + getOrCreateClusters("GROUPING", track.groupings); + + for (const auto& [tag, values] : track.userExtraTags) + getOrCreateClusters(tag, values); return clusters; } @@ -232,7 +242,7 @@ namespace Scanner , _scanContextRunner{ _scanContext, threadCount } {} - void ScanStepScanFiles::MetadataScanQueue::pushScanRequest(const std::filesystem::path path) + void ScanStepScanFiles::MetadataScanQueue::pushScanRequest(const std::filesystem::path& path) { { std::scoped_lock lock{ _mutex }; @@ -241,12 +251,21 @@ namespace Scanner _scanContext.post([=, this] { - std::optional trackMetaData{ _metadataParser.parse(path) }; + std::unique_ptr track; + try + { + track = _metadataParser.parse(path); + } + catch(const MetaData::Exception& e) + { + LMS_LOG(DBUPDATER, INFO, "Failed to parse '" << path.string() << "'"); + } + { std::scoped_lock lock{ _mutex }; - _scanResults.emplace_back(std::make_unique(path, std::move(trackMetaData))); + _scanResults.emplace_back(MetaDataScanResult{ std::move(path), std::move(track) }); _ongoingScanCount -= 1; } _condVar.notify_all(); @@ -259,7 +278,7 @@ namespace Scanner return _scanResults.size(); } - size_t ScanStepScanFiles::MetadataScanQueue::popResults(std::vector>& results, std::size_t maxCount) + size_t ScanStepScanFiles::MetadataScanQueue::popResults(std::vector& results, std::size_t maxCount) { results.clear(); results.reserve(maxCount); @@ -285,7 +304,7 @@ namespace Scanner ScanStepScanFiles::ScanStepScanFiles(InitParams& initParams) : ScanStepBase{ initParams } - , _metadataParser{ MetaData::createParser(MetaData::ParserType::TagLib, getParserReadStyle()) } // For now, always use TagLib + , _metadataParser{ MetaData::createParser(MetaData::ParserBackend::TagLib, getParserReadStyle()) } // For now, always use TagLib , _metadataScanQueue{ *_metadataParser, getScanMetaDataThreadCount() } { LMS_LOG(DBUPDATER, INFO, "Using " << _metadataScanQueue.getThreadCount() << " thread(s) for scanning file metadata"); @@ -300,9 +319,11 @@ namespace Scanner std::vector tagsToParse{ _extraTagsToParse }; tagsToParse.insert(std::end(tagsToParse), std::cbegin(_settings.extraTags), std::cend(_settings.extraTags)); _metadataParser->setUserExtraTags(tagsToParse); + _metadataParser->setArtistTagDelimiters(_settings.artistTagDelimiters); + _metadataParser->setDefaultTagDelimiters(_settings.defaultTagDelimiters); } - std::vector> scanResults; + std::vector scanResults; context.currentStepStats.totalElems = context.stats.filesScanned; for (const ScannerSettings::MediaLibraryInfo& mediaLibrary : _settings.mediaLibraries) @@ -400,21 +421,21 @@ namespace Scanner return true; // need to scan } - void ScanStepScanFiles::processMetaDataScanResults(ScanContext& context, std::span> scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo) + void ScanStepScanFiles::processMetaDataScanResults(ScanContext& context, std::span scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo) { Database::Session& dbSession{ _db.getTLSSession() }; auto transaction{ dbSession.createWriteTransaction() }; - for (const auto& scanResult : scanResults) + for (const MetaDataScanResult& scanResult : scanResults) { if (_abortScan) return; - if (scanResult->trackMetaData) + if (scanResult.trackMetaData) { context.stats.scans++; - processFileMetaData(context, scanResult->path, *scanResult->trackMetaData, libraryInfo); + processFileMetaData(context, scanResult.path, *scanResult.trackMetaData, libraryInfo); // optimize the database during scan (if we import a very large database, it may be too late to do it once at end) if ((context.stats.scans % 1'000) == 0) @@ -422,7 +443,7 @@ namespace Scanner } else { - context.stats.errors.emplace_back(scanResult->path, ScanErrorType::CannotParseFile); + context.stats.errors.emplace_back(scanResult.path, ScanErrorType::CannotParseFile); } } } @@ -583,7 +604,7 @@ namespace Scanner track.modify()->setTotalTrack(trackMetadata.medium ? trackMetadata.medium->trackCount : std::nullopt); track.modify()->setReleaseReplayGain(trackMetadata.medium ? trackMetadata.medium->replayGain : std::nullopt); track.modify()->setDiscSubtitle(trackMetadata.medium ? trackMetadata.medium->name : ""); - track.modify()->setClusters(getOrCreateClusters(dbSession, trackMetadata.userExtraTags)); + track.modify()->setClusters(getOrCreateClusters(dbSession, trackMetadata)); track.modify()->setLastWriteTime(lastWriteTime); track.modify()->setName(title); track.modify()->setDuration(trackMetadata.duration); diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.hpp b/src/libs/services/scanner/impl/ScanStepScanFiles.hpp index 258d86ad..0669c418 100644 --- a/src/libs/services/scanner/impl/ScanStepScanFiles.hpp +++ b/src/libs/services/scanner/impl/ScanStepScanFiles.hpp @@ -47,13 +47,13 @@ namespace Scanner struct MetaDataScanResult { std::filesystem::path path; - std::optional trackMetaData; + std::unique_ptr trackMetaData; }; - void processMetaDataScanResults(ScanContext& context, std::span> scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo); + void processMetaDataScanResults(ScanContext& context, std::span scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo); void processFileMetaData(ScanContext& context, const std::filesystem::path& file, const MetaData::Track& trackMetadata, const ScannerSettings::MediaLibraryInfo& libraryInfo); std::unique_ptr _metadataParser; - const std::vector _extraTagsToParse{ "GENRE", "MOOD", "LANGUAGE", "ALBUMGROUPING" }; + const std::vector _extraTagsToParse; class MetadataScanQueue { @@ -62,10 +62,10 @@ namespace Scanner std::size_t getThreadCount() const { return _scanContextRunner.getThreadCount(); } - void pushScanRequest(const std::filesystem::path path); + void pushScanRequest(const std::filesystem::path& path); std::size_t getResultsCount() const; - size_t popResults(std::vector>& results, std::size_t maxCount); + size_t popResults(std::vector& results, std::size_t maxCount); void wait(std::size_t maxScanRequestCount = 0); // wait until ongoing scan request count <= maxScanRequestCount @@ -76,11 +76,11 @@ namespace Scanner mutable std::mutex _mutex ; std::size_t _ongoingScanCount{}; - std::deque> _scanResults; + std::deque _scanResults; std::condition_variable _condVar; }; MetadataScanQueue _metadataScanQueue; - std::deque> _metaDataScanResults; + std::deque _metaDataScanResults; }; } diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp index c37bc7c3..3f4d3ca8 100644 --- a/src/libs/services/scanner/impl/ScannerService.cpp +++ b/src/libs/services/scanner/impl/ScannerService.cpp @@ -383,6 +383,9 @@ namespace Scanner const auto& tags{ scanSettings->getExtraTagsToScan() }; std::transform(std::cbegin(tags), std::cend(tags), std::back_inserter(newSettings.extraTags), [](std::string_view tag) { return std::string{ tag };}); } + + newSettings.artistTagDelimiters = scanSettings->getArtistTagDelimiters(); + newSettings.defaultTagDelimiters = scanSettings->getDefaultTagDelimiters(); } return newSettings; diff --git a/src/libs/services/scanner/impl/ScannerSettings.hpp b/src/libs/services/scanner/impl/ScannerSettings.hpp index d00206ce..603a976f 100644 --- a/src/libs/services/scanner/impl/ScannerSettings.hpp +++ b/src/libs/services/scanner/impl/ScannerSettings.hpp @@ -30,31 +30,24 @@ namespace Scanner { struct ScannerSettings { - std::size_t scanVersion{}; - Wt::WTime startTime; - Database::ScanSettings::UpdatePeriod updatePeriod{ Database::ScanSettings::UpdatePeriod::Never }; - std::vector supportedExtensions; - bool skipDuplicateMBID{}; - std::vector extraTags; + std::size_t scanVersion{}; + Wt::WTime startTime; + Database::ScanSettings::UpdatePeriod updatePeriod{ Database::ScanSettings::UpdatePeriod::Never }; + std::vector supportedExtensions; + bool skipDuplicateMBID{}; + std::vector extraTags; + std::vector artistTagDelimiters; + std::vector defaultTagDelimiters; struct MediaLibraryInfo { Database::MediaLibraryId id; std::filesystem::path rootDirectory; - bool operator==(const MediaLibraryInfo& other) const { return id == other.id && rootDirectory == other.rootDirectory; } + bool operator<=>(const MediaLibraryInfo& other) const = default; }; - std::vector mediaLibraries; + std::vector mediaLibraries; - bool operator==(const ScannerSettings& rhs) const - { - return scanVersion == rhs.scanVersion - && startTime == rhs.startTime - && updatePeriod == rhs.updatePeriod - && supportedExtensions == rhs.supportedExtensions - && mediaLibraries == rhs.mediaLibraries - && skipDuplicateMBID == rhs.skipDuplicateMBID - && extraTags == rhs.extraTags; - } + bool operator<=>(const ScannerSettings& rhs) const = default; }; } diff --git a/src/libs/subsonic/impl/ProtocolVersion.cpp b/src/libs/subsonic/impl/ProtocolVersion.cpp index 2dbf3a45..8864ae19 100644 --- a/src/libs/subsonic/impl/ProtocolVersion.cpp +++ b/src/libs/subsonic/impl/ProtocolVersion.cpp @@ -25,7 +25,7 @@ namespace StringUtils std::optional readAs(std::string_view str) { // Expects "X.Y.Z" - const auto numbers{ StringUtils::splitString(str, ".") }; + const auto numbers{ StringUtils::splitString(str, '.') }; if (numbers.size() < 2 || numbers.size() > 3) return std::nullopt; diff --git a/src/libs/subsonic/impl/SubsonicId.cpp b/src/libs/subsonic/impl/SubsonicId.cpp index 91efad1c..6e00b340 100644 --- a/src/libs/subsonic/impl/SubsonicId.cpp +++ b/src/libs/subsonic/impl/SubsonicId.cpp @@ -63,7 +63,7 @@ namespace StringUtils template<> std::optional readAs(std::string_view str) { - std::vector values{ StringUtils::splitString(str, "-") }; + std::vector values{ StringUtils::splitString(str, '-') }; if (values.size() != 2) return std::nullopt; @@ -88,7 +88,7 @@ namespace StringUtils template<> std::optional readAs(std::string_view str) { - std::vector values{ StringUtils::splitString(str, "-") }; + std::vector values{ StringUtils::splitString(str, '-') }; if (values.size() != 2) return std::nullopt; @@ -113,7 +113,7 @@ namespace StringUtils template<> std::optional readAs(std::string_view str) { - std::vector values{ StringUtils::splitString(str, "-") }; + std::vector values{ StringUtils::splitString(str, '-') }; if (values.size() != 2) return std::nullopt; @@ -129,7 +129,7 @@ namespace StringUtils template<> std::optional readAs(std::string_view str) { - std::vector values{ StringUtils::splitString(str, "-") }; + std::vector values{ StringUtils::splitString(str, '-') }; if (values.size() != 2) return std::nullopt; diff --git a/src/libs/subsonic/impl/entrypoints/Searching.cpp b/src/libs/subsonic/impl/entrypoints/Searching.cpp index 69d04fa9..e9f2d20a 100644 --- a/src/libs/subsonic/impl/entrypoints/Searching.cpp +++ b/src/libs/subsonic/impl/entrypoints/Searching.cpp @@ -49,7 +49,7 @@ namespace API::Subsonic if (context.clientInfo.name == "Symfonium") query = StringUtils::stringTrim(query, "\""); - std::vector keywords{ StringUtils::splitString(query, " ") }; + std::vector keywords{ StringUtils::splitString(query, ' ') }; // Optional params std::size_t artistCount{ getParameterAs(context.parameters, "artistCount").value_or(20) }; diff --git a/src/libs/utils/impl/Logger.cpp b/src/libs/utils/impl/Logger.cpp index 1d95ced4..a0a9e988 100644 --- a/src/libs/utils/impl/Logger.cpp +++ b/src/libs/utils/impl/Logger.cpp @@ -68,6 +68,7 @@ Log::Log(ILogger& logger, Module module, Severity severity) Log::~Log() { + assert(_logger.isSeverityActive(_severity)); _logger.processLog(*this); } diff --git a/src/libs/utils/impl/StreamLogger.cpp b/src/libs/utils/impl/StreamLogger.cpp index cc8efbe6..4bbca70b 100644 --- a/src/libs/utils/impl/StreamLogger.cpp +++ b/src/libs/utils/impl/StreamLogger.cpp @@ -17,6 +17,7 @@ * along with LMS. If not, see . */ +#include #include #include "utils/StreamLogger.hpp" @@ -29,7 +30,7 @@ StreamLogger::StreamLogger(std::ostream& os, EnumSet severities) void StreamLogger::processLog(const Log& log) { - if (_severities.contains(log.getSeverity())) - _os << std::this_thread::get_id() << " [" << getSeverityName(log.getSeverity()) << "] [" << getModuleName(log.getModule()) << "] " << log.getMessage() << std::endl; + assert(isSeverityActive(log.getSeverity())); + _os << std::this_thread::get_id() << " [" << getSeverityName(log.getSeverity()) << "] [" << getModuleName(log.getModule()) << "] " << log.getMessage() << std::endl; } diff --git a/src/libs/utils/impl/String.cpp b/src/libs/utils/impl/String.cpp index 02b1a59c..f938b8fb 100644 --- a/src/libs/utils/impl/String.cpp +++ b/src/libs/utils/impl/String.cpp @@ -23,17 +23,13 @@ #include #include -#include -#include -#include - #include #include namespace StringUtils { - namespace + namespace details { constexpr std::pair jsEscapeChars[] { @@ -87,32 +83,23 @@ namespace StringUtils os << c; } } - } - bool readList(const std::string& str, const std::string& separators, std::list& results) - { - std::string curStr; - - for (char c : str) + template + std::string joinStrings(std::span strings, std::string_view delimiter) { - if (separators.find(c) != std::string::npos) { - if (!curStr.empty()) { - results.push_back(curStr); - curStr.clear(); - } - } - else { - if (curStr.empty() && std::isspace(c)) - continue; + std::string res; + bool first{ true }; - curStr.push_back(c); + for (const StringType& str : strings) + { + if (!first) + res += delimiter; + res += str; + first = false; } + + return res; } - - if (!curStr.empty()) - results.push_back(curStr); - - return !str.empty(); } template<> @@ -138,57 +125,102 @@ namespace StringUtils return std::nullopt; } - std::vector splitStringCopy(std::string_view string, std::string_view separators) + std::vector splitString(std::string_view str, char separator) { - std::string str{ stringTrim(string, separators) }; - - std::vector res; - boost::algorithm::split(res, str, boost::is_any_of(separators), boost::token_compress_on); - - return res; + return splitString(str, std::string_view{ &separator, 1 }); } - std::vector splitString(std::string_view str, std::string_view separators) + std::vector splitString(std::string_view str, std::string_view separator) { std::vector res; - std::string_view::size_type strBegin{}; + if (separator.empty()) + return { str }; - while ((strBegin = str.find_first_not_of(separators, strBegin)) != std::string_view::npos) + size_t pos{}; + size_t found{ str.find(separator) }; + + while (found != std::string_view::npos) { - auto strEnd{ str.find_first_of(separators, strBegin + 1) }; - if (strEnd == std::string_view::npos) + res.push_back(str.substr(pos, found - pos)); + pos = found + separator.size(); + found = str.find(separator, pos); + } + + res.push_back(str.substr(pos)); + + return res; + } + + std::string joinStrings(std::span strings, std::string_view delimiter) + { + return details::joinStrings(strings, delimiter); + } + + std::string joinStrings(std::span strings, std::string_view delimiter) + { + return details::joinStrings(strings, delimiter); + } + + std::string joinStrings(std::span strings, char delimiter) + { + return details::joinStrings(strings, std::string_view{ &delimiter, 1 }); + } + + std::string joinStrings(std::span strings, char delimiter) + { + return details::joinStrings(strings, std::string_view{ &delimiter, 1 }); + } + + std::string escapeAndJoinStrings(std::span strings, char delimiter, char escapeChar) + { + std::string result; + for (const std::string_view str : strings) + { + if (!result.empty()) + result.push_back(delimiter); + + for (char c : str) { - res.push_back(str.substr(strBegin, str.size() - strBegin)); - break; + if (c == delimiter || c == escapeChar) + result.push_back(escapeChar); + + result.push_back(c); } - - res.push_back(str.substr(strBegin, strEnd - strBegin)); - strBegin = strEnd + 1; } - - return res; + return result; } - std::string joinStrings(const std::vector& strings, std::string_view delimiter) + std::vector splitEscapedStrings(std::string_view str, char delimiter, char escapeChar) { - std::string res; - bool first{ true }; + std::vector result; + std::string current; + bool escaped{}; - for (std::string_view str : strings) + for (char c : str) { - if (!first) - res += delimiter; - res += str; - first = false; + if (escaped) { + current.push_back(c); + escaped = false; + } + else + { + if (c == delimiter) + { + result.push_back(std::move(current)); + current.clear(); + } + else if (c == escapeChar) + escaped = true; + else + current.push_back(c); + } } - return res; - } + if (!current.empty()) + result.push_back(std::move(current)); - std::string joinStrings(const std::vector& strings, const std::string& delimiter) - { - return boost::algorithm::join(strings, delimiter); + return result; } std::string_view stringTrim(std::string_view str, std::string_view whitespaces) @@ -237,7 +269,7 @@ namespace StringUtils return res; } - std::string bufferToString(const std::vector& data) + std::string bufferToString(std::span data) { std::ostringstream oss; @@ -253,7 +285,7 @@ namespace StringUtils { if (strA.size() != strB.size()) return false; - + for (std::size_t i{}; i < strA.size(); ++i) { if (std::tolower(strA[i]) != std::tolower(strB[i])) @@ -293,22 +325,22 @@ namespace StringUtils std::string jsEscape(std::string_view str) { - return escape(str, jsEscapeChars); + return details::escape(str, details::jsEscapeChars); } void writeJSEscapedString(std::ostream& os, std::string_view str) { - writeEscapedString(os, str, jsEscapeChars); + details::writeEscapedString(os, str, details::jsEscapeChars); } - + std::string jsonEscape(std::string_view str) { - return escape(str, jsonEscapeChars); + return details::escape(str, details::jsonEscapeChars); } void writeJsonEscapedString(std::ostream& os, std::string_view str) { - writeEscapedString(os, str, jsonEscapeChars); + details::writeEscapedString(os, str, details::jsonEscapeChars); } std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar) @@ -327,9 +359,41 @@ namespace StringUtils return res; } - bool stringEndsWith(const std::string& str, const std::string& ending) + std::string unescapeString(std::string_view str, char escapeChar) { - return boost::algorithm::ends_with(str, ending); + std::string res; + res.reserve(str.size()); + + bool escaped{}; + + for (char c : str) + { + if (escaped) + { + res += c; + escaped = false; + } + else + { + if (c == escapeChar) + escaped = true; + else + res += c; + } + } + + if (escaped) + res += escapeChar; + + return res; + } + + bool stringEndsWith(std::string_view str, std::string_view ending) + { + if (str.length() < ending.length()) + return false; + + return str.substr(str.length() - ending.length()) == ending; } std::optional stringFromHex(const std::string& str) diff --git a/src/libs/utils/include/utils/StreamLogger.hpp b/src/libs/utils/include/utils/StreamLogger.hpp index 7ef757bd..ae68c036 100644 --- a/src/libs/utils/include/utils/StreamLogger.hpp +++ b/src/libs/utils/include/utils/StreamLogger.hpp @@ -25,11 +25,12 @@ class StreamLogger final : public ILogger { public: + static constexpr EnumSet allSeverities{ Severity::FATAL, Severity::ERROR, Severity::WARNING, Severity::INFO, Severity::DEBUG }; static constexpr EnumSet defaultSeverities{ Severity::FATAL, Severity::ERROR, Severity::WARNING, Severity::INFO }; StreamLogger(std::ostream& oss, EnumSet severities = defaultSeverities); - bool isSeverityActive(Severity) const override { return true; } + bool isSeverityActive(Severity severity) const override { return _severities.contains(severity); } void processLog(const Log& log) override; private: diff --git a/src/libs/utils/include/utils/String.hpp b/src/libs/utils/include/utils/String.hpp index 3712bb39..4c7e5473 100644 --- a/src/libs/utils/include/utils/String.hpp +++ b/src/libs/utils/include/utils/String.hpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -35,13 +36,18 @@ namespace Wt class WDateTime; } -namespace StringUtils { +namespace StringUtils +{ + [[nodiscard]] std::vector splitString(std::string_view string, char separator); + [[nodiscard]] std::vector splitString(std::string_view string, std::string_view separator); - [[nodiscard]] std::vector splitStringCopy(std::string_view string, std::string_view separators); - [[nodiscard]] std::vector splitString(std::string_view string, std::string_view separators); + [[nodiscard]] std::string joinStrings(std::span strings, std::string_view delimiter); + [[nodiscard]] std::string joinStrings(std::span strings, std::string_view delimiter); + [[nodiscard]] std::string joinStrings(std::span strings, char delimiter); + [[nodiscard]] std::string joinStrings(std::span strings, char delimiter); - [[nodiscard]] std::string joinStrings(const std::vector& strings, std::string_view delimiter); - [[nodiscard]] std::string joinStrings(const std::vector& strings, const std::string& delimiter); + [[nodiscard]] std::string escapeAndJoinStrings(std::span strings, char delimiter, char escapeChar); + [[nodiscard]] std::vector splitEscapedStrings(std::string_view string, char delimiter, char escapeChar); [[nodiscard]] std::string_view stringTrim(std::string_view str, std::string_view whitespaces = " \t"); [[nodiscard]] std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t"); @@ -50,7 +56,7 @@ namespace StringUtils { void stringToLower(std::string& str); [[nodiscard]] std::string stringToUpper(const std::string& str); - [[nodiscard]] std::string bufferToString(const std::vector& data); + [[nodiscard]] std::string bufferToString(std::span data); [[nodiscard]] bool stringCaseInsensitiveEqual(std::string_view strA, std::string_view strB); @@ -86,8 +92,9 @@ namespace StringUtils { void writeJsonEscapedString(std::ostream& os, std::string_view str); [[nodiscard]] std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar); + [[nodiscard]] std::string unescapeString(std::string_view str, char escapeChar); - [[nodiscard]] bool stringEndsWith(const std::string& str, const std::string& ending); + [[nodiscard]] bool stringEndsWith(std::string_view str, std::string_view ending); [[nodiscard]] std::optional stringFromHex(const std::string& str); diff --git a/src/libs/utils/include/utils/UUID.hpp b/src/libs/utils/include/utils/UUID.hpp index 2507965c..0522ffd2 100644 --- a/src/libs/utils/include/utils/UUID.hpp +++ b/src/libs/utils/include/utils/UUID.hpp @@ -33,6 +33,8 @@ public: std::string_view getAsString() const { return _value; } + bool operator<=>(const UUID&) const = default; + private: UUID(std::string_view value); std::string _value; diff --git a/src/libs/utils/test/String.cpp b/src/libs/utils/test/String.cpp index 0a8769d1..9087f54d 100644 --- a/src/libs/utils/test/String.cpp +++ b/src/libs/utils/test/String.cpp @@ -24,80 +24,77 @@ #include #include "utils/String.hpp" -TEST(StringUtils, splitString) + +TEST(StringUtils, splitString_charDelim) { + struct TestCase { - const std::string test{ "a" }; - - const std::vector strings{ StringUtils::splitString(test, "") }; - ASSERT_EQ(strings.size(), 1); - EXPECT_EQ(strings.front(), "a"); - } + std::string_view input; + char delimiter; + std::vector expectedOutput; + }; + TestCase tests[] { - const std::string test{ "a b" }; - - const std::vector strings{ StringUtils::splitString(test, "|") }; - ASSERT_EQ(strings.size(), 1); - EXPECT_EQ(strings.front(), "a b"); - } + {"abc", '-', {"abc"}}, + {"a", '-', {"a"}}, + {"", '-', {""}}, + {"a-b-c", '-', {"a", "b", "c"}}, + {"a|b|c", '|', {"a", "b", "c"}}, + {"a;b;c", ';', {"a", "b", "c"}}, + {";b;c", ';', {"", "b", "c"}}, + {" ;b;c", ';', {" ", "b", "c"}}, + {" ;;c", ';', {" ", "", "c"}}, + {" ; ;c", ';', {" ", " ", "c"}}, + {"a;b; ", ';', {"a", "b", " "}}, + {"a;b", ';', {"a", "b"}}, + {";b", ';', {"", "b"}}, + {";", ';', {"", ""}}, + {";;", ';', {"", "", ""}}, + {";;;", ';', {"", "", "", ""}}, + {";;a;;b;;", ';', {"", "", "a", "", "b", "", ""}}, + {"a b", ' ', {"a", "b"}}, + {"", ' ', {""}}, + {"a-b|c", '-', {"a","b|c"}}, + {"a|b-c", '-', {"a|b", "c"}}, + {"test=foo bar", '=', {"test", "foo bar"}}, + }; + for (const TestCase& test : tests) { - const std::string test{ " a" }; - - const std::vector strings{ StringUtils::splitString(test, " ") }; - ASSERT_EQ(strings.size(), 1); - EXPECT_EQ(strings.front(), "a"); - } - - { - const std::string test{ "a " }; - - const std::vector strings{ StringUtils::splitString(test, " ") }; - ASSERT_EQ(strings.size(), 1); - EXPECT_EQ(strings.front(), "a"); - } - - { - const std::string test{ "a b" }; - - const std::vector strings{ StringUtils::splitString(test, " ") }; - ASSERT_EQ(strings.size(), 2); - EXPECT_EQ(strings.front(), "a"); - EXPECT_EQ(strings.back(), "b"); - } - - { - const std::string test{ "a b,c|defgh " }; - - const std::vector strings{ StringUtils::splitString(test, " ,|") }; - ASSERT_EQ(strings.size(), 4); - EXPECT_EQ(strings[0], "a"); - EXPECT_EQ(strings[1], "b"); - EXPECT_EQ(strings[2], "c"); - EXPECT_EQ(strings[3], "defgh"); + const std::vector res{ StringUtils::splitString(test.input, test.delimiter) }; + EXPECT_EQ(res, test.expectedOutput) << "Input = '" << test.input << "', delims = '" << test.delimiter << "'"; } } - -TEST(StringUtils, splitStringCopy) +TEST(StringUtils, splitString_stringDelim) { + struct TestCase { - const std::string test{ "test=foo" }; - - const std::vector strings{ StringUtils::splitStringCopy(test, "=") }; - ASSERT_EQ(strings.size(), 2); - EXPECT_EQ(strings[0], "test"); - EXPECT_EQ(strings[1], "foo"); - } + std::string_view input; + std::string_view delimiter; + std::vector expectedOutput; + }; + TestCase tests[] { - const std::string test{ "test=foo bar" }; + {"abc", "", {"abc"}}, + {"abc", "-", {"abc"}}, + {"abc", "b", {"a", "c"}}, + {"ab/cd", "/", {"ab", "cd"}}, + {"ab/cd", "/ ", {"ab/cd"}}, + {"ab/cd", " /", {"ab/cd"}}, + {"ab /cd", " /", {"ab", "cd"}}, + {"ab/ cd", "/ ", {"ab", "cd"}}, + {"ab / cd", " / ", {"ab", "cd"}}, + {"ab/cd", " / ", {"ab/cd"}}, + {"ab/cd / ", " / ", {"ab/cd", ""}}, + }; - const std::vector strings{ StringUtils::splitStringCopy(test, "=") }; - ASSERT_EQ(strings.size(), 2); - EXPECT_EQ(strings[0], "test"); - EXPECT_EQ(strings[1], "foo bar"); + for (const TestCase& test : tests) + { + const std::vector res{ StringUtils::splitString(test.input, test.delimiter) }; + EXPECT_EQ(res, test.expectedOutput) << "Input = '" << test.input << "', delims = '" << test.delimiter << "'"; } } @@ -128,6 +125,58 @@ TEST(StringUtils, joinStrings) } } +TEST(StringUtils, escapeAndJoinStrings) +{ + struct TestCase + { + std::vector input; + char delimiter; + char escapeChar; + std::string expectedOutput; + }; + + TestCase tests[] + { + {{""}, ';', '\\', ""}, + {{";"}, ';', '\\', "\\;"}, + {{";;"}, ';', '\\', "\\;\\;"}, + {{"a;", "b"}, ';', '\\', "a\\;;b"}, + {{"a;", "b;"}, ';', '\\', "a\\;;b\\;"}, + }; + + for (const TestCase& test : tests) + { + const std::string str{ StringUtils::escapeAndJoinStrings(test.input, test.delimiter, test.escapeChar) }; + EXPECT_EQ(str, test.expectedOutput); + } +} + +TEST(StringUtils, splitEscapedStrings) +{ + struct TestCase + { + std::string input; + char delimiter; + char escapeChar; + std::vector expectedOutput; + }; + + TestCase tests[] + { + {"", ';', '\\', {}}, + {"\\;", ';', '\\', {";"}}, + {"\\;\\;", ';', '\\', {";;"}}, + {"a\\;;b", ';', '\\', {"a;", "b"}}, + {"a\\;;b\\;", ';', '\\', {"a;", "b;"}}, + }; + + for (const TestCase& test : tests) + { + const std::vector str{ StringUtils::splitEscapedStrings(test.input, test.delimiter, test.escapeChar) }; + EXPECT_EQ(str, test.expectedOutput); + } +} + TEST(StringUtils, escapeJSString) { EXPECT_EQ(StringUtils::jsEscape(""), ""); @@ -153,6 +202,17 @@ TEST(StringUtils, escapeString) EXPECT_EQ(StringUtils::escapeString("*a*", "*", '_'), "_*a_*"); EXPECT_EQ(StringUtils::escapeString("*a|", "*|", '_'), "_*a_|"); EXPECT_EQ(StringUtils::escapeString("**||", "*|", '_'), "_*_*_|_|"); + EXPECT_EQ(StringUtils::escapeString("one;two", ";", '\\'), "one\\;two"); + EXPECT_EQ(StringUtils::escapeString("one\\;two", ";", '\\'), "one\\\\;two"); + EXPECT_EQ(StringUtils::escapeString("one;", ";", '\\'), "one\\;"); +} + +TEST(StringUtils, unescapeString) +{ + EXPECT_EQ(StringUtils::unescapeString("one\\", '\\'), "one\\"); + EXPECT_EQ(StringUtils::unescapeString("\\\\one", '\\'), "\\one"); + EXPECT_EQ(StringUtils::unescapeString("one\\;two", '\\'), "one;two"); + EXPECT_EQ(StringUtils::unescapeString("one\\\\;two", '\\'), "one\\;two"); } TEST(StringUtils, readAs_bool) @@ -220,4 +280,16 @@ TEST(Stringutils, dateTime) { const Wt::WDateTime dateTime{ Wt::WDate {2020, 01, 03 }, Wt::WTime{9, 8, 11, 75} }; EXPECT_EQ(StringUtils::toISO8601String(dateTime), "2020-01-03T09:08:11.075"); +} + +TEST(StringUtils, stringEndsWith) +{ + EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "Bar")); + EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "")); + EXPECT_TRUE(StringUtils::stringEndsWith("", "")); + EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "ar")); + EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "FooBar")); + EXPECT_FALSE(StringUtils::stringEndsWith("FooBar", "1FooBar")); + EXPECT_FALSE(StringUtils::stringEndsWith("FooBar", "1FooBar")); + EXPECT_FALSE(StringUtils::stringEndsWith("FooBar", "R")); } \ No newline at end of file diff --git a/src/lms/ui/admin/ScanSettingsView.cpp b/src/lms/ui/admin/ScanSettingsView.cpp index 8e050f45..497df553 100644 --- a/src/lms/ui/admin/ScanSettingsView.cpp +++ b/src/lms/ui/admin/ScanSettingsView.cpp @@ -45,6 +45,24 @@ namespace UserInterface namespace { + class TagDelimitersValidator : public Wt::WValidator + { + private: + Wt::WValidator::Result validate(const Wt::WString& input) const override + { + if (input.empty()) + return Wt::WValidator::Result{ Wt::ValidationState::Valid }; + + std::string inputStr{ input.toUTF8() }; + if (std::all_of(std::cbegin(inputStr), std::cend(inputStr), [](char c) { return std::isspace(c); })) + return Wt::WValidator::Result{ Wt::ValidationState::Invalid, Wt::WString::tr("Lms.Admin.Database.tag-delimiter-must-not-contain-only-spaces") }; + + return Wt::WValidator::Result{ Wt::ValidationState::Valid }; + } + + std::string javaScriptValidate() const override { return {}; } + }; + class DatabaseSettingsModel : public Wt::WFormModel { public: @@ -52,10 +70,12 @@ namespace UserInterface static inline constexpr Field UpdateStartTimeField{ "update-start-time" }; static inline constexpr Field SimilarityEngineTypeField{ "similarity-engine-type" }; static inline constexpr Field ExtraTagsField{ "extra-tags-to-scan" }; + static inline constexpr Field ArtistTagDelimiterField{ "artist-tag-delimiter" }; + static inline constexpr Field DefaultTagDelimiterField{ "default-tag-delimiter" }; using UpdatePeriodModel = ValueStringModel; - static inline constexpr std::string_view extraTagsDelimiter{ ";" }; + static inline constexpr char extraTagsDelimiter{ ';' }; DatabaseSettingsModel() { @@ -65,11 +85,15 @@ namespace UserInterface addField(UpdateStartTimeField); addField(SimilarityEngineTypeField); addField(ExtraTagsField); + addField(ArtistTagDelimiterField); + addField(DefaultTagDelimiterField); setValidator(UpdatePeriodField, createMandatoryValidator()); setValidator(UpdateStartTimeField, createMandatoryValidator()); setValidator(SimilarityEngineTypeField, createMandatoryValidator()); setValidator(ExtraTagsField, createUppercaseValidator()); + setValidator(ArtistTagDelimiterField, std::make_unique()); + setValidator(DefaultTagDelimiterField, std::make_unique()); // populate the model with initial data loadData(); @@ -103,8 +127,18 @@ namespace UserInterface if (similarityEngineTypeRow) setValue(SimilarityEngineTypeField, _similarityEngineTypeModel->getString(*similarityEngineTypeRow)); - auto extraTags{ scanSettings->getExtraTagsToScan() }; - setValue(ExtraTagsField, StringUtils::joinStrings(scanSettings->getExtraTagsToScan(), extraTagsDelimiter)); + const auto extraTags{ scanSettings->getExtraTagsToScan() }; + setValue(ExtraTagsField, StringUtils::joinStrings(extraTags, extraTagsDelimiter)); + + { + std::vector delimiters{ scanSettings->getArtistTagDelimiters() }; + setValue(ArtistTagDelimiterField, delimiters.empty() ? "" : delimiters.front()); + } + + { + std::vector delimiters{ scanSettings->getDefaultTagDelimiters() }; + setValue(DefaultTagDelimiterField, delimiters.empty() ? "" : delimiters.front()); + } } void saveData() @@ -126,6 +160,20 @@ namespace UserInterface scanSettings.modify()->setSimilarityEngineType(_similarityEngineTypeModel->getValue(*similarityEngineTypeRow)); scanSettings.modify()->setExtraTagsToScan(StringUtils::splitString(valueText(ExtraTagsField).toUTF8(), extraTagsDelimiter)); + + { + std::vector artistDelimiters; + if (std::string artistDelimiter{ valueText(ArtistTagDelimiterField).toUTF8() }; !artistDelimiter.empty()) + artistDelimiters.push_back(std::move(artistDelimiter)); + scanSettings.modify()->setArtistTagDelimiters(artistDelimiters); + } + + { + std::vector defaultDelimiters; + if (std::string defaultDelimiter{ valueText(DefaultTagDelimiterField).toUTF8() }; !defaultDelimiter.empty()) + defaultDelimiters.push_back(std::move(defaultDelimiter)); + scanSettings.modify()->setDefaultTagDelimiters(defaultDelimiters); + } } private: @@ -201,6 +249,12 @@ namespace UserInterface // Extra tags t->setFormWidget(DatabaseSettingsModel::ExtraTagsField, std::make_unique()); + // Artist tag delimiter + t->setFormWidget(DatabaseSettingsModel::ArtistTagDelimiterField, std::make_unique()); + + // Default tag delimiter + t->setFormWidget(DatabaseSettingsModel::DefaultTagDelimiterField, std::make_unique()); + // Buttons Wt::WPushButton* saveBtn = t->bindWidget("save-btn", std::make_unique(Wt::WString::tr("Lms.save"))); Wt::WPushButton* discardBtn = t->bindWidget("discard-btn", std::make_unique(Wt::WString::tr("Lms.discard"))); diff --git a/src/lms/ui/common/Template.cpp b/src/lms/ui/common/Template.cpp index a8cdb6a2..6c7262d6 100644 --- a/src/lms/ui/common/Template.cpp +++ b/src/lms/ui/common/Template.cpp @@ -22,20 +22,20 @@ namespace UserInterface { - void - Template::applyArguments(WWidget* widget, const std::vector& args) - { - for (const Wt::WString& arg : args) - { - const std::vector operands {StringUtils::splitStringCopy(arg.toUTF8(), "=")}; + void Template::applyArguments(WWidget* widget, const std::vector& args) + { + for (const Wt::WString& argStr : args) + { + std::string arg{ argStr.toUTF8() }; + const std::vector operands{ StringUtils::splitString(arg, '=') }; - if (operands.size() == 2) - { - if (operands[0] == "class") - widget->addStyleClass(operands[1]); - else - widget->setAttributeValue(operands[0], operands[1]); - } - } - } + if (operands.size() == 2) + { + if (operands[0] == "class") + widget->addStyleClass(std::string{ operands[1] }); + else + widget->setAttributeValue(std::string{ operands[0] }, std::string{ operands[1] }); + } + } + } } // namespace UserInterface diff --git a/src/lms/ui/explore/DatabaseCollectorBase.cpp b/src/lms/ui/explore/DatabaseCollectorBase.cpp index fcb80343..95d88acd 100644 --- a/src/lms/ui/explore/DatabaseCollectorBase.cpp +++ b/src/lms/ui/explore/DatabaseCollectorBase.cpp @@ -59,7 +59,7 @@ namespace UserInterface void DatabaseCollectorBase::setSearch(std::string_view searchText) { _searchText = searchText; - _searchKeywords = StringUtils::splitString(_searchText, " "); + _searchKeywords = StringUtils::splitString(_searchText, ' '); } } // ns UserInterface diff --git a/src/tools/metadata/LmsMetadata.cpp b/src/tools/metadata/LmsMetadata.cpp index c5820764..28b089cc 100644 --- a/src/tools/metadata/LmsMetadata.cpp +++ b/src/tools/metadata/LmsMetadata.cpp @@ -26,6 +26,7 @@ #include +#include "metadata/Exception.hpp" #include "metadata/IParser.hpp" #include "utils/StreamLogger.hpp" @@ -76,8 +77,8 @@ namespace if (medium.position) os << "\tPosition: " << *medium.position << std::endl; - if (!medium.type.empty()) - os << "\tType: " << medium.type << std::endl; + if (!medium.media.empty()) + os << "\tMedia: " << medium.media << std::endl; if (medium.trackCount) std::cout << "\tTrackCount: " << *medium.trackCount << std::endl; @@ -95,15 +96,8 @@ namespace { using namespace MetaData; - parser.setUserExtraTags({ "MOOD", "ALBUMGROUPING", "GENRE", "LANGUAGE" }); - const auto start{ std::chrono::steady_clock::now() }; - std::optional track{ parser.parse(file, true) }; - if (!track) - { - std::cerr << "Parsing failed" << std::endl; - return; - } + std::unique_ptr track{ parser.parse(file, true) }; const auto end{ std::chrono::steady_clock::now() }; std::cout << "Parsing time: " << std::fixed << std::setprecision(2) << std::chrono::duration_cast(end - start).count() / 1000. << "ms" << std::endl; @@ -155,6 +149,21 @@ namespace if (track->recordingMBID) std::cout << "Recording MBID = " << track->recordingMBID->getAsString() << std::endl; + for (std::string_view genre : track->genres) + std::cout << "Genre: " << genre << std::endl; + + for (std::string_view genre : track->moods) + std::cout << "Mood: " << genre << std::endl; + + for (std::string_view grouping : track->groupings) + std::cout << "Grouping: " << grouping << std::endl; + + for (std::string_view language : track->languages) + std::cout << "Language: " << language << std::endl; + + for (std::string_view label : track->labels) + std::cout << "Label: " << label << std::endl; + for (const auto& [tag, values] : track->userExtraTags) { std::cout << "Tag: " << tag << std::endl; @@ -209,7 +218,7 @@ int main(int argc, char* argv[]) try { // log to stdout - Service logger{ std::make_unique(std::cout) }; + Service logger{ std::make_unique(std::cout, StreamLogger::allSeverities) }; for (std::size_t i{}; i < static_cast(argc - 1); ++i) { @@ -217,17 +226,28 @@ int main(int argc, char* argv[]) std::cout << "Parsing file '" << file << "'" << std::endl; + try { std::cout << "Using av:" << std::endl; - auto parser{ MetaData::createParser(MetaData::ParserType::AvFormat, MetaData::ParserReadStyle::Accurate) }; + auto parser{ MetaData::createParser(MetaData::ParserBackend::AvFormat, MetaData::ParserReadStyle::Accurate) }; parse(*parser, file); } + catch (MetaData::Exception& e) + { + std::cerr << "Parsing failed: " << e.what() << std::endl; + } + try { std::cout << "Using TagLib:" << std::endl; - auto parser{ MetaData::createParser(MetaData::ParserType::TagLib, MetaData::ParserReadStyle::Accurate) }; + auto parser{ MetaData::createParser(MetaData::ParserBackend::TagLib, MetaData::ParserReadStyle::Accurate) }; parse(*parser, file); } + catch (MetaData::Exception& e) + { + std::cerr << "Parsing failed: " << e.what() << std::endl; + } + } } catch (std::exception& e)