Now manually parse sync/unsync lyrics frames in id3v2 tags

This commit is contained in:
emeric
2024-10-27 15:21:54 +01:00
parent 1e3dad7acc
commit f613d16fc2
14 changed files with 193 additions and 58 deletions
+13
View File
@@ -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<minutes>(timestamp).count() };
timestamp -= duration_cast<milliseconds>(minutes{ mins });
const auto secs{ duration_cast<seconds>(timestamp).count() };
timestamp -= duration_cast<milliseconds>(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
+4
View File
@@ -19,6 +19,7 @@
#pragma once
#include <chrono>
#include <initializer_list>
#include <optional>
#include <span>
@@ -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
+19 -1
View File
@@ -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
@@ -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; }
+5 -1
View File
@@ -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<void(std::string_view role, std::string_view artist)>;
virtual void visitPerformerTags(PerformerVisitor visitor) const = 0;
using LyricsVisitor = std::function<void(std::string_view language, std::string_view lyrics)>;
virtual void visitLyricsTags(LyricsVisitor visitor) const = 0;
virtual bool hasEmbeddedCover() const = 0;
virtual const AudioProperties& getAudioProperties() const = 0;
};
+24 -14
View File
@@ -121,6 +121,29 @@ namespace lms::metadata
return getTagValueFirstMatchAs<T>(tagReader, { tagType });
}
std::vector<Lyrics> getLyrics(const ITagReader& tagReader)
{
std::vector<Lyrics> 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<Artist> getArtists(const ITagReader& tagReader,
std::initializer_list<TagType> artistTagNames,
std::initializer_list<TagType> artistSortTagNames,
@@ -321,20 +344,7 @@ namespace lms::metadata
track.originalYear = utils::parseYear(*dateStr);
}
std::vector<std::string> lyricsEntries{ getTagValuesAs<std::string>(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<std::string>(tagReader, TagType::Comment, {} /* no custom delimiter on comments */);
track.copyright = getTagValueAs<std::string>(tagReader, TagType::Copyright).value_or("");
track.copyrightURL = getTagValueAs<std::string>(tagReader, TagType::CopyrightURL).value_or("");
+78 -24
View File
@@ -35,8 +35,10 @@
#include <taglib/mpcfile.h>
#include <taglib/mpegfile.h>
#include <taglib/opusfile.h>
#include <taglib/synchronizedlyricsframe.h>
#include <taglib/tag.h>
#include <taglib/tpropertymap.h>
#include <taglib/unsynchronizedlyricsframe.h>
#include <taglib/vorbisfile.h>
#include <taglib/wavfile.h>
#include <taglib/wavpackfile.h>
@@ -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());
};
auto processID3v2Tags = [&](TagLib::ID3v2::Tag& id3v2Tags) {
const auto& frameListMap{ id3v2Tags.frameListMap() };
// Not that good embedded pictures handling
// + get some extra tags that may not be known by taglib
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<const TagLib::ID3v2::SynchronizedLyricsFrame*>(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<const TagLib::ID3v2::UnsynchronizedLyricsFrame*>(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<TagLib::ASF::File*>(_file.file()) })
@@ -255,16 +311,8 @@ namespace lms::metadata
// MP3
else if (TagLib::MPEG::File * mp3File{ dynamic_cast<TagLib::MPEG::File*>(_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<TagLib::RIFF::AIFF::File*>(_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<TagLib::RIFF::WAV::File*>(_file.file()) })
{
if (wavFile->hasID3v2Tag())
{
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
if (!frameListMap["APIC"].isEmpty())
_hasEmbeddedCover = true;
}
processID3v2Tags(*wavFile->ID3v2Tag());
}
if (debug && core::Service<core::logging::ILogger>::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
@@ -20,6 +20,8 @@
#pragma once
#include <filesystem>
#include <map>
#include <string>
#include <taglib/fileref.h>
#include <taglib/tpropertymap.h>
@@ -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<std::string /* language*/, std::string /* lyrics */> _id3v2Lyrics;
};
} // namespace lms::metadata
@@ -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;
+7 -6
View File
@@ -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<IParser&>(parser).setUserExtraTags(std::vector<std::string>{ "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");
+27 -6
View File
@@ -38,15 +38,29 @@ namespace lms::metadata
};
using Tags = std::unordered_map<TagType, std::vector<std::string_view>>;
using Performers = std::unordered_map<std::string_view, std::vector<std::string_view>>;
using Performers = std::unordered_map<std::string_view /*role*/, std::vector<std::string_view> /*names*/>;
using ExtraUserTags = std::unordered_map<std::string_view, std::vector<std::string_view>>;
TestTagReader(Tags&& tags, Performers&& performers = {}, ExtraUserTags&& extraUserTags = {})
using LyricsTags = std::unordered_map<std::string_view /*language*/, std::string_view /*contents*/>;
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
@@ -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() : "<none>") << "'");
LMS_LOG(DBUPDATER, DEBUG, "Updating track for external lyrics " << trackLyrics->getAbsoluteFilePath() << ", using " << (track ? track->getAbsoluteFilePath() : "<none>"));
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++;
});
}
@@ -127,7 +127,7 @@ namespace lms::scanner
{
db::TrackLyrics::pointer lyrics{ session.create<db::TrackLyrics>() };
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);
+1 -1
View File
@@ -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;