diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp index 226caa1d..38f62bc5 100644 --- a/src/libs/core/impl/String.cpp +++ b/src/libs/core/impl/String.cpp @@ -463,4 +463,17 @@ namespace lms::core::stringUtils // assume UTC return date.toString("yyyy-MM-dd").toUTF8(); } + + std::string formatTimestamp(std::chrono::milliseconds timestamp) + { + using namespace std::chrono; + + const auto mins{ duration_cast(timestamp).count() }; + timestamp -= duration_cast(minutes{ mins }); + const auto secs{ duration_cast(timestamp).count() }; + timestamp -= duration_cast(seconds{ secs }); + const auto millis{ timestamp.count() }; + + return "[" + std::to_string(mins) + ":" + (secs < 10 ? "0" : "") + std::to_string(secs) + "." + (millis < 100 ? (millis < 10 ? "00" : "0") : "") + std::to_string(millis) + "]"; + } } // namespace lms::core::stringUtils diff --git a/src/libs/core/include/core/String.hpp b/src/libs/core/include/core/String.hpp index 2b2fedda..867e73e6 100644 --- a/src/libs/core/include/core/String.hpp +++ b/src/libs/core/include/core/String.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include #include @@ -113,4 +114,7 @@ namespace lms::core::stringUtils [[nodiscard]] std::string toISO8601String(const Wt::WDateTime& dateTime); [[nodiscard]] std::string toISO8601String(const Wt::WDate& date); + + // to "[minutes:seconds.milliseconds]" + std::string formatTimestamp(std::chrono::milliseconds timestamp); } // namespace lms::core::stringUtils \ No newline at end of file diff --git a/src/libs/metadata/impl/AvFormatTagReader.cpp b/src/libs/metadata/impl/AvFormatTagReader.cpp index f9ba4655..081144ce 100644 --- a/src/libs/metadata/impl/AvFormatTagReader.cpp +++ b/src/libs/metadata/impl/AvFormatTagReader.cpp @@ -79,7 +79,6 @@ namespace lms::metadata { TagType::LyricistSortOrder, { "LYRICISTSORT" } }, { TagType::Lyricists, { "LYRICISTS" } }, { TagType::LyricistsSortOrder, { "LYRICISTSSORT" } }, - { TagType::Lyrics, { "LYRICS" } }, { TagType::Media, { "TMED", "MEDIA", "WM/MEDIA" } }, { TagType::MixDJ, { "DJMIXER" } }, { TagType::Mixer, { "MIXER" } }, @@ -207,4 +206,23 @@ namespace lms::metadata visitor("", value); }); } + + void AvFormatTagReader::visitLyricsTags(LyricsVisitor visitor) const + { + // MPEG files: need to visit LYRICS-language entries + for (const auto& [tag, value] : _metaDataMap) + { + constexpr std::string_view lyricsPrefix{ "LYRICS-" }; + if (tag.starts_with(lyricsPrefix)) + { + const std::string language{ core::stringUtils::stringToLower(tag.substr(lyricsPrefix.size())) }; + visitor(language, value); + } + } + + // otherwise, just visit regular LYRICS tag with no language + visitTagValues("LYRICS", [&](std::string_view value) { + visitor("", value); + }); + } } // namespace lms::metadata diff --git a/src/libs/metadata/impl/AvFormatTagReader.hpp b/src/libs/metadata/impl/AvFormatTagReader.hpp index 15b806b0..cdf5db4f 100644 --- a/src/libs/metadata/impl/AvFormatTagReader.hpp +++ b/src/libs/metadata/impl/AvFormatTagReader.hpp @@ -40,6 +40,7 @@ namespace lms::metadata void visitTagValues(TagType tag, TagValueVisitor visitor) const override; void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override; void visitPerformerTags(PerformerVisitor visitor) const override; + void visitLyricsTags(LyricsVisitor visitor) const override; bool hasEmbeddedCover() const override { return _hasEmbeddedCover; } const AudioProperties& getAudioProperties() const override { return _audioProperties; } diff --git a/src/libs/metadata/impl/ITagReader.hpp b/src/libs/metadata/impl/ITagReader.hpp index da1a0d8b..5cad170a 100644 --- a/src/libs/metadata/impl/ITagReader.hpp +++ b/src/libs/metadata/impl/ITagReader.hpp @@ -76,7 +76,7 @@ namespace lms::metadata LyricistSortOrder, // non standard Lyricists, // non standard LyricistsSortOrder, // non standard - Lyrics, + // Lyrics, Handled separately Media, MixDJ, Mixer, @@ -104,6 +104,7 @@ namespace lms::metadata OriginalFilename, OriginalReleaseDate, OriginalReleaseYear, + // Performers, Handled separately Podcast, PodcastURL, Producer, @@ -154,6 +155,9 @@ namespace lms::metadata using PerformerVisitor = std::function; virtual void visitPerformerTags(PerformerVisitor visitor) const = 0; + using LyricsVisitor = std::function; + virtual void visitLyricsTags(LyricsVisitor visitor) const = 0; + virtual bool hasEmbeddedCover() const = 0; virtual const AudioProperties& getAudioProperties() const = 0; }; diff --git a/src/libs/metadata/impl/Parser.cpp b/src/libs/metadata/impl/Parser.cpp index 9206c7cf..0b077e27 100644 --- a/src/libs/metadata/impl/Parser.cpp +++ b/src/libs/metadata/impl/Parser.cpp @@ -121,6 +121,29 @@ namespace lms::metadata return getTagValueFirstMatchAs(tagReader, { tagType }); } + std::vector getLyrics(const ITagReader& tagReader) + { + std::vector res; + + tagReader.visitLyricsTags([&](std::string_view language, std::string_view lyricsText) { + std::istringstream iss{ std::string{ lyricsText } }; // TODO avoid copies (ispanstream?) + try + { + Lyrics lyrics{ parseLyrics(iss) }; + if (lyrics.language.empty()) + lyrics.language = language; + + res.emplace_back(std::move(lyrics)); + } + catch (const LyricsException& e) + { + LMS_LOG(METADATA, ERROR, "Failed to parse lyrics: " + std::string{ e.what() }); + } + }); + + return res; + } + std::vector getArtists(const ITagReader& tagReader, std::initializer_list artistTagNames, std::initializer_list artistSortTagNames, @@ -321,20 +344,7 @@ namespace lms::metadata track.originalYear = utils::parseYear(*dateStr); } - std::vector lyricsEntries{ getTagValuesAs(tagReader, TagType::Lyrics, {} /* no custom delimiter on lyrics */) }; - for (const std::string& lyrics : lyricsEntries) - { - std::istringstream iss{ lyrics }; - try - { - track.lyrics.emplace_back(parseLyrics(iss)); - } - catch (const LyricsException& e) - { - LMS_LOG(METADATA, ERROR, "Failed to parse lyrics: " + std::string{ e.what() }); - } - } - + track.lyrics = getLyrics(tagReader); // no custom delimiter on lyrics track.comments = getTagValuesAs(tagReader, TagType::Comment, {} /* no custom delimiter on comments */); track.copyright = getTagValueAs(tagReader, TagType::Copyright).value_or(""); track.copyrightURL = getTagValueAs(tagReader, TagType::CopyrightURL).value_or(""); diff --git a/src/libs/metadata/impl/TagLibTagReader.cpp b/src/libs/metadata/impl/TagLibTagReader.cpp index 6a4d0831..47c8de81 100644 --- a/src/libs/metadata/impl/TagLibTagReader.cpp +++ b/src/libs/metadata/impl/TagLibTagReader.cpp @@ -35,8 +35,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -100,7 +102,6 @@ namespace lms::metadata { TagType::LyricistSortOrder, { "LYRICISTSORT" } }, { TagType::Lyricists, { "LYRICISTS" } }, { TagType::LyricistsSortOrder, { "LYRICISTSSORT" } }, - { TagType::Lyrics, { "LYRICS" } }, { TagType::Media, { "MEDIA" } }, { TagType::MixDJ, { "DJMIXER" } }, { TagType::Mixer, { "MIXER" } }, @@ -220,8 +221,63 @@ namespace lms::metadata mergeTagMaps(_propertyMap, apeTag->properties()); }; - // Not that good embedded pictures handling - // + get some extra tags that may not be known by taglib + auto processID3v2Tags = [&](TagLib::ID3v2::Tag& id3v2Tags) { + const auto& frameListMap{ id3v2Tags.frameListMap() }; + + // Not that good embedded pictures handling + if (!frameListMap["APIC"].isEmpty()) + _hasEmbeddedCover = true; + + // Get some extra tags that may not be known by taglib + if (!frameListMap["TSST"].isEmpty() && !_propertyMap.contains("DISCSUBTITLE")) + _propertyMap["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString() }; + + // consider each frame hold a different set of lyrics + // Synchronized lyrics frames + for (const TagLib::ID3v2::Frame* frame : frameListMap["SYLT"]) + { + const auto* lyricsFrame{ dynamic_cast(frame) }; + if (!lyricsFrame) + continue; // TODO log or assert? + + const std::string language{ lyricsFrame->language().data(), lyricsFrame->language().size() }; + std::string lyrics; + for (const TagLib::ID3v2::SynchronizedLyricsFrame::SynchedText& synchedText : lyricsFrame->synchedText()) + { + std::chrono::milliseconds timestamp{}; + switch (lyricsFrame->timestampFormat()) + { + case TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds: + timestamp = std::chrono::milliseconds{ synchedText.time }; + break; + case TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames: + timestamp = std::chrono::milliseconds{ _audioProperties.sampleRate ? (synchedText.time * 1000) / _audioProperties.sampleRate : 0 }; + break; + case TagLib::ID3v2::SynchronizedLyricsFrame::Unknown: + break; + } + + if (!lyrics.empty()) + lyrics += '\n'; + + lyrics += core::stringUtils::formatTimestamp(timestamp); + lyrics += synchedText.text.to8Bit(true); + } + + _id3v2Lyrics.emplace(language, std::move(lyrics)); + } + + // Unsynchronized lyrics frames + for (const TagLib::ID3v2::Frame* frame : frameListMap["USLT"]) + { + const auto* lyricsFrame{ dynamic_cast(frame) }; + if (!lyricsFrame) + continue; // TODO log or assert? + + const std::string language{ lyricsFrame->language().data(), lyricsFrame->language().size() }; + _id3v2Lyrics.emplace(language, lyricsFrame->text().to8Bit(true)); + } + }; // WMA if (TagLib::ASF::File * asfFile{ dynamic_cast(_file.file()) }) @@ -255,16 +311,8 @@ namespace lms::metadata // MP3 else if (TagLib::MPEG::File * mp3File{ dynamic_cast(_file.file()) }) { - if (mp3File->ID3v2Tag()) - { - const auto& frameListMap{ mp3File->ID3v2Tag()->frameListMap() }; - - if (!frameListMap["APIC"].isEmpty()) - _hasEmbeddedCover = true; - - if (!frameListMap["TSST"].isEmpty() && !_propertyMap.contains("DISCSUBTITLE")) - _propertyMap["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString() }; - } + if (mp3File->hasID3v2Tag()) + processID3v2Tags(*mp3File->ID3v2Tag()); getAPETags(mp3File->APETag()); } @@ -326,22 +374,12 @@ namespace lms::metadata else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast(_file.file()) }) { if (aiffFile->hasID3v2Tag()) - { - const auto& frameListMap{ aiffFile->tag()->frameListMap() }; - - if (!frameListMap["APIC"].isEmpty()) - _hasEmbeddedCover = true; - } + processID3v2Tags(*aiffFile->tag()); } else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast(_file.file()) }) { if (wavFile->hasID3v2Tag()) - { - const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() }; - - if (!frameListMap["APIC"].isEmpty()) - _hasEmbeddedCover = true; - } + processID3v2Tags(*wavFile->ID3v2Tag()); } if (debug && core::Service::get()->isSeverityActive(core::logging::Severity::DEBUG)) @@ -442,4 +480,20 @@ namespace lms::metadata } } } + + void TagLibTagReader::visitLyricsTags(LyricsVisitor visitor) const + { + if (!_id3v2Lyrics.empty()) + { + for (const auto& [language, lyrics] : _id3v2Lyrics) + visitor(language, lyrics); + } + else + { + // otherwise, just visit regular LYRICS tag with no language + visitTagValues("LYRICS", [&](std::string_view value) { + visitor("", value); + }); + } + } } // namespace lms::metadata diff --git a/src/libs/metadata/impl/TagLibTagReader.hpp b/src/libs/metadata/impl/TagLibTagReader.hpp index 0b13853d..86341ba4 100644 --- a/src/libs/metadata/impl/TagLibTagReader.hpp +++ b/src/libs/metadata/impl/TagLibTagReader.hpp @@ -20,6 +20,8 @@ #pragma once #include +#include +#include #include #include @@ -43,6 +45,7 @@ namespace lms::metadata void visitTagValues(TagType tag, TagValueVisitor visitor) const override; void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override; void visitPerformerTags(PerformerVisitor visitor) const override; + void visitLyricsTags(LyricsVisitor visitor) const override; bool hasEmbeddedCover() const override { return _hasEmbeddedCover; } const AudioProperties& getAudioProperties() const override { return _audioProperties; } @@ -51,5 +54,6 @@ namespace lms::metadata AudioProperties _audioProperties; TagLib::PropertyMap _propertyMap; // case-insensitive keys bool _hasEmbeddedCover{}; + std::multimap _id3v2Lyrics; }; } // namespace lms::metadata diff --git a/src/libs/metadata/include/metadata/Lyrics.hpp b/src/libs/metadata/include/metadata/Lyrics.hpp index 31afca36..d7e61e63 100644 --- a/src/libs/metadata/include/metadata/Lyrics.hpp +++ b/src/libs/metadata/include/metadata/Lyrics.hpp @@ -34,7 +34,7 @@ namespace lms::metadata { struct Lyrics { - std::string language{ "und" }; + std::string language; std::chrono::milliseconds offset{}; std::string displayArtist; std::string displayAlbum; diff --git a/src/libs/metadata/test/Parser.cpp b/src/libs/metadata/test/Parser.cpp index 9986743e..43258543 100644 --- a/src/libs/metadata/test/Parser.cpp +++ b/src/libs/metadata/test/Parser.cpp @@ -66,7 +66,6 @@ namespace lms::metadata { TagType::Remixer, { "MyRemixer1", "MyRemixer2" } }, { TagType::RecordLabel, { "Label1", "Label2" } }, { TagType::Language, { "Language1", "Language2" } }, - { TagType::Lyrics, { "[00:00.00]First line\n[00:01.00]Second line" } }, { TagType::Lyricist, { "MyLyricist1", "MyLyricist2" } }, { TagType::OriginalReleaseDate, { "2019/02/03" } }, { TagType::ReleaseType, { "Album", "Compilation" } }, @@ -76,12 +75,13 @@ namespace lms::metadata { 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" } } } + } }; + testTags.setExtraUserTags({ { "MY_AWESOME_TAG_A", { "MyTagValue1ForTagA", "MyTagValue2ForTagA" } }, + { "MY_AWESOME_TAG_B", { "MyTagValue1ForTagB", "MyTagValue2ForTagB" } } }); + testTags.setPerformersTags({ { "RoleA", { "MyPerformer1ForRoleA", "MyPerformer2ForRoleA" } }, + { "RoleB", { "MyPerformer1ForRoleB", "MyPerformer2ForRoleB" } } }); + testTags.setLyricsTags({ { "eng", "[00:00.00]First line\n[00:01.00]Second line" } }); static_cast(parser).setUserExtraTags(std::vector{ "MY_AWESOME_TAG_A", "MY_AWESOME_TAG_B", "MY_AWESOME_MISSING_TAG" }); @@ -137,6 +137,7 @@ namespace lms::metadata EXPECT_EQ(track->lyricistArtists[0].name, "MyLyricist1"); EXPECT_EQ(track->lyricistArtists[1].name, "MyLyricist2"); ASSERT_EQ(track->lyrics.size(), 1); + EXPECT_EQ(track->lyrics.front().language, "eng"); ASSERT_EQ(track->lyrics.front().synchronizedLines.size(), 2); ASSERT_TRUE(track->lyrics.front().synchronizedLines.contains(std::chrono::milliseconds{ 0 })); EXPECT_EQ(track->lyrics.front().synchronizedLines.find(std::chrono::milliseconds{ 0 })->second, "First line"); diff --git a/src/libs/metadata/test/TestTagReader.hpp b/src/libs/metadata/test/TestTagReader.hpp index d27acfaf..77ebcdf5 100644 --- a/src/libs/metadata/test/TestTagReader.hpp +++ b/src/libs/metadata/test/TestTagReader.hpp @@ -38,15 +38,29 @@ namespace lms::metadata }; using Tags = std::unordered_map>; - using Performers = std::unordered_map>; + using Performers = std::unordered_map /*names*/>; using ExtraUserTags = std::unordered_map>; - TestTagReader(Tags&& tags, Performers&& performers = {}, ExtraUserTags&& extraUserTags = {}) + using LyricsTags = std::unordered_map; + TestTagReader(Tags&& tags) : _tags{ std::move(tags) } - , _performers{ std::move(performers) } - , _extraUserTags{ std::move(extraUserTags) } { } + void setPerformersTags(Performers&& performers) + { + _performers = std::move(performers); + } + + void setExtraUserTags(ExtraUserTags&& extraUserTags) + { + _extraUserTags = std::move(extraUserTags); + } + + void setLyricsTags(LyricsTags&& lyricsTags) + { + _lyricsTags = std::move(lyricsTags); + } + void visitTagValues(TagType tag, TagValueVisitor visitor) const override { auto itValues{ _tags.find(tag) }; @@ -75,13 +89,20 @@ namespace lms::metadata } } + void visitLyricsTags(LyricsVisitor visitor) const override + { + for (const auto& [language, lyrics] : _lyricsTags) + visitor(language, lyrics); + } + bool hasEmbeddedCover() const override { return false; }; const AudioProperties& getAudioProperties() const override { return audioProperties; } private: const Tags _tags; - const Performers _performers; - const ExtraUserTags _extraUserTags; + Performers _performers; + ExtraUserTags _extraUserTags; + LyricsTags _lyricsTags; }; } // namespace lms::metadata \ No newline at end of file diff --git a/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.cpp b/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.cpp index 38d8b014..19c76c79 100644 --- a/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.cpp +++ b/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.cpp @@ -96,9 +96,14 @@ namespace lms::scanner db::Track::pointer track{ getMatchingTrack(searchContext.session, trackLyrics) }; if (track != trackLyrics->getTrack()) { - LMS_LOG(DBUPDATER, DEBUG, "Updating track for external lyrics '" << trackLyrics->getAbsoluteFilePath() << "', using '" << (track ? track->getAbsoluteFilePath().c_str() : "") << "'"); + LMS_LOG(DBUPDATER, DEBUG, "Updating track for external lyrics " << trackLyrics->getAbsoluteFilePath() << ", using " << (track ? track->getAbsoluteFilePath() : "")); trackLyricsAssociations.push_back(TrackLyricsAssociation{ .trackLyricsId = trackLyrics->getId(), .trackId = (track ? track->getId() : db::TrackId{}) }); } + else if (!track) + { + LMS_LOG(DBUPDATER, DEBUG, "No track found for external lyrics " << trackLyrics->getAbsoluteFilePath() << "'"); + } + searchContext.processedLyricsCount++; }); } diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp index 2825fdd7..90e75144 100644 --- a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp +++ b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp @@ -127,7 +127,7 @@ namespace lms::scanner { db::TrackLyrics::pointer lyrics{ session.create() }; - lyrics.modify()->setLanguage(lyricsInfo.language); + lyrics.modify()->setLanguage(!lyricsInfo.language.empty() ? lyricsInfo.language : "xxx"); lyrics.modify()->setOffset(lyricsInfo.offset); lyrics.modify()->setDisplayArtist(lyricsInfo.displayArtist); lyrics.modify()->setDisplayTitle(lyricsInfo.displayTitle); @@ -956,7 +956,7 @@ namespace lms::scanner trackLyrics.modify()->setLastWriteTime(fileInfo->lastWriteTime); trackLyrics.modify()->setFileSize(fileInfo->fileSize); - trackLyrics.modify()->setLanguage(lyricsInfo->language); + trackLyrics.modify()->setLanguage(!lyricsInfo->language.empty() ? lyricsInfo->language : "xxx"); trackLyrics.modify()->setOffset(lyricsInfo->offset); trackLyrics.modify()->setDisplayTitle(lyricsInfo->displayTitle); trackLyrics.modify()->setDisplayArtist(lyricsInfo->displayArtist); diff --git a/src/tools/metadata/LmsMetadata.cpp b/src/tools/metadata/LmsMetadata.cpp index 8458085f..550e531f 100644 --- a/src/tools/metadata/LmsMetadata.cpp +++ b/src/tools/metadata/LmsMetadata.cpp @@ -45,7 +45,7 @@ namespace lms::metadata os << "\tLanguage: " << lyrics.language << std::endl; os << "\tSynchronized: " << !lyrics.synchronizedLines.empty() << std::endl; for (const auto& [timestamp, line] : lyrics.synchronizedLines) - os << "\t[" << timestamp.count() << "] '" << line << "'" << std::endl; + os << "\t" << core::stringUtils::formatTimestamp(timestamp) << " '" << line << "'" << std::endl; for (const auto& line : lyrics.unsynchronizedLines) os << "\t'" << line << "'" << std::endl;