/*
* 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