diff --git a/README.md b/README.md index 6c12f894..a9bd385c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A [demo instance](http://lms-demo.poupon.dev) is available. Note the administrat ## Main features * Recommendation engine * Audio transcoding for maximum interoperability and reduced bandwith requirements -* Multi-value tags: `genre`, `albumgenre`, `mood`, `albummood`, `albumgrouping`, ... +* Multi-valued tags: `genre`, `mood`, `artists`, ... * Artist relationships: `composer`, `conductor`, `lyricist`, `mixer`, `performer`, `producer`, `remixer` * [Release types](https://musicbrainz.org/doc/Release_Group/Type): `album`, `single`, `EP`, `compilation`, `live`, ... * [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) support to handle duplicated artist and release names @@ -45,7 +45,7 @@ It is possible to apply global filters on your collection using `genre`, `mood`, __Note__: you can use the `lms-metadata` tool to have an idea of the tags parsed by _LMS_ using [TagLib](https://github.com/taglib/taglib). ### Multiple album artists -_LMS_ requires the `albumartists` and `albumartistssort` tags to properly handle multiple album artists on the same album. As they are a custom tags, you may need to setup your favorite tagger to add them. +_LMS_ requires the `albumartists` and `albumartistssort` tags to properly handle multiple album artists on the same album. As they are custom tags, you may need to set up your favorite tagger to add them. __Note__: if you use [Picard](https://picard.musicbrainz.org/), add the following script to include these tags: ``` 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..d774dec0 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -64,7 +64,9 @@ Root directory +Delimiter to be used for splitting artist tags (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..c4af2527 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 tags 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..f7136b27 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -64,7 +64,9 @@ Cartella principale +Delimitatore da utilizzare per separare i tag 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/approot/release.xml b/approot/release.xml index c2f9dcfc..4428ed2a 100644 --- a/approot/release.xml +++ b/approot/release.xml @@ -50,7 +50,21 @@ -

${disc-title}

+
+

${disc-title}

+
+ ${play-btn class="d-none d-sm-block btn btn-sm btn-outline-secondary border-0"} + +
+
${tracks class="d-grid gap-1 Lms-row-container"}
diff --git a/conf/lms.conf b/conf/lms.conf index ef239776..e65b63bd 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -33,7 +33,7 @@ approot = "/usr/share/lms/approot"; # Location for deployment (See README if you want to deploy on a non root path) deploy-path = "/"; -# Number of threads to be used to dispatch http requests (0 means auto detect) +# Number of threads to be used to dispatch http requests (0 means number of logical CPUs) http-server-thread-count = 0; # ListenBrainz root API @@ -98,3 +98,6 @@ scanner-skip-duplicate-mbid = false; # Scanner read style for metadata, maybe be 'fast', 'average' or 'accurate' scanner-parser-read-style = "average"; + +# Number of threads to use for scanning file metadata (0 means number of logical CPUs / 2) +scanner-metadata-thread-count = 0; \ No newline at end of file diff --git a/docroot/js/mediaplayer.js b/docroot/js/mediaplayer.js index b5dfdeb1..f6f61110 100644 --- a/docroot/js/mediaplayer.js +++ b/docroot/js/mediaplayer.js @@ -66,6 +66,9 @@ LMS.mediaplayer = function () { navigator.mediaSession.setActionHandler("nexttrack", function() { _playNext(); }); + navigator.mediaSession.setActionHandler("seekto", function(e) { + _seekTo(e.seekTime); + }); } if (_pendingTrackParameters != null) { @@ -81,16 +84,10 @@ LMS.mediaplayer = function () { if (_elems.audio.paused) { _elems.playpause.firstElementChild.classList.remove(pauseClass); _elems.playpause.firstElementChild.classList.add(playClass); - if ("mediaSession" in navigator) { - navigator.mediaSession.playbackState = "paused"; - } } else { _elems.playpause.firstElementChild.classList.remove(playClass); _elems.playpause.firstElementChild.classList.add(pauseClass); - if ("mediaSession" in navigator) { - navigator.mediaSession.playbackState = "playing"; - } } } @@ -202,6 +199,46 @@ LMS.mediaplayer = function () { _gainNode.gain.value = Math.pow(10, (_settings.replayGain.preAmpGain + replayGain) / 20); } + let _seekTo = function(seekTime) { + _initAudioCtx(); + let mode = _getAudioMode(); + if (!mode) + return; + + switch (mode) { + case Mode.Transcoding: + _offset = seekTime; + _removeAudioSources(); + _addAudioSource(_audioTranscodingSrc + "&offset=" + _offset); + _elems.audio.load(); + _elems.audio.currentTime = 0; + _playTrack(); + break; + + case Mode.File: + _elems.audio.currentTime = seekTime; + _playTrack(); + break; + } + + _updateMediaSessionState(); + } + + let _updateMediaSessionState = function() { + if ("mediaSession" in navigator) { + navigator.mediaSession.setPositionState({ + duration: _duration, + playbackRate: 1, + position: _offset + _elems.audio.currentTime, + }); + + if (_elems.audio.paused) + navigator.mediaSession.playbackState = "paused"; + else + navigator.mediaSession.playbackState = "playing"; + } + } + let init = function(root, defaultSettings) { _root = root; @@ -228,34 +265,17 @@ LMS.mediaplayer = function () { _playNext(); }); _elems.seek.addEventListener("change", function() { - _initAudioCtx(); - let mode = _getAudioMode(); - if (!mode) - return; - - let selectedOffset = parseInt(_elems.seek.value, 10); - - switch (mode) { - case Mode.Transcoding: - _offset = selectedOffset; - _removeAudioSources(); - _addAudioSource(_audioTranscodingSrc + "&offset=" + _offset); - _elems.audio.load(); - _elems.audio.currentTime = 0; - _playTrack(); - break; - - case Mode.File: - _elems.audio.currentTime = selectedOffset; - _playTrack(); - break; - } + _seekTo(parseInt(_elems.seek.value, 10)); }); _elems.audio.addEventListener("play", _updateControls); _elems.audio.addEventListener("playing", _updateControls); _elems.audio.addEventListener("pause", _updateControls); + _elems.audio.addEventListener("play", _updateMediaSessionState); + _elems.audio.addEventListener("playing", _updateMediaSessionState); + _elems.audio.addEventListener("pause", _updateMediaSessionState); + _elems.audio.addEventListener("pause", _stopTimer); _elems.audio.addEventListener("playing", _startTimer); _elems.audio.addEventListener("waiting", _stopTimer); 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 b3adbb49..746d222b 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -30,6 +30,15 @@ namespace Database { + namespace + { + static constexpr Version LMS_DATABASE_VERSION{ 54 }; + } + + VersionInfo::VersionInfo() + : _version{ LMS_DATABASE_VERSION } + {} + VersionInfo::pointer VersionInfo::getOrCreate(Session& session) { session.checkWriteTransaction(); @@ -72,11 +81,6 @@ namespace Database::Migration Db& _db; }; - static void migrateFromV32(Session& session) - { - ScanSettings::get(session).modify()->addAudioFileExtension(".wv"); - } - static void migrateFromV33(Session& session) { // remove name from track_artist_link @@ -220,7 +224,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"); } @@ -364,7 +368,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, @@ -399,6 +403,31 @@ 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 migrateFromV52(Session& session) + { + // Add sort name for releases + session.getDboSession().execute("ALTER TABLE release ADD sort_name TEXT NOT NULL DEFAULT ''"); + + // Just increment the scan version of the settings to make the next scheduled scan rescan everything + session.getDboSession().execute("UPDATE scan_settings SET scan_version = scan_version + 1"); + } + + void migrateFromV53(Session& session) + { + // Add release group mbid + session.getDboSession().execute("ALTER TABLE release ADD group_mbid TEXT NOT NULL DEFAULT ''"); + + // Just increment the scan version of the settings to make the next scheduled scan rescan everything + session.getDboSession().execute("UPDATE scan_settings SET scan_version = scan_version + 1"); + } + void doDbMigration(Session& session) { static const std::string outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -409,7 +438,6 @@ SELECT const std::map migrationFunctions { - {32, migrateFromV32}, {33, migrateFromV33}, {34, migrateFromV34}, {35, migrateFromV35}, @@ -428,6 +456,9 @@ SELECT {48, migrateFromV48}, {49, migrateFromV49}, {50, migrateFromV50}, + {51, migrateFromV51}, + {52, migrateFromV52}, + {53, migrateFromV53}, }; { diff --git a/src/libs/database/impl/Migration.hpp b/src/libs/database/impl/Migration.hpp index 61452001..559c7f29 100644 --- a/src/libs/database/impl/Migration.hpp +++ b/src/libs/database/impl/Migration.hpp @@ -26,12 +26,14 @@ namespace Database class Session; using Version = std::size_t; - static constexpr Version LMS_DATABASE_VERSION{ 51 }; + class VersionInfo { public: using pointer = Wt::Dbo::ptr; + VersionInfo(); + static VersionInfo::pointer getOrCreate(Session& session); static VersionInfo::pointer get(Session& session); @@ -45,7 +47,7 @@ namespace Database } private: - int _version{ LMS_DATABASE_VERSION }; + int _version; }; namespace Migration diff --git a/src/libs/database/impl/ScanSettings.cpp b/src/libs/database/impl/ScanSettings.cpp index d75c0995..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)); @@ -55,14 +55,19 @@ namespace Database return res; } - void ScanSettings::addAudioFileExtension(const std::filesystem::path& ext) - { - _audioFileExtensions += " " + ext.string(); - } - 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) @@ -74,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/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 4a53b593..53fabd4e 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -142,6 +142,9 @@ namespace Database if (params.trackNumber) query.where("t.track_number = ?").bind(*params.trackNumber); + if (params.discNumber) + query.where("t.disc_number = ?").bind(*params.discNumber); + if (params.mediaLibrary.isValid()) query.where("t.media_library_id = ?").bind(params.mediaLibrary); diff --git a/src/libs/database/include/database/Release.hpp b/src/libs/database/include/database/Release.hpp index 40c9d80a..52f5100d 100644 --- a/src/libs/database/include/database/Release.hpp +++ b/src/libs/database/include/database/Release.hpp @@ -143,8 +143,10 @@ namespace Database std::size_t getMeanBitrate() const; // Accessors - const std::string& getName() const { return _name; } + std::string_view getName() const { return _name; } + std::string_view getSortName() const { return _sortName; } std::optional getMBID() const { return UUID::fromString(_MBID); } + std::optional getGroupMBID() const { return UUID::fromString(_groupMBID); } std::optional getTotalDisc() const { return _totalDisc; } std::size_t getDiscCount() const; // may not be total disc (if incomplete for example) std::vector getDiscs() const; @@ -157,7 +159,9 @@ namespace Database // Setters void setName(std::string_view name) { _name = name; } + void setSortName(std::string_view sortName) { _sortName = sortName; } void setMBID(const std::optional& mbid) { _MBID = mbid ? mbid->getAsString() : ""; } + void setGroupMBID(const std::optional& mbid) { _groupMBID = mbid ? mbid->getAsString() : ""; } void setTotalDisc(std::optional totalDisc) { _totalDisc = totalDisc; } void setArtistDisplayName(std::string_view name) { _artistDisplayName = name; } void clearReleaseTypes(); @@ -173,7 +177,9 @@ namespace Database void persist(Action& a) { Wt::Dbo::field(a, _name, "name"); + Wt::Dbo::field(a, _sortName, "sort_name"); Wt::Dbo::field(a, _MBID, "mbid"); + Wt::Dbo::field(a, _groupMBID, "group_mbid"); Wt::Dbo::field(a, _totalDisc, "total_disc"); Wt::Dbo::field(a, _artistDisplayName, "artist_display_name"); Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToOne, "release"); @@ -191,7 +197,9 @@ namespace Database static constexpr std::size_t _maxNameLength{ 256 }; std::string _name; + std::string _sortName; std::string _MBID; + std::string _groupMBID; std::optional _totalDisc{}; std::string _artistDisplayName; diff --git a/src/libs/database/include/database/ScanSettings.hpp b/src/libs/database/include/database/ScanSettings.hpp index 0026e489..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,19 +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 addAudioFileExtension(const std::filesystem::path& ext); 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 @@ -86,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: @@ -95,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/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index 4a815323..a1cd4fd8 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -78,6 +78,7 @@ namespace Database { std::string releaseName; // matching this release name TrackListId trackList; // matching this trackList std::optional trackNumber; // matching this track number + std::optional discNumber; // matching this disc number MediaLibraryId mediaLibrary; // If set, tracks in this library FindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } @@ -94,6 +95,7 @@ namespace Database { FindParameters& setReleaseName(std::string_view _releaseName) { releaseName = _releaseName; return *this; } FindParameters& setTrackList(TrackListId _trackList) { trackList = _trackList; return *this; } FindParameters& setTrackNumber(int _trackNumber) { trackNumber = _trackNumber; return *this; } + FindParameters& setDiscNumber(int _discNumber) { discNumber = _discNumber; return *this; } FindParameters& setMediaLibrary(MediaLibraryId _mediaLibrary) { mediaLibrary = _mediaLibrary; return *this; } }; 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..0a81fec3 --- /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 avformat 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", "ALBUM-SORT" } }, + { 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", "ALBUMGROUPING" } }, + { 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..80a10e66 --- /dev/null +++ b/src/libs/metadata/impl/Parser.cpp @@ -0,0 +1,397 @@ +/* + * 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->sortName = getTagValueAs(tagReader, TagType::AlbumSortOrder).value_or(""); + release->artistDisplayName = getTagValueAs(tagReader, TagType::AlbumArtist).value_or(""); // TODO try to join albumartists if present + release->mbid = getTagValueAs(tagReader, TagType::MusicBrainzReleaseID); + release->groupMBID = getTagValueAs(tagReader, TagType::MusicBrainzReleaseGroupID); + 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..a44743a4 --- /dev/null +++ b/src/libs/metadata/impl/TagLibTagReader.cpp @@ -0,0 +1,377 @@ +/* + * 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", "ALBUMGROUPING" } }, + { 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.contains("DISCSUBTITLE")) + _propertyMap["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString() }; + } + + 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) << "'"); + } + + for (const auto& value : _propertyMap.unsupportedData()) + LMS_LOG(METADATA, DEBUG, "Unknown 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..a5bcc29c 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>; @@ -51,21 +54,33 @@ namespace MetaData struct Release { std::optional mbid; + std::optional groupMBID; std::string name; + std::string sortName; std::string artistDisplayName; 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 +90,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 +123,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 +142,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..e1e67ff3 --- /dev/null +++ b/src/libs/metadata/test/Parser.cpp @@ -0,0 +1,269 @@ +/* + * 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::AlbumSortOrder, { "MyAlbumSortName" } }, + { 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::MusicBrainzReleaseGroupID, { "5b1a5a44-8420-4426-9b86-d25dc8d04838" } }, + { 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->groupMBID.value(), UUID::fromString("5b1a5a44-8420-4426-9b86-d25dc8d04838")); + EXPECT_EQ(track->medium->release->mediumCount, 3); + EXPECT_EQ(track->medium->release->name, "MyAlbum"); + EXPECT_EQ(track->medium->release->sortName, "MyAlbumSortName"); + { + 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/auth/impl/AuthServiceBase.cpp b/src/libs/services/auth/impl/AuthServiceBase.cpp index 2a1334a2..eed258c5 100644 --- a/src/libs/services/auth/impl/AuthServiceBase.cpp +++ b/src/libs/services/auth/impl/AuthServiceBase.cpp @@ -19,6 +19,7 @@ #include "AuthServiceBase.hpp" +#include #include "database/Db.hpp" #include "database/Session.hpp" #include "database/User.hpp" @@ -26,46 +27,57 @@ namespace Auth { - using namespace Database; + using namespace Database; - AuthServiceBase::AuthServiceBase(Db& db) - : _db {db} - {} + AuthServiceBase::AuthServiceBase(Db& db) + : _db{ db } + {} - UserId - AuthServiceBase::getOrCreateUser(std::string_view loginName) - { - Session& session {getDbSession()}; - auto transaction {session.createWriteTransaction()}; + UserId AuthServiceBase::getOrCreateUser(std::string_view loginName) + { + Session& session{ getDbSession() }; + auto transaction{ session.createWriteTransaction() }; - User::pointer user {User::find(session, loginName)}; - if (!user) - { - const UserType type {User::getCount(session) == 0 ? UserType::ADMIN : UserType::REGULAR}; + User::pointer user{ User::find(session, loginName) }; + if (!user) + { + const UserType type{ User::getCount(session) == 0 ? UserType::ADMIN : UserType::REGULAR }; - LMS_LOG(AUTH, DEBUG, "Creating user '" << loginName << "', admin = " << (type == UserType::ADMIN)); + LMS_LOG(AUTH, DEBUG, "Creating user '" << loginName << "', admin = " << (type == UserType::ADMIN)); - user = session.create(loginName); - user.modify()->setType(type); - } + user = session.create(loginName); + user.modify()->setType(type); + } - return user->getId(); - } + return user->getId(); + } - void - AuthServiceBase::onUserAuthenticated(UserId userId) - { - Session& session {getDbSession()}; - auto transaction {session.createWriteTransaction()}; + void AuthServiceBase::onUserAuthenticated(UserId userId) + { + Session& session{ getDbSession() }; - User::pointer user {User::find(session, userId)}; - if (user) - user.modify()->setLastLogin(Wt::WDateTime::currentDateTime()); - } + // Update last login only if relevant (avoid hammering write accesses to the database) + { + auto transaction{ session.createReadTransaction() }; - Session& - AuthServiceBase::getDbSession() - { - return _db.getTLSSession(); - } + const User::pointer user{ User::find(session, userId) }; + if (!user) + return; + + if (std::abs(Wt::WDateTime::currentDateTime().secsTo(user->getLastLogin())) < 60) + return; + } + + { + auto transaction{ session.createWriteTransaction() }; + + if (User::pointer user{ User::find(session, userId) }) + user.modify()->setLastLogin(Wt::WDateTime::currentDateTime()); + } + } + + Session& AuthServiceBase::getDbSession() + { + return _db.getTLSSession(); + } } diff --git a/src/libs/services/auth/impl/AuthServiceBase.hpp b/src/libs/services/auth/impl/AuthServiceBase.hpp index ee372c7b..4c0d2ae6 100644 --- a/src/libs/services/auth/impl/AuthServiceBase.hpp +++ b/src/libs/services/auth/impl/AuthServiceBase.hpp @@ -24,23 +24,23 @@ namespace Database { - class Db; - class Session; + class Db; + class Session; } namespace Auth { - class AuthServiceBase - { - protected: - AuthServiceBase(Database::Db& db); + class AuthServiceBase + { + protected: + AuthServiceBase(Database::Db& db); - Database::UserId getOrCreateUser(std::string_view loginName); - void onUserAuthenticated(Database::UserId userId); + Database::UserId getOrCreateUser(std::string_view loginName); + void onUserAuthenticated(Database::UserId userId); - Database::Session& getDbSession(); + Database::Session& getDbSession(); - private: - Database::Db& _db; - }; + private: + Database::Db& _db; + }; } diff --git a/src/libs/services/auth/impl/internal/InternalPasswordService.cpp b/src/libs/services/auth/impl/internal/InternalPasswordService.cpp index 961aafe6..4304aae2 100644 --- a/src/libs/services/auth/impl/internal/InternalPasswordService.cpp +++ b/src/libs/services/auth/impl/internal/InternalPasswordService.cpp @@ -29,110 +29,105 @@ namespace Auth { - InternalPasswordService::InternalPasswordService(Database::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService) - : PasswordServiceBase {db, maxThrottlerEntries, authTokenService} - { - _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::OneCharClass, 4); - _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::TwoCharClass, 4); - _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::PassPhrase, 4); - _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::ThreeCharClass, 4); - _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::FourCharClass, 4); - _validator.setMinimumPassPhraseWords(1); - _validator.setMinimumMatchLength(3); - } + InternalPasswordService::InternalPasswordService(Database::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService) + : PasswordServiceBase{ db, maxThrottlerEntries, authTokenService } + { + _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::OneCharClass, 4); + _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::TwoCharClass, 4); + _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::PassPhrase, 4); + _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::ThreeCharClass, 4); + _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::FourCharClass, 4); + _validator.setMinimumPassPhraseWords(1); + _validator.setMinimumMatchLength(3); + } - bool - InternalPasswordService::checkUserPassword(std::string_view loginName, std::string_view password) - { - LMS_LOG(AUTH, DEBUG, "Checking internal password for user '" << loginName << "'"); + bool InternalPasswordService::checkUserPassword(std::string_view loginName, std::string_view password) + { + LMS_LOG(AUTH, DEBUG, "Checking internal password for user '" << loginName << "'"); - Database::User::PasswordHash passwordHash; - { - Database::Session& session {getDbSession()}; - auto transaction {session.createReadTransaction()}; + Database::User::PasswordHash passwordHash; + { + Database::Session& session{ getDbSession() }; + auto transaction{ session.createReadTransaction() }; - const Database::User::pointer user {Database::User::find(session, loginName)}; - if (!user) - { - LMS_LOG(AUTH, DEBUG, "hashing random stuff"); - // hash random stuff here to waste some time - hashRandomPassword(); - return false; - } + const Database::User::pointer user{ Database::User::find(session, loginName) }; + if (!user) + { + LMS_LOG(AUTH, DEBUG, "hashing random stuff"); + // hash random stuff here to waste some time + hashRandomPassword(); + return false; + } - // Don't allow users being created or coming from other backends - passwordHash = user->getPasswordHash(); - if (passwordHash.salt.empty() || passwordHash.hash.empty()) - { - // hash random stuff here to waste some time - hashRandomPassword(); - return false; - } - } + // Don't allow users being created or coming from other backends + passwordHash = user->getPasswordHash(); + if (passwordHash.salt.empty() || passwordHash.hash.empty()) + { + // hash random stuff here to waste some time + hashRandomPassword(); + return false; + } + } - return _hashFunc.verify(std::string {password}, std::string {passwordHash.salt}, std::string {passwordHash.hash}); - } + return _hashFunc.verify(std::string{ password }, std::string{ passwordHash.salt }, std::string{ passwordHash.hash }); + } - bool - InternalPasswordService::canSetPasswords() const - { - return true; - } + bool InternalPasswordService::canSetPasswords() const + { + return true; + } - IPasswordService::PasswordAcceptabilityResult - InternalPasswordService::checkPasswordAcceptability(std::string_view password, const PasswordValidationContext& context) const - { - switch (context.userType) - { - case Database::UserType::ADMIN: - case Database::UserType::REGULAR: - return _validator.evaluateStrength(std::string {password}, context.loginName, "").isValid() ? PasswordAcceptabilityResult::OK : PasswordAcceptabilityResult::TooWeak; - case Database::UserType::DEMO: - return password == context.loginName ? PasswordAcceptabilityResult::OK : PasswordAcceptabilityResult::MustMatchLoginName; - } + IPasswordService::PasswordAcceptabilityResult InternalPasswordService::checkPasswordAcceptability(std::string_view password, const PasswordValidationContext& context) const + { + switch (context.userType) + { + case Database::UserType::ADMIN: + case Database::UserType::REGULAR: + return _validator.evaluateStrength(std::string{ password }, context.loginName, "").isValid() ? PasswordAcceptabilityResult::OK : PasswordAcceptabilityResult::TooWeak; + case Database::UserType::DEMO: + return password == context.loginName ? PasswordAcceptabilityResult::OK : PasswordAcceptabilityResult::MustMatchLoginName; + } - throw NotImplementedException {}; - } + throw NotImplementedException{}; + } - void - InternalPasswordService::setPassword(Database::UserId userId, std::string_view newPassword) - { - const Database::User::PasswordHash passwordHash {hashPassword(newPassword)}; + void InternalPasswordService::setPassword(Database::UserId userId, std::string_view newPassword) + { + const Database::User::PasswordHash passwordHash{ hashPassword(newPassword) }; - Database::Session& session {getDbSession()}; - auto transaction {session.createWriteTransaction()}; + Database::Session& session{ getDbSession() }; + auto transaction{ session.createWriteTransaction() }; - Database::User::pointer user {Database::User::find(session, userId)}; - if (!user) - throw Exception {"User not found!"}; + Database::User::pointer user{ Database::User::find(session, userId) }; + if (!user) + throw Exception{ "User not found!" }; - switch (checkPasswordAcceptability(newPassword, PasswordValidationContext {user->getLoginName(), user->getType()})) - { - case PasswordAcceptabilityResult::OK: - break; - case PasswordAcceptabilityResult::TooWeak: - throw PasswordTooWeakException {}; - case PasswordAcceptabilityResult::MustMatchLoginName: - throw PasswordMustMatchLoginNameException {}; - } + switch (checkPasswordAcceptability(newPassword, PasswordValidationContext{ user->getLoginName(), user->getType() })) + { + case PasswordAcceptabilityResult::OK: + break; + case PasswordAcceptabilityResult::TooWeak: + throw PasswordTooWeakException{}; + case PasswordAcceptabilityResult::MustMatchLoginName: + throw PasswordMustMatchLoginNameException{}; + } - user.modify()->setPasswordHash(passwordHash); - getAuthTokenService().clearAuthTokens(userId); - } + user.modify()->setPasswordHash(passwordHash); + getAuthTokenService().clearAuthTokens(userId); + } - Database::User::PasswordHash - InternalPasswordService::hashPassword(std::string_view password) const - { - const std::string salt {Wt::WRandom::generateId(32)}; + Database::User::PasswordHash InternalPasswordService::hashPassword(std::string_view password) const + { + const std::string salt{ Wt::WRandom::generateId(32) }; - return {salt, _hashFunc.compute(std::string {password}, salt)}; - } + return { salt, _hashFunc.compute(std::string {password}, salt) }; + } - void - InternalPasswordService::hashRandomPassword() const - { - hashPassword(Wt::WRandom::generateId(32)); - } + void + InternalPasswordService::hashRandomPassword() const + { + hashPassword(Wt::WRandom::generateId(32)); + } } // namespace Auth diff --git a/src/libs/services/auth/impl/internal/InternalPasswordService.hpp b/src/libs/services/auth/impl/internal/InternalPasswordService.hpp index 6c241f31..3cd9e2e4 100644 --- a/src/libs/services/auth/impl/internal/InternalPasswordService.hpp +++ b/src/libs/services/auth/impl/internal/InternalPasswordService.hpp @@ -28,25 +28,25 @@ namespace Auth { - class IAuthTokenService; + class IAuthTokenService; - class InternalPasswordService : public PasswordServiceBase - { - public: - InternalPasswordService(Database::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService); + class InternalPasswordService : public PasswordServiceBase + { + public: + InternalPasswordService(Database::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService); - private: - bool checkUserPassword(std::string_view loginName, std::string_view password) override; + private: + bool checkUserPassword(std::string_view loginName, std::string_view password) override; - bool canSetPasswords() const override; - PasswordAcceptabilityResult checkPasswordAcceptability(std::string_view loginName, const PasswordValidationContext& context) const override; - void setPassword(Database::UserId userId, std::string_view newPassword) override; + bool canSetPasswords() const override; + PasswordAcceptabilityResult checkPasswordAcceptability(std::string_view loginName, const PasswordValidationContext& context) const override; + void setPassword(Database::UserId userId, std::string_view newPassword) override; - Database::User::PasswordHash hashPassword(std::string_view password) const; - void hashRandomPassword() const; + Database::User::PasswordHash hashPassword(std::string_view password) const; + void hashRandomPassword() const; - const Wt::Auth::BCryptHashFunction _hashFunc {7}; // TODO parametrize this - Wt::Auth::PasswordStrengthValidator _validator; - }; + const Wt::Auth::BCryptHashFunction _hashFunc{ 7 }; // TODO parametrize this + Wt::Auth::PasswordStrengthValidator _validator; + }; } diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp index 8b706840..6212b32e 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) @@ -128,6 +129,10 @@ namespace Scanner { if (release->getName() != releaseInfo.name) release.modify()->setName(releaseInfo.name); + if (release->getSortName() != releaseInfo.sortName) + release.modify()->setSortName(releaseInfo.sortName); + if (release->getGroupMBID() != releaseInfo.groupMBID) + release.modify()->setGroupMBID(releaseInfo.groupMBID); if (release->getTotalDisc() != releaseInfo.mediumCount) release.modify()->setTotalDisc(releaseInfo.mediumCount); if (release->getArtistDisplayName() != releaseInfo.artistDisplayName) @@ -179,25 +184,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; } @@ -215,22 +229,105 @@ namespace Scanner throw LmsException{ "Invalid value for 'scanner-parser-read-style'" }; } + + std::size_t getScanMetaDataThreadCount() + { + std::size_t threadCount{ Service::get()->getULong("scanner-metadata-thread-count", 0) }; + + if (threadCount == 0) + threadCount = std::max(std::thread::hardware_concurrency() / 2, 1); + + return threadCount; + } } // namespace + ScanStepScanFiles::MetadataScanQueue::MetadataScanQueue(MetaData::IParser& parser, std::size_t threadCount) + : _metadataParser{ parser } + , _scanContextRunner{ _scanContext, threadCount } + {} + + void ScanStepScanFiles::MetadataScanQueue::pushScanRequest(const std::filesystem::path& path) + { + { + std::scoped_lock lock{ _mutex }; + _ongoingScanCount += 1; + } + + _scanContext.post([=, this] + { + 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(MetaDataScanResult{ std::move(path), std::move(track) }); + _ongoingScanCount -= 1; + } + _condVar.notify_all(); + }); + } + + std::size_t ScanStepScanFiles::MetadataScanQueue::getResultsCount() const + { + std::scoped_lock lock{ _mutex }; + return _scanResults.size(); + } + + size_t ScanStepScanFiles::MetadataScanQueue::popResults(std::vector& results, std::size_t maxCount) + { + results.clear(); + results.reserve(maxCount); + + { + std::scoped_lock lock{ _mutex }; + + while (results.size() < maxCount && !_scanResults.empty()) + { + results.push_back(std::move(_scanResults.front())); + _scanResults.pop_front(); + } + } + + return results.size(); + } + + void ScanStepScanFiles::MetadataScanQueue::wait(std::size_t maxScanRequestCount) + { + std::unique_lock lock{ _mutex }; + _condVar.wait(lock, [=, this] {return _ongoingScanCount <= maxScanRequestCount;}); + } + 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"); } void ScanStepScanFiles::process(ScanContext& context) { + const std::size_t scanQueueMaxScanRequestCount{ 20 * _metadataScanQueue.getThreadCount() }; + const std::size_t processMetaDataBatchSize{ 10 }; + { 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; context.currentStepStats.totalElems = context.stats.filesScanned; for (const ScannerSettings::MediaLibraryInfo& mediaLibrary : _settings.mediaLibraries) @@ -247,22 +344,32 @@ namespace Scanner } else if (PathUtils::hasFileAnyExtension(path, _settings.supportedExtensions)) { - scanAudioFile(path, context, mediaLibrary); + if (checkFileNeedScan(context, path, mediaLibrary)) + _metadataScanQueue.pushScanRequest(path); context.currentStepStats.processedElems++; _progressCallback(context.currentStepStats); - - // 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.currentStepStats.processedElems % 1'000) == 0) - _db.getTLSSession().optimize(); } + while (_metadataScanQueue.getResultsCount() > processMetaDataBatchSize) + { + _metadataScanQueue.popResults(scanResults, processMetaDataBatchSize); + processMetaDataScanResults(context, scanResults, mediaLibrary); + } + + _metadataScanQueue.wait(scanQueueMaxScanRequestCount); + return true; }, &excludeDirFileName); - } - } - void ScanStepScanFiles::scanAudioFile(const std::filesystem::path& file, ScanContext& context, const ScannerSettings::MediaLibraryInfo& libraryInfo) + _metadataScanQueue.wait(); + + while (_metadataScanQueue.popResults(scanResults, processMetaDataBatchSize) > 0) + processMetaDataScanResults(context, scanResults, mediaLibrary); + } + } + + bool ScanStepScanFiles::checkFileNeedScan(ScanContext& context, const std::filesystem::path& file, const ScannerSettings::MediaLibraryInfo& libraryInfo) { ScanStats& stats{ context.stats }; Wt::WDateTime lastWriteTime; @@ -274,7 +381,7 @@ namespace Scanner { LMS_LOG(DBUPDATER, ERROR, e.what()); stats.skips++; - return; + return false; } bool needUpdateLibrary{}; @@ -296,7 +403,7 @@ namespace Scanner if (trackMediaLibrary && trackMediaLibrary->getId() == libraryInfo.id) { stats.skips++; - return; + return false; } needUpdateLibrary = true; @@ -312,26 +419,60 @@ namespace Scanner assert(track); track.modify()->setMediaLibrary(Database::MediaLibrary::find(dbSession, libraryInfo.id)); // may be null, will be handled in the next scan anyway stats.updates++; - return; + return false; } - std::optional trackInfo{ _metadataParser->parse(file) }; - if (!trackInfo) - { - context.stats.errors.emplace_back(file, ScanErrorType::CannotParseFile); - return; - } - - stats.scans++; + return true; // need to scan + } + void ScanStepScanFiles::processMetaDataScanResults(ScanContext& context, std::span scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo) + { Database::Session& dbSession{ _db.getTLSSession() }; auto transaction{ dbSession.createWriteTransaction() }; + for (const MetaDataScanResult& scanResult : scanResults) + { + if (_abortScan) + return; + + if (scanResult.trackMetaData) + { + context.stats.scans++; + + 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) + _db.getTLSSession().optimize(); + } + else + { + context.stats.errors.emplace_back(scanResult.path, ScanErrorType::CannotParseFile); + } + } + } + + void ScanStepScanFiles::processFileMetaData(ScanContext& context, const std::filesystem::path& file, const MetaData::Track& trackMetadata, const ScannerSettings::MediaLibraryInfo& libraryInfo) + { + ScanStats& stats{ context.stats }; + Wt::WDateTime lastWriteTime; + try + { + lastWriteTime = PathUtils::getLastWriteTime(file); + } + catch (LmsException& e) + { + LMS_LOG(DBUPDATER, ERROR, e.what()); + stats.skips++; + return; + } + + Database::Session& dbSession{ _db.getTLSSession() }; Track::pointer track{ Track::findByPath(dbSession, file) }; - if (trackInfo->mbid && (!track || _settings.skipDuplicateMBID)) + if (trackMetadata.mbid && (!track || _settings.skipDuplicateMBID)) { - std::vector duplicateTracks{ Track::findByMBID(dbSession, *trackInfo->mbid) }; + std::vector duplicateTracks{ Track::findByMBID(dbSession, *trackMetadata.mbid) }; // find for existing MBIDs as the file may have just been moved if (!track && duplicateTracks.size() == 1) @@ -378,7 +519,7 @@ namespace Scanner } // We estimate this is an audio file if the duration is not null - if (trackInfo->duration == std::chrono::milliseconds::zero()) + if (trackMetadata.duration == std::chrono::milliseconds::zero()) { LMS_LOG(DBUPDATER, DEBUG, "Skipped '" << file.string() << "' (duration is 0)"); @@ -394,8 +535,8 @@ namespace Scanner // ***** Title std::string title; - if (!trackInfo->title.empty()) - title = trackInfo->title; + if (!trackMetadata.title.empty()) + title = trackMetadata.title; else { // TODO parse file name guess track etc. @@ -424,78 +565,78 @@ namespace Scanner track.modify()->setMediaLibrary(MediaLibrary::find(dbSession, libraryInfo.id)); // may be null if settings are updated in // => next scan will correct this track.modify()->clearArtistLinks(); // Do not fallback on artists with the same name but having a MBID for artist and releaseArtists, as it may be corrected by properly tagging files - for (const Artist::pointer& artist : getOrCreateArtists(dbSession, trackInfo->artists, false)) + for (const Artist::pointer& artist : getOrCreateArtists(dbSession, trackMetadata.artists, false)) track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, artist, TrackArtistLinkType::Artist)); - if (trackInfo->medium && trackInfo->medium->release) + if (trackMetadata.medium && trackMetadata.medium->release) { - for (const Artist::pointer& releaseArtist : getOrCreateArtists(dbSession, trackInfo->medium->release->artists, false)) + for (const Artist::pointer& releaseArtist : getOrCreateArtists(dbSession, trackMetadata.medium->release->artists, false)) track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, releaseArtist, TrackArtistLinkType::ReleaseArtist)); } // Allow fallbacks on artists with the same name even if they have MBID, since there is no tag to indicate the MBID of these artists // We could ask MusicBrainz to get all the information, but that would heavily slow down the import process - for (const Artist::pointer& conductor : getOrCreateArtists(dbSession, trackInfo->conductorArtists, true)) + for (const Artist::pointer& conductor : getOrCreateArtists(dbSession, trackMetadata.conductorArtists, true)) track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, conductor, TrackArtistLinkType::Conductor)); - for (const Artist::pointer& composer : getOrCreateArtists(dbSession, trackInfo->composerArtists, true)) + for (const Artist::pointer& composer : getOrCreateArtists(dbSession, trackMetadata.composerArtists, true)) track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, composer, TrackArtistLinkType::Composer)); - for (const Artist::pointer& lyricist : getOrCreateArtists(dbSession, trackInfo->lyricistArtists, true)) + for (const Artist::pointer& lyricist : getOrCreateArtists(dbSession, trackMetadata.lyricistArtists, true)) track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, lyricist, TrackArtistLinkType::Lyricist)); - for (const Artist::pointer& mixer : getOrCreateArtists(dbSession, trackInfo->mixerArtists, true)) + for (const Artist::pointer& mixer : getOrCreateArtists(dbSession, trackMetadata.mixerArtists, true)) track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, mixer, TrackArtistLinkType::Mixer)); - for (const auto& [role, performers] : trackInfo->performerArtists) + for (const auto& [role, performers] : trackMetadata.performerArtists) { for (const Artist::pointer& performer : getOrCreateArtists(dbSession, performers, true)) track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, performer, TrackArtistLinkType::Performer, role)); } - for (const Artist::pointer& producer : getOrCreateArtists(dbSession, trackInfo->producerArtists, true)) + for (const Artist::pointer& producer : getOrCreateArtists(dbSession, trackMetadata.producerArtists, true)) track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, producer, TrackArtistLinkType::Producer)); - for (const Artist::pointer& remixer : getOrCreateArtists(dbSession, trackInfo->remixerArtists, true)) + for (const Artist::pointer& remixer : getOrCreateArtists(dbSession, trackMetadata.remixerArtists, true)) track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, remixer, TrackArtistLinkType::Remixer)); track.modify()->setScanVersion(_settings.scanVersion); - if (trackInfo->medium && trackInfo->medium->release) - track.modify()->setRelease(getOrCreateRelease(dbSession, *trackInfo->medium->release, file.parent_path())); + if (trackMetadata.medium && trackMetadata.medium->release) + track.modify()->setRelease(getOrCreateRelease(dbSession, *trackMetadata.medium->release, file.parent_path())); else track.modify()->setRelease({}); - track.modify()->setTotalTrack(trackInfo->medium ? trackInfo->medium->trackCount : std::nullopt); - track.modify()->setReleaseReplayGain(trackInfo->medium ? trackInfo->medium->replayGain : std::nullopt); - track.modify()->setDiscSubtitle(trackInfo->medium ? trackInfo->medium->name : ""); - track.modify()->setClusters(getOrCreateClusters(dbSession, trackInfo->userExtraTags)); + 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)); track.modify()->setLastWriteTime(lastWriteTime); track.modify()->setName(title); - track.modify()->setDuration(trackInfo->duration); - track.modify()->setBitrate(trackInfo->bitrate); + track.modify()->setDuration(trackMetadata.duration); + track.modify()->setBitrate(trackMetadata.bitrate); track.modify()->setAddedTime(Wt::WDateTime::currentDateTime()); - track.modify()->setTrackNumber(trackInfo->position); - track.modify()->setDiscNumber(trackInfo->medium ? trackInfo->medium->position : std::nullopt); - track.modify()->setDate(trackInfo->date); - track.modify()->setYear(trackInfo->year); - track.modify()->setOriginalDate(trackInfo->originalDate); - track.modify()->setOriginalYear(trackInfo->originalYear); + track.modify()->setTrackNumber(trackMetadata.position); + track.modify()->setDiscNumber(trackMetadata.medium ? trackMetadata.medium->position : std::nullopt); + track.modify()->setDate(trackMetadata.date); + track.modify()->setYear(trackMetadata.year); + track.modify()->setOriginalDate(trackMetadata.originalDate); + track.modify()->setOriginalYear(trackMetadata.originalYear); // If a file has an OriginalDate but no date, set it to ease filtering - if (!trackInfo->date.isValid() && trackInfo->originalDate.isValid()) - track.modify()->setDate(trackInfo->originalDate); + if (!trackMetadata.date.isValid() && trackMetadata.originalDate.isValid()) + track.modify()->setDate(trackMetadata.originalDate); // If a file has an OriginalYear but no Year, set it to ease filtering - if (!trackInfo->year && trackInfo->originalYear) - track.modify()->setYear(trackInfo->originalYear); + if (!trackMetadata.year && trackMetadata.originalYear) + track.modify()->setYear(trackMetadata.originalYear); - track.modify()->setRecordingMBID(trackInfo->recordingMBID); - track.modify()->setTrackMBID(trackInfo->mbid); + track.modify()->setRecordingMBID(trackMetadata.recordingMBID); + track.modify()->setTrackMBID(trackMetadata.mbid); if (auto trackFeatures{ TrackFeatures::find(dbSession, track->getId()) }) trackFeatures.remove(); // TODO: only if MBID changed? - track.modify()->setHasCover(trackInfo->hasCover); - track.modify()->setCopyright(trackInfo->copyright); - track.modify()->setCopyrightURL(trackInfo->copyrightURL); - track.modify()->setTrackReplayGain(trackInfo->replayGain); - track.modify()->setArtistDisplayName(trackInfo->artistDisplayName); + track.modify()->setHasCover(trackMetadata.hasCover); + track.modify()->setCopyright(trackMetadata.copyright); + track.modify()->setCopyrightURL(trackMetadata.copyrightURL); + track.modify()->setTrackReplayGain(trackMetadata.replayGain); + track.modify()->setArtistDisplayName(trackMetadata.artistDisplayName); } } diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.hpp b/src/libs/services/scanner/impl/ScanStepScanFiles.hpp index 35d69407..0669c418 100644 --- a/src/libs/services/scanner/impl/ScanStepScanFiles.hpp +++ b/src/libs/services/scanner/impl/ScanStepScanFiles.hpp @@ -19,11 +19,16 @@ #pragma once +#include +#include #include +#include +#include #include #include #include "metadata/IParser.hpp" +#include "utils/IOContextRunner.hpp" #include "ScanStepBase.hpp" namespace Scanner @@ -38,9 +43,44 @@ namespace Scanner std::string_view getStepName() const override { return "Scanning files"; } void process(ScanContext& context) override; - void scanAudioFile(const std::filesystem::path& file, ScanContext& context, const ScannerSettings::MediaLibraryInfo& libraryInfo); + bool checkFileNeedScan(ScanContext& context, const std::filesystem::path& file, const ScannerSettings::MediaLibraryInfo& libraryInfo); + struct MetaDataScanResult + { + std::filesystem::path path; + std::unique_ptr trackMetaData; + }; + 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 + { + public: + MetadataScanQueue(MetaData::IParser& parser, std::size_t threadCount); + + std::size_t getThreadCount() const { return _scanContextRunner.getThreadCount(); } + + void pushScanRequest(const std::filesystem::path& path); + + std::size_t getResultsCount() const; + size_t popResults(std::vector& results, std::size_t maxCount); + + void wait(std::size_t maxScanRequestCount = 0); // wait until ongoing scan request count <= maxScanRequestCount + + private: + MetaData::IParser& _metadataParser; + boost::asio::io_context _scanContext; + IOContextRunner _scanContextRunner; + + mutable std::mutex _mutex ; + std::size_t _ongoingScanCount{}; + std::deque _scanResults; + std::condition_variable _condVar; + }; + MetadataScanQueue _metadataScanQueue; + + 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/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp index dc617454..d73c666b 100644 --- a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp @@ -65,10 +65,12 @@ namespace Scrobbling::ListenBrainz Wt::Json::Object additionalInfo; additionalInfo["listening_from"] = "LMS"; additionalInfo["duration_ms"] = std::chrono::duration_cast(track->getDuration()).count(); - if (track->getRelease()) + if (const auto release {track->getRelease()}) { - if (auto MBID{ track->getRelease()->getMBID() }) + if (auto MBID{ release->getMBID() }) additionalInfo["release_mbid"] = Wt::Json::Value{ std::string {MBID->getAsString()} }; + if (auto groupMBID{ release->getGroupMBID() }) + additionalInfo["release_group_mbid"] = Wt::Json::Value{ std::string {groupMBID->getAsString()} }; } { @@ -94,10 +96,10 @@ namespace Scrobbling::ListenBrainz Wt::Json::Object trackMetadata; trackMetadata["additional_info"] = std::move(additionalInfo); - trackMetadata["artist_name"] = Wt::Json::Value{ artists.front()->getName() }; + trackMetadata["artist_name"] = Wt::Json::Value{ std::string{ track->getArtistDisplayName() } }; trackMetadata["track_name"] = Wt::Json::Value{ track->getName() }; if (track->getRelease()) - trackMetadata["release_name"] = Wt::Json::Value{ track->getRelease()->getName() }; + trackMetadata["release_name"] = Wt::Json::Value{ std::string {track->getRelease()->getName()} }; Wt::Json::Object payload; payload["track_metadata"] = std::move(trackMetadata); 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/Utils.cpp b/src/libs/subsonic/impl/Utils.cpp index ec7a3b6f..2bef51be 100644 --- a/src/libs/subsonic/impl/Utils.cpp +++ b/src/libs/subsonic/impl/Utils.cpp @@ -33,7 +33,7 @@ namespace API::Subsonic::Utils throw NotImplementedGenericError{}; } - std::string makeNameFilesystemCompatible(const std::string& name) + std::string makeNameFilesystemCompatible(std::string_view name) { return StringUtils::replaceInString(name, "/", "_"); } diff --git a/src/libs/subsonic/impl/Utils.hpp b/src/libs/subsonic/impl/Utils.hpp index d94f11b8..0d85bce4 100644 --- a/src/libs/subsonic/impl/Utils.hpp +++ b/src/libs/subsonic/impl/Utils.hpp @@ -20,9 +20,10 @@ #pragma once #include +#include namespace API::Subsonic::Utils { void checkSetPasswordImplemented(); - std::string makeNameFilesystemCompatible(const std::string& name); + std::string makeNameFilesystemCompatible(std::string_view name); } \ No newline at end of file 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/subsonic/impl/responses/Album.cpp b/src/libs/subsonic/impl/responses/Album.cpp index f570c959..b263acc4 100644 --- a/src/libs/subsonic/impl/responses/Album.cpp +++ b/src/libs/subsonic/impl/responses/Album.cpp @@ -106,6 +106,8 @@ namespace API::Subsonic return albumNode; // OpenSubsonic specific fields (must always be set) + albumNode.setAttribute("sortName", release->getSortName()); + if (!id3) albumNode.setAttribute("mediaType", "album"); diff --git a/src/libs/utils/impl/IOContextRunner.cpp b/src/libs/utils/impl/IOContextRunner.cpp index 42fbd182..77a88b9d 100644 --- a/src/libs/utils/impl/IOContextRunner.cpp +++ b/src/libs/utils/impl/IOContextRunner.cpp @@ -24,40 +24,45 @@ #include "utils/ILogger.hpp" IOContextRunner::IOContextRunner(boost::asio::io_service& ioService, std::size_t threadCount) -: _ioService {ioService} -, _work {ioService} + : _ioService{ ioService } + , _work{ ioService } { - LMS_LOG(UTILS, INFO, "Starting IO context with " << threadCount << " threads..."); - for (std::size_t i {}; i < threadCount; ++i) - { - _threads.emplace_back([&] - { - try - { - _ioService.run(); - } - catch (const std::exception& e) - { - LMS_LOG(UTILS, FATAL, "Exception caught in IO context: " << e.what()); - std::abort(); - } - }); - } + LMS_LOG(UTILS, INFO, "Starting IO context with " << threadCount << " threads..."); + for (std::size_t i{}; i < threadCount; ++i) + { + _threads.emplace_back([&] + { + try + { + _ioService.run(); + } + catch (const std::exception& e) + { + LMS_LOG(UTILS, FATAL, "Exception caught in IO context: " << e.what()); + std::abort(); + } + }); + } } void IOContextRunner::stop() { - LMS_LOG(UTILS, DEBUG, "Stopping IO context..."); - _work.reset(); - _ioService.stop(); - LMS_LOG(UTILS, DEBUG, "IO context stopped!"); + LMS_LOG(UTILS, DEBUG, "Stopping IO context..."); + _work.reset(); + _ioService.stop(); + LMS_LOG(UTILS, DEBUG, "IO context stopped!"); +} + +std::size_t IOContextRunner::getThreadCount() const +{ + return _threads.size(); } IOContextRunner::~IOContextRunner() { - stop(); + stop(); - for (std::thread& t : _threads) - t.join(); + for (std::thread& t : _threads) + t.join(); } 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/IOContextRunner.hpp b/src/libs/utils/include/utils/IOContextRunner.hpp index 804a297c..e538826f 100644 --- a/src/libs/utils/include/utils/IOContextRunner.hpp +++ b/src/libs/utils/include/utils/IOContextRunner.hpp @@ -25,19 +25,18 @@ class IOContextRunner { - public: - IOContextRunner(boost::asio::io_service& ioService, std::size_t threadCount); - ~IOContextRunner(); +public: + IOContextRunner(boost::asio::io_service& ioService, std::size_t threadCount); + ~IOContextRunner(); - IOContextRunner(const IOContextRunner&) = delete; - IOContextRunner(IOContextRunner&&) = delete; - IOContextRunner& operator=(const IOContextRunner&) = delete; - IOContextRunner& operator=(IOContextRunner&&) = delete; + void stop(); + std::size_t getThreadCount() const; - void stop(); +private: + IOContextRunner(const IOContextRunner&) = delete; + IOContextRunner& operator=(const IOContextRunner&) = delete; - private: - boost::asio::io_service& _ioService; - std::optional _work; - std::vector _threads; + boost::asio::io_service& _ioService; + std::optional _work; + std::vector _threads; }; 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/MediaPlayer.cpp b/src/lms/ui/MediaPlayer.cpp index aeeca1a1..b1644471 100644 --- a/src/lms/ui/MediaPlayer.cpp +++ b/src/lms/ui/MediaPlayer.cpp @@ -289,7 +289,7 @@ namespace UserInterface if (track->getRelease()) { _release->setTextFormat(Wt::TextFormat::Plain); - _release->setText(Wt::WString::fromUTF8(track->getRelease()->getName())); + _release->setText(Wt::WString::fromUTF8(std::string{ track->getRelease()->getName() })); _release->setLink(Utils::createReleaseLink(track->getRelease())); } else diff --git a/src/lms/ui/Utils.cpp b/src/lms/ui/Utils.cpp index eb7111a1..b4f02e61 100644 --- a/src/lms/ui/Utils.cpp +++ b/src/lms/ui/Utils.cpp @@ -255,9 +255,10 @@ namespace UserInterface::Utils if (setText) { + std::string releaseName{ release->getName() }; res->setTextFormat(Wt::TextFormat::Plain); - res->setText(Wt::WString::fromUTF8(release->getName())); - res->setToolTip(Wt::WString::fromUTF8(release->getName()), Wt::TextFormat::Plain); + res->setText(Wt::WString::fromUTF8(releaseName)); + res->setToolTip(Wt::WString::fromUTF8(releaseName), Wt::TextFormat::Plain); } return res; 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/lms/ui/explore/PlayQueueController.cpp b/src/lms/ui/explore/PlayQueueController.cpp index bf800d19..87119aad 100644 --- a/src/lms/ui/explore/PlayQueueController.cpp +++ b/src/lms/ui/explore/PlayQueueController.cpp @@ -88,6 +88,36 @@ namespace UserInterface return res; } + std::vector getDiscTracks(Database::Session& session, const std::vector& discs, const std::vector& clusters, std::size_t maxTrackCount) + { + using namespace Database; + assert(maxTrackCount); + + std::vector res; + + auto transaction{ session.createReadTransaction() }; + + for (const PlayQueueController::Disc& disc : discs) + { + Database::Track::FindParameters params; + params.setRelease(disc.releaseId); + params.setSortMethod(Database::TrackSortMethod::Release); + params.setDiscNumber(disc.discNumber); + params.setClusters(clusters); + params.setRange(Database::Range{ 0, maxTrackCount - res.size() }); + + const auto tracks{ Database::Track::findIds(session, params) }; + + res.reserve(res.size() + tracks.results.size()); + res.insert(std::end(res), std::cbegin(tracks.results), std::cend(tracks.results)); + + if (res.size() == maxTrackCount) + break; + } + + return res; + } + std::vector getTrackListTracks(Database::Session& session, Database::TrackListId trackListId, const std::vector& clusters, std::size_t maxTrackCount) { using namespace Database; @@ -149,6 +179,12 @@ namespace UserInterface processCommand(command, tracks); } + void PlayQueueController::processCommand(Command command, const std::vector& discs) + { + const std::vector tracks{ getDiscTracks(LmsApp->getDbSession(), discs, _filters.getClusterIds(), _maxTrackCountToEnqueue) }; + processCommand(command, tracks); + } + void PlayQueueController::playTrackInRelease(Database::TrackId trackId) { Database::ReleaseId releaseId; diff --git a/src/lms/ui/explore/PlayQueueController.hpp b/src/lms/ui/explore/PlayQueueController.hpp index a7483f49..08b6106e 100644 --- a/src/lms/ui/explore/PlayQueueController.hpp +++ b/src/lms/ui/explore/PlayQueueController.hpp @@ -45,11 +45,18 @@ namespace UserInterface PlayShuffled, }; - void processCommand(Command command, const std::vector&); - void processCommand(Command command, const std::vector&); - void processCommand(Command command, const std::vector&); - void processCommand(Command command, Database::TrackListId); - void playTrackInRelease(Database::TrackId); + void processCommand(Command command, const std::vector& artists); + void processCommand(Command command, const std::vector& releases); + void processCommand(Command command, const std::vector& tracks); + + struct Disc + { + Database::ReleaseId releaseId; + size_t discNumber; + }; + void processCommand(Command command, const std::vector& discs); + void processCommand(Command command, Database::TrackListId trackList); + void playTrackInRelease(Database::TrackId track); void setMaxTrackCountToEnqueue(std::size_t maxTrackCount) { _maxTrackCountToEnqueue = maxTrackCount; } diff --git a/src/lms/ui/explore/ReleaseView.cpp b/src/lms/ui/explore/ReleaseView.cpp index 82dea623..fdf49b4a 100644 --- a/src/lms/ui/explore/ReleaseView.cpp +++ b/src/lms/ui/explore/ReleaseView.cpp @@ -238,14 +238,14 @@ namespace UserInterface if (!release) throw ReleaseNotFoundException{}; - LmsApp->setTitle(release->getName()); + LmsApp->setTitle(std::string{ release->getName() }); _releaseId = *releaseId; refreshCopyright(release); refreshLinks(release); refreshSimilarReleases(similarReleasesIds); - bindString("name", Wt::WString::fromUTF8(release->getName()), Wt::TextFormat::Plain); + bindString("name", Wt::WString::fromUTF8(std::string{ release->getName() }), Wt::TextFormat::Plain); Wt::WString year{ ReleaseHelpers::buildReleaseYearString(release->getYear(), release->getOriginalYear()) }; if (!year.empty()) @@ -340,21 +340,47 @@ namespace UserInterface // Expect to be called in asc order std::map trackContainers; - auto getOrAddDiscContainer = [&](std::size_t discNumber, const std::string& discSubtitle) -> Wt::WContainerWidget* + auto getOrAddDiscContainer = [&, releaseId = _releaseId](std::size_t discNumber, const std::string& discSubtitle) -> Wt::WContainerWidget* { - { - auto it = trackContainers.find(discNumber); - if (it != std::cend(trackContainers)) - return it->second; - } + if (auto it{ trackContainers.find(discNumber) }; it != std::cend(trackContainers)) + return it->second; - Wt::WTemplate* disc{ rootContainer->addNew(Wt::WString::tr("Lms.Explore.Release.template.entry-disc")) }; + Template* disc{ rootContainer->addNew