Now manually parse sync/unsync lyrics frames in id3v2 tags
This commit is contained in:
@@ -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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
// 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<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
|
||||
|
||||
Reference in New Issue
Block a user