/* * 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 "TrackMetadataParser.hpp" #include #include #include #include "audio/IAudioFileInfo.hpp" #include "core/ILogger.hpp" #include "core/PartialDateTime.hpp" #include "core/String.hpp" #include "scanners/lyrics/LyricsParser.hpp" #include "Utils.hpp" namespace lms::scanner { namespace { void visitTagValues(const audio::ITagReader& tagReader, std::string_view tagType, std::span tagDelimiters, audio::ITagReader::TagValueVisitor visitor) { tagReader.visitTagValues(tagType, [&](std::string_view value) { auto visitTagIfNonEmpty{ [&](std::string_view tag) { tag = core::stringUtils::stringTrim(tag); if (!tag.empty()) visitor(tag); } }; for (std::string_view tagDelimiter : tagDelimiters) { if (value.find(tagDelimiter) != std::string_view::npos) { for (std::string_view splitTag : core::stringUtils::splitString(value, tagDelimiters)) visitTagIfNonEmpty(splitTag); return; } } // no delimiter found, or no delimiter to be used visitTagIfNonEmpty(value); }); } template void addTagIfNonEmpty(std::vector& res, std::string_view tag) { if (tag.empty()) return; if (std::optional val{ core::stringUtils::readAs(tag) }) res.emplace_back(std::move(*val)); } template std::vector getTagValuesFirstMatchAs(const audio::ITagReader& tagReader, std::initializer_list tagTypes, std::span tagDelimiters, const TrackMetadataParser::WhiteList* whitelist = nullptr) { std::vector res; for (const audio::TagType tagType : tagTypes) { tagReader.visitTagValues(tagType, [&](std::string_view value) { value = core::stringUtils::stringTrim(value); // short path: no custom delimiter if (tagDelimiters.empty()) { addTagIfNonEmpty(res, value); return; } // Algo: // 1. replace whitelist entries by placeholders // 2. apply delimiters // 3. replace whitelist entries back constexpr std::string_view substitutionPrefix{ "__LMS_ENTRY__" }; std::unordered_map substitutionMap; std::string strToSplit{ value }; if (whitelist) { std::size_t counter{}; for (std::string_view whiteListEntry : *whitelist) { whiteListEntry = core::stringUtils::stringTrim(whiteListEntry); const std::string::size_type pos{ strToSplit.find(whiteListEntry) }; if (pos == std::string::npos) continue; std::string substitutionStr{ std::string{ substitutionPrefix } + std::to_string(counter++) }; strToSplit.replace(pos, whiteListEntry.size(), substitutionStr); substitutionMap.emplace(std::move(substitutionStr), whiteListEntry); } } for (std::string_view strSplit : core::stringUtils::splitString(strToSplit, tagDelimiters)) { std::string str{ core::stringUtils::stringTrim(strSplit) }; while (true) { std::string::size_type prefixPos{ str.find(substitutionPrefix) }; if (prefixPos == std::string::npos) break; std::string::size_type counterEnd{ prefixPos + substitutionPrefix.size() }; while (std::isdigit(str[counterEnd])) counterEnd++; std::string substitutionStr{ str.substr(prefixPos, counterEnd - prefixPos) }; auto it{ substitutionMap.find(substitutionStr) }; if (it != std::cend(substitutionMap)) str.replace(prefixPos, counterEnd - prefixPos, it->second); } addTagIfNonEmpty(res, str); } }); if (!res.empty()) break; } return res; } template std::optional getTagValueFirstMatchAs(const audio::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 audio::ITagReader& tagReader, audio::TagType tagType, std::span tagDelimiters) { return getTagValuesFirstMatchAs(tagReader, { tagType }, tagDelimiters); } template std::optional getTagValueAs(const audio::ITagReader& tagReader, audio::TagType tagType) { return getTagValueFirstMatchAs(tagReader, { tagType }); } std::vector getLyrics(const audio::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?) Lyrics lyrics{ parseLyrics(iss) }; if (lyrics.language.empty()) lyrics.language = language; res.emplace_back(std::move(lyrics)); }); return res; } std::vector getArtists(const audio::ITagReader& tagReader, std::initializer_list artistTagNames, std::initializer_list artistSortTagNames, std::initializer_list artistMBIDTagNames, const TrackMetadataParser::Parameters& params) { std::vector artistNames{ getTagValuesFirstMatchAs(tagReader, artistTagNames, params.artistTagDelimiters, ¶ms.artistsToNotSplit) }; if (artistNames.empty()) return {}; std::vector artistSortNames{ getTagValuesFirstMatchAs(tagReader, artistSortTagNames, params.artistTagDelimiters, ¶ms.artistsToNotSplit) }; std::vector artistMBIDs{ getTagValuesFirstMatchAs(tagReader, artistMBIDTagNames, params.defaultTagDelimiters) }; 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 audio::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) }; core::stringUtils::capitalize(performer.role); performers[performer.role].push_back(std::move(performer.artist)); } else { // "PERFORMER:role", "artist" (MP3) std::string roleCapitalized{ core::stringUtils::stringToLower(role) }; core::stringUtils::capitalize(roleCapitalized); performers[roleCapitalized].push_back(Artist{ name }); } }); return performers; } bool strIsMatchingArtistNames(std::string_view str, std::span artistNames) { std::string_view::size_type currentOffset{}; for (const std::string_view artistName : artistNames) { std::string_view::size_type newPos{ str.find(artistName, currentOffset) }; if (newPos == std::string_view::npos) return false; currentOffset = newPos + artistName.size(); } return true; } bool strIsContainingAny(std::string_view str, std::span subStrs) { return std::any_of(std::cbegin(subStrs), std::cend(subStrs), [&str](const std::string& subStr) { return str.find(subStr) != std::string_view::npos; }); } std::string computeArtistDisplayName(std::span artists, const std::optional& artistTag, std::span artistTagDelimiters) { std::string artistDisplayName; if (artists.size() == 1) artistDisplayName = artists.front().name; else if (artists.size() > 1) { std::vector artistNames; std::transform(std::cbegin(artists), std::cend(artists), std::back_inserter(artistNames), [](const Artist& artist) -> std::string_view { return artist.name; }); // Picard use case: if we manage to match all artists in the "artist" tag (considered single-valued), and if no custom delimiter is hit, we use it as the display name // Otherwise, we reconstruct the string using a standard, hardcoded, join if (artistTag && strIsMatchingArtistNames(*artistTag, artistNames)) { // Limitation: this test does not take the whitelist into account if (!strIsContainingAny(*artistTag, artistTagDelimiters)) artistDisplayName = *artistTag; } if (artistDisplayName.empty()) artistDisplayName = core::stringUtils::joinStrings(artistNames, ", "); } return artistDisplayName; } std::optional getAdvisory(const audio::ITagReader& tagReader) { if (const auto value{ getTagValueAs(tagReader, audio::TagType::Advisory) }) { switch (*value) { case 1: case 4: return Track::Advisory::Explicit; case 2: return Track::Advisory::Clean; case 0: return Track::Advisory::Unknown; } } return std::nullopt; } } // namespace TrackMetadataParser::TrackMetadataParser(const Parameters& params) : _params{ params } { } TrackMetadataParser::~TrackMetadataParser() = default; Track TrackMetadataParser::parseTrackMetaData(const audio::ITagReader& tagReader) const { Track track; processTags(tagReader, track); return track; } void TrackMetadataParser::processTags(const audio::ITagReader& tagReader, Track& track) const { using namespace audio; 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 (const auto dateStr{ getTagValueAs(tagReader, TagType::Date) }) { if (const core::PartialDateTime date{ core::PartialDateTime::fromString(*dateStr) }; date.isValid()) track.date = date; } if (const auto dateStr = getTagValueAs(tagReader, TagType::OriginalReleaseDate)) { if (const core::PartialDateTime date{ core::PartialDateTime::fromString(*dateStr) }; date.isValid()) track.originalDate = date; } if (const auto dateStr{ getTagValueAs(tagReader, TagType::OriginalReleaseYear) }) track.originalYear = utils::parseYear(*dateStr); if (const auto encodingTimeStr{ getTagValueAs(tagReader, TagType::EncodingTime) }) { if (const core::PartialDateTime date{ core::PartialDateTime::fromString(*encodingTimeStr) }; date.isValid()) track.encodingTime = date; } track.advisory = getAdvisory(tagReader); 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(""); track.replayGain = getTagValueAs(tagReader, TagType::ReplayGainTrackGain); for (const std::string& userExtraTag : _params.userExtraTags) { visitTagValues(tagReader, userExtraTag, _params.defaultTagDelimiters, [&](std::string_view value) { value = core::stringUtils::stringTrim(value); if (!value.empty()) track.userExtraTags[userExtraTag].push_back(std::string{ value }); }); } track.genres = getTagValuesAs(tagReader, TagType::Genre, _params.defaultTagDelimiters); track.moods = getTagValuesAs(tagReader, TagType::Mood, _params.defaultTagDelimiters); track.groupings = getTagValuesAs(tagReader, TagType::Grouping, _params.defaultTagDelimiters); track.languages = getTagValuesAs(tagReader, TagType::Language, _params.defaultTagDelimiters); std::vector artistDelimiters{}; track.medium = getMedium(tagReader); track.artists = getArtists(tagReader, { TagType::Artists, TagType::Artist }, { TagType::ArtistsSortOrder, TagType::ArtistSortOrder }, { TagType::MusicBrainzArtistID }, _params); track.artistDisplayName = computeArtistDisplayName(track.artists, getTagValueAs(tagReader, TagType::Artist), _params.artistTagDelimiters); track.conductorArtists = getArtists(tagReader, { TagType::Conductors, TagType::Conductor }, { TagType::ConductorsSortOrder, TagType::ConductorSortOrder }, { TagType::MusicBrainzConductorID }, _params); track.composerArtists = getArtists(tagReader, { TagType::Composers, TagType::Composer }, { TagType::ComposersSortOrder, TagType::ComposerSortOrder }, { TagType::MusicBrainzComposerID }, _params); track.lyricistArtists = getArtists(tagReader, { TagType::Lyricists, TagType::Lyricist }, { TagType::LyricistsSortOrder, TagType::LyricistSortOrder }, { TagType::MusicBrainzLyricistID }, _params); track.mixerArtists = getArtists(tagReader, { TagType::Mixers, TagType::Mixer }, { TagType::MixersSortOrder, TagType::MixerSortOrder }, { TagType::MusicBrainzMixerID }, _params); track.producerArtists = getArtists(tagReader, { TagType::Producers, TagType::Producer }, { TagType::ProducersSortOrder, TagType::ProducerSortOrder }, { TagType::MusicBrainzProducerID }, _params); track.remixerArtists = getArtists(tagReader, { TagType::Remixers, TagType::Remixer }, { TagType::RemixersSortOrder, TagType::RemixerSortOrder }, { TagType::MusicBrainzRemixerID }, _params); track.performerArtists = getPerformerArtists(tagReader); // artistDelimiters not supported // If a file has originalDate but no originalYear, set it if (!track.originalYear) track.originalYear = track.originalDate.getYear(); } std::optional TrackMetadataParser::getMedium(const audio::ITagReader& tagReader) const { using namespace audio; 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{ core::stringUtils::splitString(*value, '/') }; if (strings.size() == 2) medium->trackCount = core::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 TrackMetadataParser::getRelease(const audio::ITagReader& tagReader) const { using namespace audio; std::optional release; auto releaseName{ getTagValueAs(tagReader, TagType::Album) }; if (!releaseName) return release; release.emplace(); release->name = std::move(*releaseName); release->sortName = getTagValueAs(tagReader, TagType::AlbumSortOrder).value_or(release->name); release->artists = getArtists(tagReader, { TagType::AlbumArtists, TagType::AlbumArtist }, { TagType::AlbumArtistsSortOrder, TagType::AlbumArtistSortOrder }, { TagType::MusicBrainzReleaseArtistID }, _params); release->artistDisplayName = computeArtistDisplayName(release->artists, getTagValueAs(tagReader, TagType::AlbumArtist), _params.artistTagDelimiters); release->mbid = getTagValueAs(tagReader, TagType::MusicBrainzReleaseID); release->groupMBID = getTagValueAs(tagReader, TagType::MusicBrainzReleaseGroupID); release->mediumCount = getTagValueAs(tagReader, TagType::TotalDiscs); release->isCompilation = getTagValueAs(tagReader, TagType::Compilation).value_or(false); release->barcode = getTagValueAs(tagReader, TagType::Barcode).value_or(""); release->labels = getTagValuesAs(tagReader, TagType::RecordLabel, _params.defaultTagDelimiters); release->comment = getTagValueAs(tagReader, TagType::AlbumComment).value_or(""); release->countries = getTagValuesAs(tagReader, TagType::ReleaseCountry, _params.defaultTagDelimiters); 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{ core::stringUtils::splitString(*value, '/') }; if (strings.size() == 2) release->mediumCount = core::stringUtils::readAs(strings[1]); } } release->releaseTypes = getTagValuesAs(tagReader, TagType::ReleaseType, _params.defaultTagDelimiters); return release; } } // namespace lms::scanner