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