Added a way to set custom tag delimiters for artists and for other fields, fixes #417
This commit is contained in:
@@ -42,6 +42,24 @@
|
||||
${extra-tags-to-scan-info}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="${id:artist-tag-delimiter}">
|
||||
${tr:Lms.Admin.Database.artist-tag-delimiter}
|
||||
</label>
|
||||
${artist-tag-delimiter class="form-control"}
|
||||
<div class="invalid-feedback">
|
||||
${artist-tag-delimiter-info}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="${id:default-tag-delimiter}">
|
||||
${tr:Lms.Admin.Database.default-tag-delimiter}
|
||||
</label>
|
||||
${default-tag-delimiter class="form-control"}
|
||||
<div class="invalid-feedback">
|
||||
${default-tag-delimiter-info}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
${save-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"}
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
<message id="Lms.Admin.MediaLibrary.root-path">Root directory</message>
|
||||
|
||||
<!--Scan settings-->
|
||||
<message id="Lms.Admin.Database.artist-tag-delimiter">Delimiter to be used for splitting artist names (only if the file does not contain any multi-valued tag)</message>
|
||||
<message id="Lms.Admin.Database.daily">Daily</message>
|
||||
<message id="Lms.Admin.Database.default-tag-delimiter">Delimiter to be used for splitting other tags (only if the file does not contain any multi-valued tag)</message>
|
||||
<message id="Lms.Admin.Database.extra-tags-to-scan">Extra tags to scan (use <kbd>;</kbd> as separator)</message>
|
||||
<message id="Lms.Admin.Database.hourly">Hourly</message>
|
||||
<message id="Lms.Admin.Database.immediate-scan">Scan now!</message>
|
||||
@@ -77,6 +79,7 @@
|
||||
<message id="Lms.Admin.Database.similarity-engine-type">Similarity engine</message>
|
||||
<message id="Lms.Admin.Database.similarity-engine-type.clusters">Tag-based</message>
|
||||
<message id="Lms.Admin.Database.similarity-engine-type.none">None</message>
|
||||
<message id="Lms.Admin.Database.tag-delimiter-must-not-contain-only-spaces">The tag delimiter must not consist solely of spaces</message>
|
||||
<message id="Lms.Admin.Database.update-period">Update period</message>
|
||||
<message id="Lms.Admin.Database.update-start-time">Update start time</message>
|
||||
<message id="Lms.Admin.Database.weekly">Weekly</message>
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
<message id="Lms.Admin.MediaLibrary.root-path">Répertoire racine</message>
|
||||
|
||||
<!--Scan settings-->
|
||||
<message id="Lms.Admin.Database.artist-tag-delimiter">Délimiteur à utiliser pour séparer les noms d'artistes (uniquement si le fichier ne contient pas de tag à valeurs multiples)</message>
|
||||
<message id="Lms.Admin.Database.daily">Tous les jours</message>
|
||||
<message id="Lms.Admin.Database.default-tag-delimiter">Délimiteur à utiliser pour séparer les autres tags (uniquement si le fichier ne contient pas de tag à valeurs multiples)</message>
|
||||
<message id="Lms.Admin.Database.extra-tags-to-scan">Tags supplémentaires à scanner (utiliser <kbd>;</kbd> comme séparateur)</message>
|
||||
<message id="Lms.Admin.Database.hourly">Toutes les heures</message>
|
||||
<message id="Lms.Admin.Database.immediate-scan">Scanner maintenant !</message>
|
||||
@@ -77,6 +79,7 @@
|
||||
<message id="Lms.Admin.Database.similarity-engine-type">Moteur de similarité</message>
|
||||
<message id="Lms.Admin.Database.similarity-engine-type.clusters">Basé sur les tags</message>
|
||||
<message id="Lms.Admin.Database.similarity-engine-type.none">Aucun</message>
|
||||
<message id="Lms.Admin.Database.tag-delimiter-must-not-contain-only-spaces">Le délimiteur de tag ne doit pas comporter uniquement des espaces</message>
|
||||
<message id="Lms.Admin.Database.update-period">Périodicité des mises à jour</message>
|
||||
<message id="Lms.Admin.Database.update-start-time">Heure de départ de la mise à jour</message>
|
||||
<message id="Lms.Admin.Database.weekly">Toutes les semaines</message>
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
<message id="Lms.Admin.MediaLibrary.root-path">Cartella principale</message>
|
||||
|
||||
<!--Scan settings-->
|
||||
<message id="Lms.Admin.Database.artist-tag-delimiter">Delimitatore da utilizzare per separare i nomi degli artisti (solo se il file non contiene alcun tag con valori multipli)</message>
|
||||
<message id="Lms.Admin.Database.daily">Giornaliera</message>
|
||||
<message id="Lms.Admin.Database.default-tag-delimiter">Delimitatore da utilizzare per separare gli altri tag (solo se il file non contiene alcun tag con valori multipli)</message>
|
||||
<message id="Lms.Admin.Database.extra-tags-to-scan">Tag aggiuntivi da scansionare (usa <kbd>;</kbd> come separatore)</message>
|
||||
<message id="Lms.Admin.Database.hourly">Ogni ora</message>
|
||||
<message id="Lms.Admin.Database.immediate-scan">Scansiona ora!</message>
|
||||
@@ -77,6 +79,7 @@
|
||||
<message id="Lms.Admin.Database.similarity-engine-type">Motore di similarità</message>
|
||||
<message id="Lms.Admin.Database.similarity-engine-type.clusters">Basato su tag</message>
|
||||
<message id="Lms.Admin.Database.similarity-engine-type.none">Nessuno</message>
|
||||
<message id="Lms.Admin.Database.tag-delimiter-must-not-contain-only-spaces">Il delimitatore del tag non deve consistere esclusivamente di spazi</message>
|
||||
<message id="Lms.Admin.Database.update-period">Frequenza di aggiornamento</message>
|
||||
<message id="Lms.Admin.Database.update-start-time">Orario di aggiornamento</message>
|
||||
<message id="Lms.Admin.Database.weekly">Settimanale</message>
|
||||
|
||||
@@ -64,8 +64,10 @@
|
||||
|
||||
|
||||
<!--Scan settings-->
|
||||
|
||||
<message id="Lms.Admin.Database.daily">每日</message>
|
||||
|
||||
|
||||
<message id="Lms.Admin.Database.hourly">每小时</message>
|
||||
<message id="Lms.Admin.Database.immediate-scan">立即扫描!</message>
|
||||
<message id="Lms.Admin.Database.monthly">每月</message>
|
||||
|
||||
@@ -84,6 +84,7 @@ namespace Av
|
||||
public:
|
||||
virtual ~IAudioFile() = default;
|
||||
|
||||
// Keys are forced to be in upper case
|
||||
using MetadataMap = std::unordered_map<std::string, std::string>;
|
||||
|
||||
virtual const std::filesystem::path& getPath() const = 0;
|
||||
|
||||
@@ -215,7 +215,7 @@ CREATE TABLE IF NOT EXISTS "track_backup" (
|
||||
session.getDboSession().execute("ALTER TABLE starred_release RENAME COLUMN scrobbling_state TO sync_state");
|
||||
session.getDboSession().execute("ALTER TABLE starred_track RENAME COLUMN scrobbler TO backend");
|
||||
session.getDboSession().execute("ALTER TABLE starred_track RENAME COLUMN scrobbling_state TO sync_state");
|
||||
|
||||
|
||||
session.getDboSession().execute("UPDATE user SET feedback_backend = scrobbling_backend");
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ CREATE TABLE IF NOT EXISTS "track_backup" (
|
||||
constraint "fk_track_media_library" foreign key ("media_library_id") references "media_library" ("id") on delete set null deferrable initially deferred
|
||||
))");
|
||||
|
||||
// Migrate data, with the new media_library_id field set to 1
|
||||
// Migrate data, with the new media_library_id field set to 1
|
||||
session.getDboSession().execute(R"(INSERT INTO track_backup
|
||||
SELECT
|
||||
id,
|
||||
@@ -394,6 +394,13 @@ SELECT
|
||||
session.getDboSession().execute("ALTER TABLE track_backup RENAME TO track");
|
||||
}
|
||||
|
||||
void migrateFromV51(Session& session)
|
||||
{
|
||||
// Add custom artist tag delimiters, no need to rescan since it has no effect when empty
|
||||
session.getDboSession().execute("ALTER TABLE scan_settings ADD artist_tag_delimiters TEXT NOT NULL DEFAULT ''");
|
||||
session.getDboSession().execute("ALTER TABLE scan_settings ADD default_tag_delimiters TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
void doDbMigration(Session& session)
|
||||
{
|
||||
static const std::string outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
|
||||
@@ -422,6 +429,7 @@ SELECT
|
||||
{48, migrateFromV48},
|
||||
{49, migrateFromV49},
|
||||
{50, migrateFromV50},
|
||||
{51, migrateFromV51},
|
||||
};
|
||||
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace Database
|
||||
class Session;
|
||||
|
||||
using Version = std::size_t;
|
||||
static constexpr Version LMS_DATABASE_VERSION{ 51 };
|
||||
static constexpr Version LMS_DATABASE_VERSION{ 52 };
|
||||
class VersionInfo
|
||||
{
|
||||
public:
|
||||
|
||||
@@ -46,7 +46,7 @@ namespace Database
|
||||
|
||||
std::vector<std::filesystem::path> ScanSettings::getAudioFileExtensions() const
|
||||
{
|
||||
const auto extensions{ StringUtils::splitString(_audioFileExtensions, " ") };
|
||||
const auto extensions{ StringUtils::splitString(_audioFileExtensions, ' ') };
|
||||
|
||||
std::vector<std::filesystem::path> res(std::cbegin(extensions), std::cend(extensions));
|
||||
std::sort(std::begin(res), std::end(res));
|
||||
@@ -57,7 +57,17 @@ namespace Database
|
||||
|
||||
std::vector<std::string_view> ScanSettings::getExtraTagsToScan() const
|
||||
{
|
||||
return StringUtils::splitString(_extraTagsToScan, ";");
|
||||
return StringUtils::splitString(_extraTagsToScan, ';');
|
||||
}
|
||||
|
||||
std::vector<std::string> ScanSettings::getArtistTagDelimiters() const
|
||||
{
|
||||
return StringUtils::splitEscapedStrings(_artistTagDelimiters, ';', '\\');
|
||||
}
|
||||
|
||||
std::vector<std::string> ScanSettings::getDefaultTagDelimiters() const
|
||||
{
|
||||
return StringUtils::splitEscapedStrings(_defaultTagDelimiters, ';', '\\');
|
||||
}
|
||||
|
||||
void ScanSettings::setExtraTagsToScan(const std::vector<std::string_view>& extraTags)
|
||||
@@ -69,6 +79,26 @@ namespace Database
|
||||
_extraTagsToScan = std::move(newTagsToScan);
|
||||
}
|
||||
|
||||
void ScanSettings::setArtistTagDelimiters(std::span<const std::string_view> delimiters)
|
||||
{
|
||||
std::string tagDelimiters{ StringUtils::escapeAndJoinStrings(delimiters, ';', '\\') };
|
||||
if (tagDelimiters != _artistTagDelimiters)
|
||||
{
|
||||
_artistTagDelimiters.swap(tagDelimiters);
|
||||
incScanVersion();
|
||||
}
|
||||
}
|
||||
|
||||
void ScanSettings::setDefaultTagDelimiters(std::span<const std::string_view> delimiters)
|
||||
{
|
||||
std::string tagDelimiters{ StringUtils::escapeAndJoinStrings(delimiters, ';', '\\') };
|
||||
if (tagDelimiters != _defaultTagDelimiters)
|
||||
{
|
||||
_defaultTagDelimiters.swap(tagDelimiters);
|
||||
incScanVersion();
|
||||
}
|
||||
}
|
||||
|
||||
void ScanSettings::incScanVersion()
|
||||
{
|
||||
_scanVersion += 1;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
@@ -62,18 +63,22 @@ namespace Database
|
||||
static pointer get(Session& session);
|
||||
|
||||
// Getters
|
||||
std::size_t getScanVersion() const { return _scanVersion; }
|
||||
Wt::WTime getUpdateStartTime() const { return _startTime; }
|
||||
UpdatePeriod getUpdatePeriod() const { return _updatePeriod; }
|
||||
std::vector<std::string_view> getExtraTagsToScan() const;
|
||||
std::vector<std::filesystem::path> getAudioFileExtensions() const;
|
||||
SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; }
|
||||
std::size_t getScanVersion() const { return _scanVersion; }
|
||||
Wt::WTime getUpdateStartTime() const { return _startTime; }
|
||||
UpdatePeriod getUpdatePeriod() const { return _updatePeriod; }
|
||||
std::vector<std::string_view> getExtraTagsToScan() const;
|
||||
std::vector<std::filesystem::path> getAudioFileExtensions() const;
|
||||
SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; }
|
||||
std::vector<std::string> getArtistTagDelimiters() const;
|
||||
std::vector<std::string> getDefaultTagDelimiters() const;
|
||||
|
||||
// Setters
|
||||
void setUpdateStartTime(Wt::WTime t) { _startTime = t; }
|
||||
void setUpdatePeriod(UpdatePeriod p) { _updatePeriod = p; }
|
||||
void setExtraTagsToScan(const std::vector<std::string_view>& extraTags);
|
||||
void setSimilarityEngineType(SimilarityEngineType type) { _similarityEngineType = type; }
|
||||
void setArtistTagDelimiters(std::span<const std::string_view> delimiters);
|
||||
void setDefaultTagDelimiters(std::span<const std::string_view> delimiters);
|
||||
void incScanVersion();
|
||||
|
||||
template<class Action>
|
||||
@@ -85,6 +90,8 @@ namespace Database
|
||||
Wt::Dbo::field(a, _audioFileExtensions, "audio_file_extensions");
|
||||
Wt::Dbo::field(a, _similarityEngineType, "similarity_engine_type");
|
||||
Wt::Dbo::field(a, _extraTagsToScan, "extra_tags_to_scan");
|
||||
Wt::Dbo::field(a, _artistTagDelimiters, "artist_tag_delimiters");
|
||||
Wt::Dbo::field(a, _defaultTagDelimiters, "default_tag_delimiters");
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -94,6 +101,7 @@ namespace Database
|
||||
SimilarityEngineType _similarityEngineType{ SimilarityEngineType::Clusters };
|
||||
std::string _audioFileExtensions{ ".alac .mp3 .ogg .oga .aac .m4a .m4b .flac .wav .wma .aif .aiff .ape .mpc .shn .opus .wv" };
|
||||
std::string _extraTagsToScan;
|
||||
std::string _artistTagDelimiters;
|
||||
std::string _defaultTagDelimiters;
|
||||
};
|
||||
} // namespace Database
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ if(BUILD_TESTING)
|
||||
endif()
|
||||
|
||||
add_library(lmsmetadata SHARED
|
||||
impl/AvFormatParser.cpp
|
||||
impl/Factory.cpp
|
||||
impl/TagLibParser.cpp
|
||||
impl/AvFormatTagReader.cpp
|
||||
impl/Parser.cpp
|
||||
impl/TagLibTagReader.cpp
|
||||
impl/Utils.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2013 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "AvFormatParser.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
|
||||
#include "av/IAudioFile.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
#include "utils/String.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
template <typename T>
|
||||
std::optional<T> findFirstValueOfAs(const Av::IAudioFile::MetadataMap& metadataMap, std::initializer_list<std::string> tags)
|
||||
{
|
||||
auto it = std::find_first_of(std::cbegin(metadataMap), std::cend(metadataMap), std::cbegin(tags), std::cend(tags), [](const auto& it, const auto& str) { return it.first == str; });
|
||||
if (it == std::cend(metadataMap))
|
||||
return std::nullopt;
|
||||
|
||||
return StringUtils::readAs<T>(StringUtils::stringTrim(it->second));
|
||||
}
|
||||
|
||||
template <>
|
||||
std::optional<std::vector<UUID>> findFirstValueOfAs(const Av::IAudioFile::MetadataMap& metadataMap, std::initializer_list<std::string> tags)
|
||||
{
|
||||
std::optional<std::string> str{ findFirstValueOfAs<std::string>(metadataMap, tags) };
|
||||
if (!str)
|
||||
return std::nullopt;
|
||||
|
||||
const std::vector<std::string_view> strUuids{ StringUtils::splitString(*str, "/") };
|
||||
std::vector<UUID> res;
|
||||
|
||||
for (std::string_view strUuid : strUuids)
|
||||
{
|
||||
std::optional<UUID> uuid{ UUID::fromString(strUuid) };
|
||||
if (!uuid)
|
||||
return std::nullopt;
|
||||
|
||||
res.push_back(std::move(*uuid));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::vector<Artist> getReleaseArtists(const Av::IAudioFile::MetadataMap& metadataMap)
|
||||
{
|
||||
std::vector<Artist> res;
|
||||
|
||||
auto name{ findFirstValueOfAs<std::string>(metadataMap, {"ALBUM_ARTIST"}) };
|
||||
if (!name)
|
||||
return res;
|
||||
|
||||
auto mbid{ findFirstValueOfAs<UUID>(metadataMap, {"MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID"}) };
|
||||
|
||||
return { Artist {mbid, *name, std::nullopt} };
|
||||
}
|
||||
|
||||
std::vector<Artist> getArtists(const Av::IAudioFile::MetadataMap& metadataMap)
|
||||
{
|
||||
std::vector<Artist> artists;
|
||||
|
||||
std::vector<std::string_view> artistNames;
|
||||
if (metadataMap.find("ARTISTS") != metadataMap.end())
|
||||
{
|
||||
artistNames = StringUtils::splitString(metadataMap.find("ARTISTS")->second, "/;");
|
||||
}
|
||||
else if (metadataMap.find("ARTIST") != metadataMap.end())
|
||||
{
|
||||
artistNames = { metadataMap.find("ARTIST")->second };
|
||||
}
|
||||
|
||||
auto artistMBIDs{ findFirstValueOfAs<std::vector<UUID>>(metadataMap, {"MUSICBRAINZ ARTIST ID", "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ/ARTIST ID"}) };
|
||||
|
||||
for (std::size_t i{}; i < artistNames.size(); ++i)
|
||||
{
|
||||
if (artistMBIDs && artistNames.size() == artistMBIDs->size())
|
||||
artists.emplace_back(Artist{ (*artistMBIDs)[i], artistNames[i], std::nullopt });
|
||||
else
|
||||
artists.emplace_back(Artist{ std::nullopt, artistNames[i], std::nullopt });
|
||||
}
|
||||
|
||||
return artists;
|
||||
}
|
||||
|
||||
std::optional<Release> getRelease(const Av::IAudioFile::MetadataMap& metadataMap)
|
||||
{
|
||||
std::optional<Release> res;
|
||||
|
||||
std::optional<std::string> releaseName{ findFirstValueOfAs<std::string>(metadataMap, {"ALBUM", "TALB", "WM/ALBUMTITLE"}) };
|
||||
if (!releaseName)
|
||||
return res;
|
||||
|
||||
res.emplace();
|
||||
res->name = std::move(*releaseName);
|
||||
res->mbid = findFirstValueOfAs<UUID>(metadataMap, { "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ/ALBUM ID" });
|
||||
res->artists = getReleaseArtists(metadataMap);
|
||||
res->mediumCount = findFirstValueOfAs<std::size_t>(metadataMap, { "TOTALDISCS", "DISCTOTAL" });
|
||||
if (!res->mediumCount)
|
||||
{
|
||||
// mediumCount may be encoded as position/count
|
||||
if (const auto value{ findFirstValueOfAs<std::string>(metadataMap, {"TPOS", "DISC", "DISK", "DISCNUMBER", "WM/PARTOFSET"}) })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, "/") };
|
||||
if (strings.size() == 2)
|
||||
res->mediumCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::optional<Medium> getMedium(const Av::IAudioFile::MetadataMap& metadataMap)
|
||||
{
|
||||
std::optional<Medium> res;
|
||||
res.emplace();
|
||||
|
||||
res->type = findFirstValueOfAs<std::string>(metadataMap, { "TMED", "MEDIA", "WM/MEDIA" }).value_or("");
|
||||
res->name = findFirstValueOfAs<std::string>(metadataMap, { "TSST", "DISCSUBTITLE", "SETSUBTITLE" }).value_or("");
|
||||
res->trackCount = findFirstValueOfAs<std::size_t>(metadataMap, { "TOTALTRACKS", "TRACKTOTAL" });
|
||||
if (!res->trackCount)
|
||||
{
|
||||
// totalTracks may be encoded as "position/count"
|
||||
if (const auto value{ findFirstValueOfAs<std::string>(metadataMap, {"TRCK", "TRACK", "TRACKNUMBER", "TRKN", "WM/TRACKNUMBER"}) })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, "/") };
|
||||
if (strings.size() == 2)
|
||||
res->trackCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// position may be encoded in TPOS/DISC/DISK as "position/count". Expecting 'Number[/Total]'
|
||||
res->position = findFirstValueOfAs<std::size_t>(metadataMap, { "TPOS", "DISC", "DISK", "DISCNUMBER", "WM/PARTOFSET" });
|
||||
res->release = getRelease(metadataMap);
|
||||
|
||||
if (res->type.empty()
|
||||
&& res->name.empty()
|
||||
&& !res->trackCount
|
||||
&& !res->position
|
||||
&& !res->release
|
||||
&& !res->replayGain)
|
||||
{
|
||||
res.reset();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Track> AvFormatParser::parse(const std::filesystem::path& p, bool debug)
|
||||
{
|
||||
Track track;
|
||||
|
||||
try
|
||||
{
|
||||
const auto mediaFile{ Av::parseAudioFile(p) };
|
||||
|
||||
Av::ContainerInfo info{ mediaFile->getContainerInfo() };
|
||||
track.duration = info.duration;
|
||||
track.bitrate = info.bitrate;
|
||||
track.hasCover = mediaFile->hasAttachedPictures();
|
||||
|
||||
MetaData::Tags tags;
|
||||
|
||||
const Av::IAudioFile::MetadataMap metadataMap{ mediaFile->getMetaData() };
|
||||
|
||||
track.artists = getArtists(metadataMap);
|
||||
track.medium = getMedium(metadataMap);
|
||||
|
||||
for (const auto& [tag, value] : metadataMap)
|
||||
{
|
||||
if (debug)
|
||||
std::cout << "TAG = " << tag << ", VAL = " << value << std::endl;
|
||||
|
||||
if (tag == "TITLE")
|
||||
track.title = value;
|
||||
else if (tag == "TRACK")
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
track.position = StringUtils::readAs<std::size_t>(value);
|
||||
}
|
||||
else if (tag == "DATE"
|
||||
|| tag == "YEAR"
|
||||
|| tag == "WM/YEAR")
|
||||
{
|
||||
track.date = Utils::parseDate(value);
|
||||
}
|
||||
else if (tag == "TDOR" // Original release time (ID3v2 2.4)
|
||||
|| tag == "TORY") // Original release year
|
||||
{
|
||||
track.originalDate = Utils::parseDate(value);
|
||||
}
|
||||
else if (tag == "ACOUSTID ID")
|
||||
{
|
||||
track.acoustID = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "MUSICBRAINZ RELEASE TRACK ID"
|
||||
|| tag == "MUSICBRAINZ_RELEASETRACKID")
|
||||
{
|
||||
track.mbid = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "MUSICBRAINZ_TRACKID"
|
||||
|| tag == "MUSICBRAINZ/TRACK ID")
|
||||
{
|
||||
track.recordingMBID = UUID::fromString(value);
|
||||
}
|
||||
else if (std::find(std::cbegin(_userExtraTags), std::cend(_userExtraTags), tag) != std::cend(_userExtraTags))
|
||||
{
|
||||
const std::vector<std::string_view> tagValues{ StringUtils::splitString(value, "/,;") };
|
||||
|
||||
if (!tagValues.empty())
|
||||
{
|
||||
std::vector<std::string> values;
|
||||
std::transform(std::cbegin(tagValues), std::cend(tagValues), std::inserter(values, std::begin(values)), [](std::string_view v) { return std::string{ v }; });
|
||||
track.userExtraTags[tag] = std::move(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Av::Exception& e)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
} // namespace MetaData
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright (C) 2013 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "AvFormatTagReader.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
|
||||
#include "av/IAudioFile.hpp"
|
||||
#include "metadata/Exception.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
#include "utils/String.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
|
||||
// Mapping to internal taglib names and/or common alternative custom names
|
||||
static const std::unordered_map<TagType, std::vector<std::string>> tagMapping
|
||||
{
|
||||
{ TagType::AcoustID, { "ACOUSTID_ID", "ACOUSTID ID" } },
|
||||
{ TagType::Album, { "ALBUM", "TALB", "WM/ALBUMTITLE" } },
|
||||
{ TagType::AlbumArtist, { "ALBUMARTIST", "ALBUM_ARTIST" } },
|
||||
{ TagType::AlbumArtistSortOrder, { "ALBUMARTISTSORT", "TSO2" } },
|
||||
{ TagType::AlbumArtists, { "ALBUMARTISTS" } },
|
||||
{ TagType::AlbumArtistsSortOrder, { "ALBUMARTISTSSORT" } },
|
||||
{ TagType::AlbumSortOrder, { "ALBUMSORT" } },
|
||||
{ TagType::Arranger, { "ARRANGER" } },
|
||||
{ TagType::Artist, { "ARTIST" } },
|
||||
{ TagType::ArtistSortOrder, { "ARTISTSORT", "ARTIST-SORT", "WM/ARTISTSORTORDER" } },
|
||||
{ TagType::Artists, { "ARTISTS", "WM/ARTISTS" } },
|
||||
{ TagType::ASIN, { "ASIN" } },
|
||||
{ TagType::Barcode, { "BARCODE", "WM/BARCODE" } },
|
||||
{ TagType::BPM, { "BPM" } },
|
||||
{ TagType::CatalogNumber, { "CATALOGNUMBER", "WM/CATALOGNO" } },
|
||||
{ TagType::Comment, { "COMMENT" } },
|
||||
{ TagType::Compilation, { "COMPILATION", "TCMP" } },
|
||||
{ TagType::Composer, { "COMPOSER" } },
|
||||
{ TagType::Composers, { "COMPOSERS" } },
|
||||
{ TagType::ComposerSortOrder, { "COMPOSERSORT", "TSOC" } },
|
||||
{ TagType::ComposersSortOrder, { "COMPOSERSSORT" } },
|
||||
{ TagType::Conductor, { "CONDUCTOR" } },
|
||||
{ TagType::ConductorSortOrder, { "CONDUCTORSORT" } },
|
||||
{ TagType::Conductors, { "CONDUCTORS" } },
|
||||
{ TagType::ConductorsSortOrder, { "CONDUCTORSSORT" } },
|
||||
{ TagType::Copyright, { "COPYRIGHT" } },
|
||||
{ TagType::CopyrightURL, { "COPYRIGHTURL" } },
|
||||
{ TagType::Date, { "DATE", "YEAR", "WM/YEAR" } },
|
||||
{ TagType::Director, { "DIRECTOR" } },
|
||||
{ TagType::DiscNumber, { "TPOS", "DISC", "DISK", "DISCNUMBER", "WM/PARTOFSET" } },
|
||||
{ TagType::DiscSubtitle, { "TSST", "DISCSUBTITLE", "SETSUBTITLE" } },
|
||||
{ TagType::EncodedBy, { "ENCODEDBY" } },
|
||||
{ TagType::Engineer, { "ENGINEER" } },
|
||||
{ TagType::GaplessPlayback, { "GAPLESSPLAYBACK" } },
|
||||
{ TagType::Genre, { "GENRE" } },
|
||||
{ TagType::Grouping, { "GROUPING", "WM/CONTENTGROUPDESCRIPTION" } },
|
||||
{ TagType::InitialKey, { "INITIALKEY" } },
|
||||
{ TagType::ISRC, { "ISRC", "WM/ISRC", "TSRC" } },
|
||||
{ TagType::Language, { "LANGUAGE" } },
|
||||
{ TagType::License, { "LICENSE" } },
|
||||
{ TagType::Lyricist, { "LYRICIST" } },
|
||||
{ TagType::LyricistSortOrder, { "LYRICISTSORT" } },
|
||||
{ TagType::Lyricists, { "LYRICISTS" } },
|
||||
{ TagType::LyricistsSortOrder, { "LYRICISTSSORT" } },
|
||||
{ TagType::Lyrics, { "LYRICS" } },
|
||||
{ TagType::Media, { "TMED", "MEDIA", "WM/MEDIA" } },
|
||||
{ TagType::MixDJ, { "DJMIXER" } },
|
||||
{ TagType::Mixer, { "MIXER" } },
|
||||
{ TagType::MixerSortOrder, { "MIXERSORT" } },
|
||||
{ TagType::Mixers, { "MIXERS" } },
|
||||
{ TagType::MixersSortOrder, { "MIXERSSORT" } },
|
||||
{ TagType::Mood, { "MOOD" } },
|
||||
{ TagType::Movement, { "MOVEMENT", "MOVEMENTNAME" } },
|
||||
{ TagType::MovementCount, { "MOVEMENTCOUNT" } },
|
||||
{ TagType::MovementNumber, { "MOVEMENTNUMBER" } },
|
||||
{ TagType::MusicBrainzArtistID, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" } },
|
||||
{ TagType::MusicBrainzDiscID, { "MUSICBRAINZ_DISCID", "MUSICBRAINZ DISC ID", "MUSICBRAINZ/DISC ID" } },
|
||||
{ TagType::MusicBrainzOriginalArtistID, { "MUSICBRAINZ_ORIGINALARTISTID", "MUSICBRAINZ ORIGINAL ARTIST ID", "MUSICBRAINZ/ORIGINAL ARTIST ID" } },
|
||||
{ TagType::MusicBrainzOriginalReleaseID, { "MUSICBRAINZ_ORIGINALRELEASEID", "MUSICBRAINZ ORIGINAL RELEASE ID", "MUSICBRAINZ/ORIGINAL RELEASE ID" } },
|
||||
{ TagType::MusicBrainzRecordingID, { "MUSICBRAINZ_TRACKID", "MUSICBRAINZ TRACK ID", "MUSICBRAINZ/TRACK ID" } },
|
||||
{ TagType::MusicBrainzReleaseArtistID, { "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID" } },
|
||||
{ TagType::MusicBrainzReleaseGroupID, { "MUSICBRAINZ_RELEASEGROUPID", "MUSICBRAINZ RELEASE GROUP ID", "MUSICBRAINZ/RELEASE GROUP ID" } },
|
||||
{ TagType::MusicBrainzReleaseID, { "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ/ALBUM ID" } },
|
||||
{ TagType::MusicBrainzTrackID, { "MUSICBRAINZ_RELEASETRACKID", "MUSICBRAINZ RELEASE TRACK ID", "MUSICBRAINZ/RELEASE TRACK ID" } },
|
||||
{ TagType::MusicBrainzWorkID, { "MUSICBRAINZ_WORKID", "MUSICBRAINZ WORK ID", "MUSICBRAINZ/WORK ID" } },
|
||||
{ TagType::OriginalArtist, { "ORIGINALARTIST" } },
|
||||
{ TagType::OriginalFilename, { "ORIGINALFILENAME" } },
|
||||
{ TagType::OriginalReleaseDate, { "ORIGINALDATE", "TDOR", "WM/ORIGINALRELEASETIME" } },
|
||||
{ TagType::OriginalReleaseYear, { "ORIGINALYEAR", "TORY", "WM/ORIGINALRELEASEYEAR" } },
|
||||
{ TagType::Podcast, { "PODCAST" } },
|
||||
{ TagType::PodcastURL, { "PODCASTURL" } },
|
||||
{ TagType::Producer, { "PRODUCER" } },
|
||||
{ TagType::ProducerSortOrder, { "PRODUCERSORTORDER" } },
|
||||
{ TagType::Producers, { "PRODUCERS" } },
|
||||
{ TagType::ProducersSortOrder, { "PRODUCERSSORTORDER" } },
|
||||
{ TagType::RecordLabel, { "LABEL", "PUBLISHER" } },
|
||||
{ TagType::ReleaseCountry, { "RELEASECOUNTRY" } },
|
||||
{ TagType::ReleaseDate, { "RELEASEDATE" } },
|
||||
{ TagType::ReleaseStatus, { "RELEASESTATUS" } },
|
||||
{ TagType::ReleaseType, { "RELEASETYPE", "MUSICBRAINZ_ALBUMTYPE", "MUSICBRAINZ ALBUM TYPE", "MUSICBRAINZ/ALBUM TYPE" } },
|
||||
{ TagType::Remixer, { "REMIXER", "MODIFIEDBY", "MIXARTIST" } },
|
||||
{ TagType::RemixerSortOrder, { "REMIXERSORTORDER", "MIXARTISTSORTORDER" } },
|
||||
{ TagType::Remixers, { "REMIXERS" } },
|
||||
{ TagType::RemixersSortOrder, { "REMIXERSSORTORDER", "MIXARTISTSSORTORDER" } },
|
||||
{ TagType::ReplayGainAlbumGain, { "REPLAYGAIN_ALBUM_GAIN" } },
|
||||
{ TagType::ReplayGainAlbumPeak, { "REPLAYGAIN_ALBUM_PEAK" } },
|
||||
{ TagType::ReplayGainAlbumRange, { "REPLAYGAIN_ALBUM_RANGE" } },
|
||||
{ TagType::ReplayGainReferenceLoudness, { "REPLAYGAIN_REFERENCE_LOUDNESS" } },
|
||||
{ TagType::ReplayGainTrackGain, { "REPLAYGAIN_TRACK_GAIN" } },
|
||||
{ TagType::ReplayGainTrackPeak, { "REPLAYGAIN_TRACK_PEAK" } },
|
||||
{ TagType::ReplayGainTrackRange, { "REPLAYGAIN_TRACK_RANGE" } },
|
||||
{ TagType::Script, { "SCRIPT", "WM/SCRIPT" } },
|
||||
{ TagType::ShowWorkAndMovement, { "SHOWWORKMOVEMENT", "SHOWMOVEMENT" } },
|
||||
{ TagType::Subtitle, { "SUBTITLE" } },
|
||||
{ TagType::TotalDiscs, { "DISCTOTAL", "TOTALDISCS"} },
|
||||
{ TagType::TotalTracks, { "TRACKTOTAL", "TOTALTRACKS" } },
|
||||
{ TagType::TrackNumber, { "TRCK", "TRACK", "TRACKNUMBER", "TRKN", "WM/TRACKNUMBER" } },
|
||||
{ TagType::TrackTitle, { "TITLE" } },
|
||||
{ TagType::TrackTitleSortOrder, { "TITLESORT" } },
|
||||
{ TagType::WorkTitle, { "WORK" } },
|
||||
{ TagType::Writer, { "WRITER" } },
|
||||
};
|
||||
}
|
||||
|
||||
AvFormatTagReader::AvFormatTagReader(const std::filesystem::path& p, bool debug)
|
||||
{
|
||||
try
|
||||
{
|
||||
const auto audioFile{ Av::parseAudioFile(p) };
|
||||
|
||||
_containerInfo = audioFile->getContainerInfo();
|
||||
_metaDataMap = audioFile->getMetaData();
|
||||
_hasEmbeddedCover = audioFile->hasAttachedPictures();
|
||||
|
||||
if (debug && Service<ILogger>::get()->isSeverityActive(Severity::DEBUG))
|
||||
{
|
||||
for (const auto& [key, value] : _metaDataMap)
|
||||
LMS_LOG(METADATA, DEBUG, "Key = '" << key << "', value = '" << value << "'");
|
||||
}
|
||||
}
|
||||
catch (Av::Exception& e)
|
||||
{
|
||||
throw ParseException{};
|
||||
}
|
||||
}
|
||||
|
||||
void AvFormatTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const
|
||||
{
|
||||
auto itTagNames{ tagMapping.find(tag) };
|
||||
if (itTagNames == std::cend(tagMapping))
|
||||
return;
|
||||
|
||||
for (const std::string& tagName : itTagNames->second)
|
||||
{
|
||||
bool visited{};
|
||||
|
||||
visitTagValues(tagName, [&](std::string_view value)
|
||||
{
|
||||
visited = true;
|
||||
visitor(value);
|
||||
});
|
||||
|
||||
if (visited)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void AvFormatTagReader::visitTagValues(std::string_view key, TagValueVisitor visitor) const
|
||||
{
|
||||
auto itValues{ _metaDataMap.find(std::string{ key }) };
|
||||
if (itValues == std::cend(_metaDataMap))
|
||||
return;
|
||||
|
||||
visitor(itValues->second);
|
||||
}
|
||||
|
||||
void AvFormatTagReader::visitPerformerTags(PerformerVisitor visitor) const
|
||||
{
|
||||
visitTagValues("PERFORMER", [&](std::string_view value)
|
||||
{
|
||||
visitor("", value);
|
||||
});
|
||||
}
|
||||
} // namespace MetaData
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2018 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include "av/IAudioFile.hpp"
|
||||
#include "metadata/IParser.hpp"
|
||||
#include "ITagReader.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
class AvFormatTagReader : public ITagReader
|
||||
{
|
||||
public:
|
||||
AvFormatTagReader(const std::filesystem::path& path, bool debug);
|
||||
|
||||
private:
|
||||
AvFormatTagReader(const AvFormatTagReader&) = delete;
|
||||
AvFormatTagReader& operator=(const AvFormatTagReader&) = delete;
|
||||
|
||||
bool hasMultiValuedTags() const override { return false; /* not supported */}
|
||||
void visitTagValues(TagType tag, TagValueVisitor visitor) const override;
|
||||
void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override;
|
||||
void visitPerformerTags(PerformerVisitor visitor) const override;
|
||||
bool hasEmbeddedCover() const override { return _hasEmbeddedCover; }
|
||||
|
||||
std::chrono::milliseconds getDuration() const override { return _containerInfo.duration; }
|
||||
std::size_t getBitrate() const override { return _containerInfo.bitrate; }
|
||||
std::size_t getBitsPerSample() const override { return 0; }
|
||||
std::size_t getSampleRate() const override { return 0; }
|
||||
|
||||
Av::IAudioFile::MetadataMap _metaDataMap;
|
||||
Av::ContainerInfo _containerInfo;
|
||||
bool _hasEmbeddedCover{};
|
||||
};
|
||||
} // namespace MetaData
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2022 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "metadata/IParser.hpp"
|
||||
|
||||
#include "utils/Exception.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
|
||||
#include "AvFormatParser.hpp"
|
||||
#include "TagLibParser.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
std::unique_ptr<IParser>
|
||||
createParser(ParserType parserType, ParserReadStyle parserReadStyle)
|
||||
{
|
||||
switch (parserType)
|
||||
{
|
||||
case ParserType::TagLib:
|
||||
LMS_LOG(METADATA, INFO, "Creating TagLib parser with read style = " << Utils::readStyleToString(parserReadStyle));
|
||||
return std::make_unique<TagLibParser>(parserReadStyle);
|
||||
case ParserType::AvFormat:
|
||||
LMS_LOG(METADATA, INFO, "Creating AvFormat parser");
|
||||
return std::make_unique<AvFormatParser>();
|
||||
}
|
||||
|
||||
throw LmsException {"Unhandled parser type"};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2018 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
// using picard internal names
|
||||
// see https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html
|
||||
enum class TagType
|
||||
{
|
||||
AcoustID,
|
||||
AcoustIDFingerprint,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AlbumArtists, // non standard
|
||||
AlbumArtistSortOrder,
|
||||
AlbumArtistsSortOrder, // non standard
|
||||
AlbumSortOrder,
|
||||
Arranger,
|
||||
Artist,
|
||||
ArtistSortOrder,
|
||||
Artists,
|
||||
ASIN,
|
||||
Barcode,
|
||||
BPM,
|
||||
CatalogNumber,
|
||||
Comment,
|
||||
Compilation,
|
||||
Composer,
|
||||
ComposerSortOrder,
|
||||
Composers, // non standard
|
||||
ComposersSortOrder, // non standard
|
||||
Conductor,
|
||||
ConductorSortOrder, // non standard
|
||||
Conductors, // non standard
|
||||
ConductorsSortOrder, // non standard
|
||||
Copyright,
|
||||
CopyrightURL, // non standard
|
||||
Date,
|
||||
Director,
|
||||
DiscNumber,
|
||||
DiscSubtitle,
|
||||
EncodedBy,
|
||||
EncoderSettings,
|
||||
Engineer,
|
||||
GaplessPlayback,
|
||||
Genre,
|
||||
Grouping,
|
||||
InitialKey,
|
||||
ISRC,
|
||||
Language,
|
||||
License,
|
||||
Lyricist,
|
||||
LyricistSortOrder, // non standard
|
||||
Lyricists, // non standard
|
||||
LyricistsSortOrder, // non standard
|
||||
Lyrics,
|
||||
Media,
|
||||
MixDJ,
|
||||
Mixer,
|
||||
MixerSortOrder, // non standard
|
||||
Mixers, // non standard
|
||||
MixersSortOrder, // non standard
|
||||
Mood,
|
||||
Movement,
|
||||
MovementCount,
|
||||
MovementNumber,
|
||||
MusicBrainzArtistID,
|
||||
MusicBrainzDiscID,
|
||||
MusicBrainzOriginalArtistID,
|
||||
MusicBrainzOriginalReleaseID,
|
||||
MusicBrainzRecordingID,
|
||||
MusicBrainzReleaseArtistID,
|
||||
MusicBrainzReleaseGroupID,
|
||||
MusicBrainzReleaseID,
|
||||
MusicBrainzTrackID,
|
||||
MusicBrainzWorkID,
|
||||
MusicIPFingerprint,
|
||||
MusicIPPUID,
|
||||
OriginalAlbum,
|
||||
OriginalArtist,
|
||||
OriginalFilename,
|
||||
OriginalReleaseDate,
|
||||
OriginalReleaseYear,
|
||||
Podcast,
|
||||
PodcastURL,
|
||||
Producer,
|
||||
ProducerSortOrder, // non standard
|
||||
Producers, // non standard
|
||||
ProducersSortOrder, // non standard
|
||||
Rating,
|
||||
RecordLabel,
|
||||
ReleaseCountry,
|
||||
ReleaseDate,
|
||||
ReleaseStatus,
|
||||
ReleaseType,
|
||||
Remixer,
|
||||
RemixerSortOrder,
|
||||
Remixers,
|
||||
RemixersSortOrder,
|
||||
ReplayGainAlbumGain,
|
||||
ReplayGainAlbumPeak,
|
||||
ReplayGainAlbumRange,
|
||||
ReplayGainReferenceLoudness,
|
||||
ReplayGainTrackGain,
|
||||
ReplayGainTrackPeak,
|
||||
ReplayGainTrackRange,
|
||||
Script,
|
||||
ShowName,
|
||||
ShowNameSortOrder,
|
||||
ShowWorkAndMovement,
|
||||
Subtitle,
|
||||
TotalDiscs,
|
||||
TotalTracks,
|
||||
TrackNumber,
|
||||
TrackTitle,
|
||||
TrackTitleSortOrder,
|
||||
Website,
|
||||
WorkTitle,
|
||||
Writer,
|
||||
};
|
||||
|
||||
class ITagReader
|
||||
{
|
||||
public:
|
||||
virtual ~ITagReader() = default;
|
||||
|
||||
virtual bool hasMultiValuedTags() const = 0;
|
||||
|
||||
using TagValueVisitor = std::function<void(std::string_view value)>;
|
||||
virtual void visitTagValues(TagType tag, TagValueVisitor visitor) const = 0;
|
||||
virtual void visitTagValues(std::string_view tag, TagValueVisitor visitor) const = 0;
|
||||
|
||||
using PerformerVisitor = std::function<void(std::string_view role, std::string_view artist)>;
|
||||
virtual void visitPerformerTags(PerformerVisitor visitor) const = 0;
|
||||
|
||||
virtual bool hasEmbeddedCover() const = 0;
|
||||
|
||||
virtual std::chrono::milliseconds getDuration() const = 0;
|
||||
virtual std::size_t getBitrate() const = 0;
|
||||
virtual std::size_t getBitsPerSample() const = 0;
|
||||
virtual std::size_t getSampleRate() const = 0;
|
||||
};
|
||||
} // namespace MetaData
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "Parser.hpp"
|
||||
|
||||
#include <span>
|
||||
|
||||
#include "metadata/Exception.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
#include "utils/String.hpp"
|
||||
|
||||
#include "AvFormatTagReader.hpp"
|
||||
#include "TagLibTagReader.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
void visitTagValues(const ITagReader& tagReader, std::string_view tagType, std::span<const std::string> tagDelimiters, ITagReader::TagValueVisitor visitor)
|
||||
{
|
||||
tagReader.visitTagValues(tagType, [&](std::string_view value)
|
||||
{
|
||||
auto visitTagIfNonEmpty{ [&](std::string_view tag)
|
||||
{
|
||||
tag = StringUtils::stringTrim(tag);
|
||||
if (!tag.empty())
|
||||
visitor(tag);
|
||||
} };
|
||||
|
||||
if (!tagReader.hasMultiValuedTags())
|
||||
{
|
||||
for (std::string_view tagDelimiter : tagDelimiters)
|
||||
{
|
||||
if (value.find(tagDelimiter) != std::string_view::npos)
|
||||
{
|
||||
for (std::string_view splitTag : StringUtils::splitString(value, tagDelimiter))
|
||||
visitTagIfNonEmpty(splitTag);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visitTagIfNonEmpty(value);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
std::vector<T> getTagValuesFirstMatchAs(const ITagReader& tagReader, std::initializer_list<TagType> tagTypes, std::span<const std::string> tagDelimiters)
|
||||
{
|
||||
std::vector<T> res;
|
||||
|
||||
for (const TagType tagType : tagTypes)
|
||||
{
|
||||
auto addTagIfNonEmpty{ [&res](std::string_view tag)
|
||||
{
|
||||
tag = StringUtils::stringTrim(tag);
|
||||
if (!tag.empty())
|
||||
{
|
||||
std::optional<T> val{ StringUtils::readAs<T>(tag) };
|
||||
if (val)
|
||||
res.emplace_back(std::move(*val));
|
||||
}
|
||||
} };
|
||||
|
||||
tagReader.visitTagValues(tagType, [&](std::string_view value)
|
||||
{
|
||||
if (!tagReader.hasMultiValuedTags())
|
||||
{
|
||||
for (std::string_view tagDelimiter : tagDelimiters)
|
||||
{
|
||||
if (value.find(tagDelimiter) != std::string_view::npos)
|
||||
{
|
||||
for (std::string_view splitTag : StringUtils::splitString(value, tagDelimiter))
|
||||
addTagIfNonEmpty(splitTag);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// no delimiter found, or no delimiter to be used
|
||||
addTagIfNonEmpty(value);
|
||||
});
|
||||
|
||||
if (!res.empty())
|
||||
break;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::optional<T> getTagValueFirstMatchAs(const ITagReader& tagReader, std::initializer_list<TagType> tagTypes)
|
||||
{
|
||||
std::optional<T> res;
|
||||
std::vector<T> values{ getTagValuesFirstMatchAs<T>(tagReader, tagTypes, {} /* don't expect multiple values here */) };
|
||||
if (!values.empty())
|
||||
res = std::move(values.front());
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::vector<T> getTagValuesAs(const ITagReader& tagReader, TagType tagType, std::span<const std::string> tagDelimiters)
|
||||
{
|
||||
return getTagValuesFirstMatchAs<T>(tagReader, { tagType }, tagDelimiters);
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::optional<T> getTagValueAs(const ITagReader& tagReader, TagType tagType)
|
||||
{
|
||||
return getTagValueFirstMatchAs<T>(tagReader, { tagType });
|
||||
}
|
||||
|
||||
std::vector<Artist> getArtists(const ITagReader& tagReader,
|
||||
std::initializer_list<TagType> artistTagNames,
|
||||
std::initializer_list<TagType> artistSortTagNames,
|
||||
std::initializer_list<TagType> artistMBIDTagNames,
|
||||
std::span<const std::string> artistTagDelimiters
|
||||
)
|
||||
{
|
||||
std::vector<std::string> artistNames{ getTagValuesFirstMatchAs<std::string>(tagReader, artistTagNames, artistTagDelimiters) };
|
||||
if (artistNames.empty())
|
||||
return {};
|
||||
|
||||
std::vector<std::string> artistSortNames{ getTagValuesFirstMatchAs<std::string>(tagReader, artistSortTagNames, artistTagDelimiters) };
|
||||
std::vector<UUID> artistMBIDs{ getTagValuesFirstMatchAs<UUID>(tagReader, artistMBIDTagNames, artistTagDelimiters) };
|
||||
|
||||
std::vector<Artist> 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 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) };
|
||||
StringUtils::capitalize(performer.role);
|
||||
performers[performer.role].push_back(std::move(performer.artist));
|
||||
}
|
||||
else
|
||||
{
|
||||
// "PERFORMER:role", "artist" (MP3)
|
||||
std::string roleCapitalized{ StringUtils::stringToLower(role) };
|
||||
StringUtils::capitalize(roleCapitalized);
|
||||
performers[roleCapitalized].push_back(Artist{ name });
|
||||
}
|
||||
});
|
||||
|
||||
return performers;
|
||||
}
|
||||
}
|
||||
|
||||
std::unique_ptr<IParser> createParser(ParserBackend parserBackend, ParserReadStyle parserReadStyle)
|
||||
{
|
||||
return std::make_unique<Parser>(parserBackend, parserReadStyle);
|
||||
}
|
||||
|
||||
Parser::Parser(ParserBackend parserBackend, ParserReadStyle readStyle)
|
||||
: _parserBackend{ parserBackend }
|
||||
, _readStyle{ readStyle }
|
||||
{
|
||||
switch (_parserBackend)
|
||||
{
|
||||
case ParserBackend::TagLib:
|
||||
LMS_LOG(METADATA, INFO, "Using TagLib parser with read style = " << Utils::readStyleToString(readStyle));
|
||||
break;
|
||||
|
||||
case ParserBackend::AvFormat:
|
||||
LMS_LOG(METADATA, INFO, "Using AvFormat parser");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
std::unique_ptr<Track> Parser::parse(const std::filesystem::path& p, bool debug)
|
||||
{
|
||||
try
|
||||
{
|
||||
std::unique_ptr<ITagReader> tagReader;
|
||||
switch (_parserBackend)
|
||||
{
|
||||
case ParserBackend::TagLib:
|
||||
tagReader = std::make_unique<TagLibTagReader>(p, _readStyle, debug);
|
||||
break;
|
||||
|
||||
case ParserBackend::AvFormat:
|
||||
tagReader = std::make_unique<AvFormatTagReader>(p, debug);
|
||||
break;
|
||||
}
|
||||
if (!tagReader)
|
||||
throw ParseException{ "Unhandled parser backend" };
|
||||
|
||||
return parse(*tagReader);
|
||||
}
|
||||
catch (const Exception& e)
|
||||
{
|
||||
LMS_LOG(METADATA, ERROR, "File '" << p.string() << "': parsing failed");
|
||||
throw ParseException{};
|
||||
}
|
||||
}
|
||||
|
||||
std::unique_ptr<Track> Parser::parse(const ITagReader& tagReader)
|
||||
{
|
||||
auto track{ std::make_unique<Track>() };
|
||||
|
||||
processAudioProperties(tagReader, *track);
|
||||
processTags(tagReader, *track);
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
void Parser::processAudioProperties(const ITagReader& tagReader, Track& track)
|
||||
{
|
||||
track.duration = tagReader.getDuration();
|
||||
track.bitrate = tagReader.getBitrate();
|
||||
}
|
||||
|
||||
void Parser::processTags(const ITagReader& tagReader, Track& track)
|
||||
{
|
||||
track.hasCover = tagReader.hasEmbeddedCover();
|
||||
|
||||
track.title = getTagValueAs<std::string>(tagReader, TagType::TrackTitle).value_or("");
|
||||
track.mbid = getTagValueAs<UUID>(tagReader, TagType::MusicBrainzTrackID);
|
||||
track.recordingMBID = getTagValueAs<UUID>(tagReader, TagType::MusicBrainzRecordingID);
|
||||
track.acoustID = getTagValueAs<UUID>(tagReader, TagType::AcoustID);
|
||||
track.position = getTagValueAs<std::size_t>(tagReader, TagType::TrackNumber); // May parse 'Number/Total', that's fine
|
||||
if (auto dateStr = getTagValueAs<std::string>(tagReader, TagType::Date))
|
||||
{
|
||||
if (const Wt::WDate date{ Utils::parseDate(*dateStr) }; date.isValid())
|
||||
{
|
||||
track.date = date;
|
||||
track.year = date.year();
|
||||
}
|
||||
else
|
||||
{
|
||||
track.year = Utils::parseYear(*dateStr);
|
||||
}
|
||||
}
|
||||
if (auto dateStr = getTagValueAs<std::string>(tagReader, TagType::OriginalReleaseDate))
|
||||
{
|
||||
if (const Wt::WDate date{ Utils::parseDate(*dateStr) }; date.isValid())
|
||||
{
|
||||
track.originalDate = date;
|
||||
track.originalYear = date.year();
|
||||
}
|
||||
else
|
||||
{
|
||||
track.originalYear = Utils::parseYear(*dateStr);
|
||||
}
|
||||
}
|
||||
if (auto dateStr = getTagValueAs<std::string>(tagReader, TagType::OriginalReleaseYear))
|
||||
{
|
||||
track.originalYear = Utils::parseYear(*dateStr);
|
||||
}
|
||||
|
||||
track.copyright = getTagValueAs<std::string>(tagReader, TagType::Copyright).value_or("");
|
||||
track.copyrightURL = getTagValueAs<std::string>(tagReader, TagType::CopyrightURL).value_or("");
|
||||
track.replayGain = getTagValueAs<float>(tagReader, TagType::ReplayGainTrackGain);
|
||||
track.artistDisplayName = getTagValueAs<std::string>(tagReader, TagType::Artist).value_or(""); // TODO join on artists if present
|
||||
|
||||
for (const std::string& userExtraTag : _userExtraTags)
|
||||
{
|
||||
visitTagValues(tagReader, userExtraTag, _defaultTagDelimiters, [&](std::string_view value)
|
||||
{
|
||||
value = StringUtils::stringTrim(value);
|
||||
if (!value.empty())
|
||||
track.userExtraTags[userExtraTag].push_back(std::string{ value });
|
||||
});
|
||||
}
|
||||
|
||||
track.genres = getTagValuesAs<std::string>(tagReader, TagType::Genre, _defaultTagDelimiters);
|
||||
track.moods = getTagValuesAs<std::string>(tagReader, TagType::Mood, _defaultTagDelimiters);
|
||||
track.groupings = getTagValuesAs<std::string>(tagReader, TagType::Grouping, _defaultTagDelimiters);
|
||||
track.labels = getTagValuesAs<std::string>(tagReader, TagType::RecordLabel, _defaultTagDelimiters);
|
||||
track.languages = getTagValuesAs<std::string>(tagReader, TagType::Language, _defaultTagDelimiters);
|
||||
|
||||
std::vector<std::string_view> artistDelimiters{};
|
||||
|
||||
track.medium = getMedium(tagReader);
|
||||
track.artists = getArtists(tagReader, { TagType::Artists, TagType::Artist }, { TagType::ArtistSortOrder }, { TagType::MusicBrainzArtistID }, _artistTagDelimiters);
|
||||
track.conductorArtists = getArtists(tagReader, { TagType::Conductors, TagType::Conductor }, { TagType::ConductorsSortOrder, TagType::ConductorSortOrder }, {}, _artistTagDelimiters);
|
||||
track.composerArtists = getArtists(tagReader, { TagType::Composers, TagType::Composer }, { TagType::ComposersSortOrder, TagType::ComposerSortOrder }, {}, _artistTagDelimiters);
|
||||
track.lyricistArtists = getArtists(tagReader, { TagType::Lyricists, TagType::Lyricist }, { TagType::LyricistsSortOrder, TagType::LyricistSortOrder }, {}, _artistTagDelimiters);
|
||||
track.mixerArtists = getArtists(tagReader, { TagType::Mixers, TagType::Mixer }, { TagType::MixersSortOrder, TagType::MixerSortOrder }, {}, _artistTagDelimiters);
|
||||
track.producerArtists = getArtists(tagReader, { TagType::Producers, TagType::Producer }, { TagType::ProducersSortOrder, TagType::ProducerSortOrder }, {}, _artistTagDelimiters);
|
||||
track.remixerArtists = getArtists(tagReader, { TagType::Remixers, TagType::Remixer }, { TagType::RemixersSortOrder, TagType::RemixerSortOrder }, {}, _artistTagDelimiters);
|
||||
track.performerArtists = getPerformerArtists(tagReader); // artistDelimiters not supported
|
||||
|
||||
// If a file has date but no year, set it
|
||||
if (!track.year && track.date.isValid())
|
||||
track.year = track.date.year();
|
||||
|
||||
// If a file has originalDate but no originalYear, set it
|
||||
if (!track.originalYear && track.originalDate.isValid())
|
||||
track.originalYear = track.originalDate.year();
|
||||
}
|
||||
|
||||
|
||||
std::optional<Medium> Parser::getMedium(const ITagReader& tagReader)
|
||||
{
|
||||
std::optional<Medium> medium;
|
||||
medium.emplace();
|
||||
|
||||
medium->media = getTagValueAs<std::string>(tagReader, TagType::Media).value_or("");
|
||||
medium->name = getTagValueAs<std::string>(tagReader, TagType::DiscSubtitle).value_or("");
|
||||
medium->trackCount = getTagValueAs<std::size_t>(tagReader, TagType::TotalTracks);
|
||||
if (!medium->trackCount)
|
||||
{
|
||||
// totalTracks may be encoded as "position/count"
|
||||
if (const auto value{ getTagValueAs<std::string>(tagReader, TagType::TrackNumber) })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, '/') };
|
||||
if (strings.size() == 2)
|
||||
medium->trackCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
// Expecting 'Number[/Total]'
|
||||
medium->position = getTagValueAs<std::size_t>(tagReader, TagType::DiscNumber);
|
||||
medium->release = getRelease(tagReader);
|
||||
medium->replayGain = getTagValueAs<float>(tagReader, TagType::ReplayGainAlbumGain);
|
||||
|
||||
if (medium->isDefault())
|
||||
medium.reset();
|
||||
|
||||
return medium;
|
||||
}
|
||||
|
||||
std::optional<Release> Parser::getRelease(const ITagReader& tagReader)
|
||||
{
|
||||
std::optional<Release> release;
|
||||
|
||||
auto releaseName{ getTagValueAs<std::string>(tagReader, TagType::Album) };
|
||||
if (!releaseName)
|
||||
return release;
|
||||
|
||||
release.emplace();
|
||||
release->name = std::move(*releaseName);
|
||||
release->artistDisplayName = getTagValueAs<std::string>(tagReader, TagType::AlbumArtist).value_or(""); // TODO try to join albumartists if present
|
||||
release->mbid = getTagValueAs<UUID>(tagReader, TagType::MusicBrainzReleaseID);
|
||||
release->artists = getArtists(tagReader, { TagType::AlbumArtists, TagType::AlbumArtist }, { TagType::AlbumArtistsSortOrder, TagType::AlbumArtistSortOrder }, { TagType::MusicBrainzReleaseArtistID }, _artistTagDelimiters);
|
||||
release->mediumCount = getTagValueAs<std::size_t>(tagReader, TagType::TotalDiscs);
|
||||
if (!release->mediumCount)
|
||||
{
|
||||
// mediumCount may be encoded as "position/count"
|
||||
if (const auto value{ getTagValueAs<std::string>(tagReader, TagType::DiscNumber) })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, '/') };
|
||||
if (strings.size() == 2)
|
||||
release->mediumCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
|
||||
release->releaseTypes = getTagValuesAs<std::string>(tagReader, TagType::ReleaseType, _defaultTagDelimiters);
|
||||
|
||||
return release;
|
||||
}
|
||||
} // namespace MetaData
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (C) 2018 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "metadata/IParser.hpp"
|
||||
#include "ITagReader.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
class Parser : public IParser
|
||||
{
|
||||
public:
|
||||
Parser(ParserBackend parserBackend = ParserBackend::TagLib, ParserReadStyle readStyle = ParserReadStyle::Average);
|
||||
|
||||
std::unique_ptr<Track> parse(const std::filesystem::path& p, bool debug = false) override;
|
||||
std::unique_ptr<Track> parse(const ITagReader& reader);
|
||||
|
||||
private:
|
||||
void setUserExtraTags(std::span<const std::string> extraTags) override { _userExtraTags.assign(std::cbegin(extraTags), std::cend(extraTags)); }
|
||||
void setArtistTagDelimiters(std::span<const std::string> delimiters) override { _artistTagDelimiters.assign(std::cbegin(delimiters), std::cend(delimiters)); }
|
||||
void setDefaultTagDelimiters(std::span<const std::string> delimiters) override { _defaultTagDelimiters.assign(std::cbegin(delimiters), std::cend(delimiters)); }
|
||||
|
||||
void processAudioProperties(const ITagReader& reader, Track& track);
|
||||
void processTags(const ITagReader& reader, Track& track);
|
||||
|
||||
std::optional<Medium> getMedium(const ITagReader& tagReader);
|
||||
std::optional<Release> getRelease(const ITagReader& tagReader);
|
||||
|
||||
const ParserBackend _parserBackend;
|
||||
const ParserReadStyle _readStyle;
|
||||
|
||||
std::vector<std::string> _userExtraTags;
|
||||
std::vector<std::string> _artistTagDelimiters;
|
||||
std::vector<std::string> _defaultTagDelimiters;
|
||||
};
|
||||
} // namespace MetaData
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "TagLibParser.hpp"
|
||||
|
||||
#include <map>
|
||||
|
||||
#include <taglib/apetag.h>
|
||||
#include <taglib/asffile.h>
|
||||
#include <taglib/id3v2tag.h>
|
||||
#include <taglib/fileref.h>
|
||||
#include <taglib/flacfile.h>
|
||||
#include <taglib/mp4file.h>
|
||||
#include <taglib/mpcfile.h>
|
||||
#include <taglib/mpegfile.h>
|
||||
#include <taglib/opusfile.h>
|
||||
#include <taglib/tag.h>
|
||||
#include <taglib/tpropertymap.h>
|
||||
#include <taglib/vorbisfile.h>
|
||||
#include <taglib/wavpackfile.h>
|
||||
|
||||
#include "utils/IConfig.hpp"
|
||||
#include "utils/Exception.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
#include "utils/Service.hpp"
|
||||
#include "utils/String.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// TODO use string_views here for values
|
||||
using TagMap = std::map<std::string, std::vector<std::string>>;
|
||||
|
||||
template<typename T>
|
||||
std::vector<T> getPropertyValuesFirstMatchAs(const TagMap& tags, std::initializer_list<std::string_view> keys)
|
||||
{
|
||||
std::vector<T> res;
|
||||
|
||||
for (std::string_view key : keys)
|
||||
{
|
||||
const auto itValues{ tags.find(std::string {key}) };
|
||||
if (itValues == std::cend(tags))
|
||||
continue;
|
||||
|
||||
const std::vector<std::string>& values{ itValues->second };
|
||||
if (values.empty())
|
||||
continue;
|
||||
|
||||
res.reserve(values.size());
|
||||
|
||||
for (const auto& value : values)
|
||||
{
|
||||
std::optional<T> val{ StringUtils::readAs<T>(value) };
|
||||
if (!val)
|
||||
continue;
|
||||
|
||||
res.emplace_back(std::move(*val));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::optional<T> getPropertyValueFirstMatchAs(const TagMap& tags, std::initializer_list<std::string_view> keys)
|
||||
{
|
||||
std::optional<T> res;
|
||||
std::vector<T> values{ getPropertyValuesFirstMatchAs<T>(tags, keys) };
|
||||
if (!values.empty())
|
||||
res = std::move(values.front());
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::vector<T> getPropertyValuesAs(const TagMap& tags, std::string_view key)
|
||||
{
|
||||
return getPropertyValuesFirstMatchAs<T>(tags, { key });
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::optional<T> getPropertyValueAs(const TagMap& tags, std::string_view key)
|
||||
{
|
||||
return getPropertyValueFirstMatchAs<T>(tags, { key });
|
||||
}
|
||||
|
||||
std::vector<std::string_view> splitAndTrimString(std::string_view str, std::string_view delimiters)
|
||||
{
|
||||
std::vector<std::string_view> strings{ StringUtils::splitString(str, delimiters) };
|
||||
for (std::string_view& s : strings)
|
||||
s = StringUtils::stringTrim(s);
|
||||
|
||||
return strings;
|
||||
}
|
||||
|
||||
std::vector<Artist> getArtists(const TagMap& tags,
|
||||
std::initializer_list<std::string_view> artistTagNames,
|
||||
std::initializer_list<std::string_view> artistSortTagNames,
|
||||
std::initializer_list<std::string_view> artistMBIDTagNames
|
||||
)
|
||||
{
|
||||
const std::vector<std::string_view> artistNames{ getPropertyValuesFirstMatchAs<std::string_view>(tags, artistTagNames) };
|
||||
if (artistNames.empty())
|
||||
return {};
|
||||
|
||||
std::vector<Artist> artists;
|
||||
artists.reserve(artistNames.size());
|
||||
std::transform(std::cbegin(artistNames), std::cend(artistNames), std::back_inserter(artists),
|
||||
[&](std::string_view name) { return Artist{ name }; });
|
||||
|
||||
{
|
||||
const std::vector<std::string_view> artistSortNames{ getPropertyValuesFirstMatchAs<std::string_view>(tags, artistSortTagNames) };
|
||||
if (artistSortNames.size() == artists.size())
|
||||
{
|
||||
for (std::size_t i{}; i < artistSortNames.size(); ++i)
|
||||
artists[i].sortName = artistSortNames[i];
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const std::vector<UUID> artistsMBID{ getPropertyValuesFirstMatchAs<UUID>(tags, artistMBIDTagNames) };
|
||||
|
||||
if (artistNames.size() == artistsMBID.size())
|
||||
{
|
||||
for (std::size_t i{}; i < artistsMBID.size(); ++i)
|
||||
artists[i].mbid = artistsMBID[i];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return artists;
|
||||
}
|
||||
|
||||
PerformerContainer getPerformerArtists(const TagMap& tags, std::initializer_list<std::string_view> artistTagNames)
|
||||
{
|
||||
PerformerContainer performers;
|
||||
|
||||
// picard stores like this: (see https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#performer)
|
||||
// We may hit both styles for the same track
|
||||
// PERFORMER: artist (role)
|
||||
if (const std::vector<std::string_view> artistNames{ getPropertyValuesFirstMatchAs<std::string_view>(tags, artistTagNames) }; !artistNames.empty())
|
||||
{
|
||||
for (std::string_view entry : artistNames)
|
||||
{
|
||||
Utils::PerformerArtist performer{ Utils::extractPerformerAndRole(entry) };
|
||||
StringUtils::capitalize(performer.role);
|
||||
performers[performer.role].push_back(std::move(performer.artist));
|
||||
}
|
||||
}
|
||||
// PERFORMER:role (MP3)
|
||||
for (const auto& [key, values] : tags)
|
||||
{
|
||||
if (key.find("PERFORMER:") == 0)
|
||||
{
|
||||
std::string performerStr{ key };
|
||||
std::string role;
|
||||
if (const std::size_t rolePos{ performerStr.find(':') }; rolePos != std::string::npos)
|
||||
{
|
||||
role = StringUtils::stringToLower(performerStr.substr(rolePos + 1, performerStr.size() - rolePos + 1));
|
||||
StringUtils::capitalize(role);
|
||||
}
|
||||
|
||||
for (const auto& value : values)
|
||||
performers[role].push_back(Artist{ value });
|
||||
}
|
||||
}
|
||||
|
||||
return performers;
|
||||
}
|
||||
|
||||
std::optional<Release> getRelease(const TagMap& tags)
|
||||
{
|
||||
std::optional<Release> release;
|
||||
|
||||
auto releaseName{ getPropertyValueAs<std::string>(tags, "ALBUM") };
|
||||
if (!releaseName)
|
||||
return release;
|
||||
|
||||
release.emplace();
|
||||
release->name = std::move(*releaseName);
|
||||
release->artistDisplayName = getPropertyValueAs<std::string_view>(tags, "ALBUMARTIST").value_or("");
|
||||
release->mbid = getPropertyValueFirstMatchAs<UUID>(tags, { "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ/ALBUM ID" });
|
||||
release->artists = getArtists(tags, { "ALBUMARTISTS", "ALBUMARTIST" }, { "ALBUMARTISTSSORT", "ALBUMARTISTSORT" }, { "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID" });
|
||||
release->mediumCount = getPropertyValueAs<std::size_t>(tags, "DISCTOTAL");
|
||||
if (!release->mediumCount)
|
||||
{
|
||||
// mediumCount may be encoded as "position/count"
|
||||
if (const auto value{ getPropertyValueAs<std::string_view>(tags, "DISCNUMBER") })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, "/") };
|
||||
if (strings.size() == 2)
|
||||
release->mediumCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
|
||||
release->releaseTypes = getPropertyValuesFirstMatchAs<std::string>(tags, { "MUSICBRAINZ_ALBUMTYPE", "RELEASETYPE", "MUSICBRAINZ ALBUM TYPE", "MUSICBRAINZ/ALBUM TYPE" });
|
||||
|
||||
return release;
|
||||
}
|
||||
|
||||
std::optional<Medium> getMedium(const TagMap& tags)
|
||||
{
|
||||
std::optional<Medium> medium;
|
||||
medium.emplace();
|
||||
|
||||
medium->type = getPropertyValueAs<std::string>(tags, "MEDIA").value_or("");
|
||||
medium->name = getPropertyValueFirstMatchAs<std::string>(tags, { "DISCSUBTITLE", "SETSUBTITLE" }).value_or("");
|
||||
medium->trackCount = getPropertyValueAs<std::size_t>(tags, "TRACKTOTAL");
|
||||
if (!medium->trackCount)
|
||||
{
|
||||
// totalTracks may be encoded as "position/count"
|
||||
if (const auto value{ getPropertyValueAs<std::string_view>(tags, "TRACKNUMBER") })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, "/") };
|
||||
if (strings.size() == 2)
|
||||
medium->trackCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
// Expecting 'Number[/Total]'
|
||||
medium->position = getPropertyValueAs<std::size_t>(tags, "DISCNUMBER");
|
||||
medium->release = getRelease(tags);
|
||||
medium->replayGain = getPropertyValueAs<float>(tags, "REPLAYGAIN_ALBUM_GAIN");
|
||||
|
||||
if (medium->type.empty()
|
||||
&& medium->name.empty()
|
||||
&& !medium->trackCount
|
||||
&& !medium->position
|
||||
&& !medium->release
|
||||
&& !medium->replayGain)
|
||||
{
|
||||
medium.reset();
|
||||
}
|
||||
|
||||
return medium;
|
||||
}
|
||||
|
||||
TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(ParserReadStyle readStyle)
|
||||
{
|
||||
switch (readStyle)
|
||||
{
|
||||
case ParserReadStyle::Fast: return TagLib::AudioProperties::ReadStyle::Fast;
|
||||
case ParserReadStyle::Average: return TagLib::AudioProperties::ReadStyle::Average;
|
||||
case ParserReadStyle::Accurate: return TagLib::AudioProperties::ReadStyle::Accurate;
|
||||
}
|
||||
|
||||
throw LmsException{ "Cannot convert read style" };
|
||||
}
|
||||
|
||||
TagMap constructTagMap(const TagLib::PropertyMap& properties)
|
||||
{
|
||||
TagMap tagMap;
|
||||
|
||||
for (const auto& [propertyName, propertyValues] : properties)
|
||||
{
|
||||
std::vector<std::string>& values{ tagMap[propertyName.upper().to8Bit(true)] };
|
||||
for (const TagLib::String& propertyValue : propertyValues)
|
||||
{
|
||||
std::string trimedValue{ StringUtils::stringTrim(propertyValue.to8Bit(true)) };
|
||||
if (!trimedValue.empty())
|
||||
values.emplace_back(std::move(trimedValue));
|
||||
}
|
||||
}
|
||||
|
||||
return tagMap;
|
||||
}
|
||||
|
||||
void mergeTagMaps(TagMap& dst, TagMap&& src)
|
||||
{
|
||||
for (auto&& [tag, values] : src)
|
||||
{
|
||||
if (dst.find(tag) == std::cend(dst))
|
||||
dst[tag] = std::move(values);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TagLibParser::TagLibParser(ParserReadStyle readStyle)
|
||||
: _readStyle{ readStyleToTagLibReadStyle(readStyle) }
|
||||
{
|
||||
}
|
||||
|
||||
void TagLibParser::processTag(Track& track, const std::string& tag, const std::vector<std::string>& values, bool debug)
|
||||
{
|
||||
if (debug)
|
||||
std::cout << "[" << tag << "] = " << StringUtils::joinStrings(values, "*SEP*") << std::endl;
|
||||
|
||||
if (tag.empty() || values.empty())
|
||||
return;
|
||||
|
||||
std::string_view value{ values.front() };
|
||||
|
||||
if (tag == "TITLE")
|
||||
track.title = value;
|
||||
else if (tag == "MUSICBRAINZ_RELEASETRACKID"
|
||||
|| tag == "MUSICBRAINZ RELEASE TRACK ID"
|
||||
|| tag == "MUSICBRAINZ/RELEASE TRACK ID")
|
||||
{
|
||||
track.mbid = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "MUSICBRAINZ_TRACKID"
|
||||
|| tag == "MUSICBRAINZ TRACK ID"
|
||||
|| tag == "MUSICBRAINZ/TRACK ID")
|
||||
track.recordingMBID = UUID::fromString(value);
|
||||
else if (tag == "ACOUSTID_ID")
|
||||
track.acoustID = UUID::fromString(value);
|
||||
else if (tag == "TRACKNUMBER")
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
track.position = StringUtils::readAs<std::size_t>(value);
|
||||
}
|
||||
else if (tag == "DATE")
|
||||
{
|
||||
if (const Wt::WDate date{ Utils::parseDate(value) }; date.isValid())
|
||||
track.date = date;
|
||||
else if (!track.year)
|
||||
track.year = Utils::parseYear(value);
|
||||
}
|
||||
else if (tag == "YEAR")
|
||||
track.year = Utils::parseYear(value);
|
||||
else if (tag == "ORIGINALDATE")
|
||||
{
|
||||
if (const Wt::WDate date{ Utils::parseDate(value) }; date.isValid())
|
||||
track.originalDate = date;
|
||||
else if (!track.originalYear)
|
||||
track.originalYear = Utils::parseYear(value);
|
||||
}
|
||||
else if (tag == "ORIGINALYEAR")
|
||||
track.originalYear = Utils::parseYear(value);
|
||||
else if (tag == "METADATA_BLOCK_PICTURE")
|
||||
track.hasCover = true;
|
||||
else if (tag == "COPYRIGHT")
|
||||
track.copyright = value;
|
||||
else if (tag == "COPYRIGHTURL")
|
||||
track.copyrightURL = value;
|
||||
else if (tag == "REPLAYGAIN_TRACK_GAIN")
|
||||
track.replayGain = StringUtils::readAs<float>(value);
|
||||
else if (tag == "ARTIST")
|
||||
track.artistDisplayName = value;
|
||||
else if (std::find(std::cbegin(_userExtraTags), std::cend(_userExtraTags), tag) != std::cend(_userExtraTags))
|
||||
{
|
||||
std::vector<std::string> tagValues;
|
||||
for (std::string_view valueList : values)
|
||||
{
|
||||
const std::vector<std::string_view> splittedValues{ splitAndTrimString(valueList, "/,;") }; // handle possibily bad split tags
|
||||
for (std::string_view value : splittedValues)
|
||||
tagValues.push_back(std::string{ value });
|
||||
}
|
||||
|
||||
if (!tagValues.empty())
|
||||
track.userExtraTags[tag] = std::move(tagValues);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Track> TagLibParser::parse(const std::filesystem::path& p, bool debug)
|
||||
{
|
||||
TagLib::FileRef f{ p.string().c_str(),
|
||||
true, // read audio properties
|
||||
_readStyle };
|
||||
|
||||
if (f.isNull())
|
||||
{
|
||||
LMS_LOG(METADATA, ERROR, "File '" << p.string() << "': parsing failed");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Track track;
|
||||
|
||||
if (const TagLib::AudioProperties* properties{ f.audioProperties() })
|
||||
{
|
||||
track.duration = std::chrono::milliseconds{ properties->lengthInMilliseconds() };
|
||||
track.bitrate = static_cast<std::size_t>(properties->bitrate() * 1000);
|
||||
}
|
||||
else
|
||||
{
|
||||
LMS_LOG(METADATA, INFO, "File '" << p.string() << "': no audio properties");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
TagMap tags{ constructTagMap(f.file()->properties()) };
|
||||
|
||||
auto getAPETags = [&](const TagLib::APE::Tag* apeTag)
|
||||
{
|
||||
if (!apeTag)
|
||||
return;
|
||||
|
||||
mergeTagMaps(tags, constructTagMap(apeTag->properties()));
|
||||
};
|
||||
|
||||
// Not that good embedded pictures handling
|
||||
|
||||
// WMA
|
||||
if (TagLib::ASF::File * asfFile{ dynamic_cast<TagLib::ASF::File*>(f.file()) })
|
||||
{
|
||||
const TagLib::ASF::Tag* tag{ asfFile->tag() };
|
||||
if (tag)
|
||||
{
|
||||
if (tag->attributeListMap().contains("WM/Picture"))
|
||||
track.hasCover = true;
|
||||
|
||||
for (const auto& [name, attributeList] : tag->attributeListMap())
|
||||
{
|
||||
std::string strName{ StringUtils::stringToUpper(name.to8Bit(true)) };
|
||||
if (strName.find("WM/") == 0 || tags.find(strName) != std::cend(tags))
|
||||
continue;
|
||||
|
||||
std::vector<std::string> attributes;
|
||||
for (const auto& attribute : attributeList)
|
||||
{
|
||||
if (attribute.type() == TagLib::ASF::Attribute::AttributeTypes::UnicodeType)
|
||||
attributes.emplace_back(attribute.toString().to8Bit(true));
|
||||
}
|
||||
|
||||
if (!attributes.empty())
|
||||
{
|
||||
if (debug)
|
||||
std::cout << "ASF property: '" << name << "'" << std::endl;
|
||||
|
||||
tags.emplace(strName, std::move(attributes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// MP3
|
||||
else if (TagLib::MPEG::File * mp3File{ dynamic_cast<TagLib::MPEG::File*>(f.file()) })
|
||||
{
|
||||
if (mp3File->ID3v2Tag())
|
||||
{
|
||||
const auto& frameListMap{ mp3File->ID3v2Tag()->frameListMap() };
|
||||
|
||||
if (!frameListMap["APIC"].isEmpty())
|
||||
track.hasCover = true;
|
||||
if (!frameListMap["TSST"].isEmpty())
|
||||
tags["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString().to8Bit(true) };
|
||||
}
|
||||
|
||||
getAPETags(mp3File->APETag());
|
||||
}
|
||||
//MP4
|
||||
else if (TagLib::MP4::File * mp4File{ dynamic_cast<TagLib::MP4::File*>(f.file()) })
|
||||
{
|
||||
TagLib::MP4::Item coverItem{ mp4File->tag()->item("covr") };
|
||||
TagLib::MP4::CoverArtList coverArtList{ coverItem.toCoverArtList() };
|
||||
if (!coverArtList.isEmpty())
|
||||
track.hasCover = true;
|
||||
}
|
||||
// MPC
|
||||
else if (TagLib::MPC::File * mpcFile{ dynamic_cast<TagLib::MPC::File*>(f.file()) })
|
||||
{
|
||||
getAPETags(mpcFile->APETag());
|
||||
}
|
||||
// WavPack
|
||||
else if (TagLib::WavPack::File * wavPackFile{ dynamic_cast<TagLib::WavPack::File*>(f.file()) })
|
||||
{
|
||||
getAPETags(wavPackFile->APETag());
|
||||
}
|
||||
// FLAC
|
||||
else if (TagLib::FLAC::File * flacFile{ dynamic_cast<TagLib::FLAC::File*>(f.file()) })
|
||||
{
|
||||
if (!flacFile->pictureList().isEmpty())
|
||||
track.hasCover = true;
|
||||
}
|
||||
else if (TagLib::Ogg::Vorbis::File * vorbisFile{ dynamic_cast<TagLib::Ogg::Vorbis::File*>(f.file()) })
|
||||
{
|
||||
if (!vorbisFile->tag()->pictureList().isEmpty())
|
||||
track.hasCover = true;
|
||||
}
|
||||
else if (TagLib::Ogg::Opus::File * opusFile{ dynamic_cast<TagLib::Ogg::Opus::File*>(f.file()) })
|
||||
{
|
||||
if (!opusFile->tag()->pictureList().isEmpty())
|
||||
track.hasCover = true;
|
||||
}
|
||||
|
||||
track.medium = getMedium(tags);
|
||||
track.artists = getArtists(tags, { "ARTISTS", "ARTIST" }, { "ARTISTSORT" }, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" });
|
||||
track.conductorArtists = getArtists(tags, { "CONDUCTORS", "CONDUCTOR" }, { "CONDUCTORSSORT", "CONDUCTORSORT" }, {});
|
||||
track.composerArtists = getArtists(tags, { "COMPOSERS", "COMPOSER" }, { "COMPOSERSSORT", "COMPOSERSORT" }, {});
|
||||
track.lyricistArtists = getArtists(tags, { "LYRICISTS", "LYRICIST" }, { "LYRICISTSSORT", "LYRICISTSORT" }, {});
|
||||
track.mixerArtists = getArtists(tags, { "MIXERS", "MIXER" }, { "MIXERSSORT", "MIXERSORT" }, {});
|
||||
track.producerArtists = getArtists(tags, { "PRODUCERS", "PRODUCER" }, { "PRODUCERSSORT", "PRODUCERSORT" }, {});
|
||||
track.remixerArtists = getArtists(tags, { "REMIXERS", "REMIXER", "ModifiedBy" }, { "REMIXERSSORT", "REMIXERSORT" }, {});
|
||||
track.performerArtists = getPerformerArtists(tags, { "PERFORMERS", "PERFORMER" });
|
||||
|
||||
for (const auto& [tag, values] : tags)
|
||||
processTag(track, tag, values, debug);
|
||||
|
||||
// If a file has date but no year, set it
|
||||
if (!track.year && track.date.isValid())
|
||||
track.year = track.date.year();
|
||||
|
||||
// If a file has originalDate but no originalYear, set it
|
||||
if (!track.originalYear && track.originalDate.isValid())
|
||||
track.originalYear = track.originalDate.year();
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
} // namespace MetaData
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2018 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <taglib/audioproperties.h>
|
||||
#include "metadata/IParser.hpp"
|
||||
|
||||
namespace TagLib
|
||||
{
|
||||
class StringList;
|
||||
}
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
|
||||
// Parse that makes use of AvFormat
|
||||
class TagLibParser : public IParser
|
||||
{
|
||||
public:
|
||||
TagLibParser(ParserReadStyle readStyle);
|
||||
|
||||
private:
|
||||
std::optional<Track> parse(const std::filesystem::path& p, bool debug = false) override;
|
||||
void processTag(Track& track, const std::string& tag, const std::vector<std::string>& values, bool debug);
|
||||
|
||||
const TagLib::AudioProperties::ReadStyle _readStyle;
|
||||
};
|
||||
|
||||
} // namespace MetaData
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
* 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "TagLibTagReader.hpp"
|
||||
|
||||
#include <taglib/apetag.h>
|
||||
#include <taglib/asffile.h>
|
||||
#include <taglib/id3v2tag.h>
|
||||
#include <taglib/fileref.h>
|
||||
#include <taglib/flacfile.h>
|
||||
#include <taglib/mp4file.h>
|
||||
#include <taglib/mpcfile.h>
|
||||
#include <taglib/mpegfile.h>
|
||||
#include <taglib/opusfile.h>
|
||||
#include <taglib/tag.h>
|
||||
#include <taglib/tpropertymap.h>
|
||||
#include <taglib/vorbisfile.h>
|
||||
#include <taglib/wavpackfile.h>
|
||||
|
||||
#include "metadata/Exception.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
#include "utils/String.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
class ParsingFailedException : public Exception {};
|
||||
|
||||
// Mapping to internal taglib names and/or common alternative custom names
|
||||
static const std::unordered_map<TagType, std::vector<std::string>> tagMapping
|
||||
{
|
||||
{ TagType::AcoustID, { "ACOUSTID_ID", "ACOUSTID ID" } },
|
||||
{ TagType::Album, { "ALBUM" } },
|
||||
{ TagType::AlbumArtist, { "ALBUMARTIST" } },
|
||||
{ TagType::AlbumArtistSortOrder, { "ALBUMARTISTSORT" } },
|
||||
{ TagType::AlbumArtists, { "ALBUMARTISTS" } },
|
||||
{ TagType::AlbumArtistsSortOrder, { "ALBUMARTISTSSORT" } },
|
||||
{ TagType::AlbumSortOrder, { "ALBUMSORT" } },
|
||||
{ TagType::Arranger, { "ARRANGER" } },
|
||||
{ TagType::Artist, { "ARTIST" } },
|
||||
{ TagType::ArtistSortOrder, { "ARTISTSORT" } },
|
||||
{ TagType::Artists, { "ARTISTS" } },
|
||||
{ TagType::ASIN, { "ASIN" } },
|
||||
{ TagType::Barcode, { "BARCODE" } },
|
||||
{ TagType::BPM, { "BPM" } },
|
||||
{ TagType::CatalogNumber, { "CATALOGNUMBER" } },
|
||||
{ TagType::Comment, { "COMMENT" } },
|
||||
{ TagType::Compilation, { "COMPILATION" } },
|
||||
{ TagType::Composer, { "COMPOSER" } },
|
||||
{ TagType::Composers, { "COMPOSERS" } },
|
||||
{ TagType::ComposerSortOrder, { "COMPOSERSORT" } },
|
||||
{ TagType::ComposersSortOrder, { "COMPOSERSSORT" } },
|
||||
{ TagType::Conductor, { "CONDUCTOR" } },
|
||||
{ TagType::ConductorSortOrder, { "CONDUCTORSORT" } },
|
||||
{ TagType::Conductors, { "CONDUCTORS" } },
|
||||
{ TagType::ConductorsSortOrder, { "CONDUCTORSSORT" } },
|
||||
{ TagType::Copyright, { "COPYRIGHT" } },
|
||||
{ TagType::CopyrightURL, { "COPYRIGHTURL" } },
|
||||
{ TagType::Date, { "DATE", "YEAR" } },
|
||||
{ TagType::Director, { "DIRECTOR" } },
|
||||
{ TagType::DiscNumber, { "DISCNUMBER", "DISC" } },
|
||||
{ TagType::DiscSubtitle, { "DISCSUBTITLE", "SETSUBTITLE" } },
|
||||
{ TagType::EncodedBy, { "ENCODEDBY" } },
|
||||
{ TagType::Engineer, { "ENGINEER" } },
|
||||
{ TagType::GaplessPlayback, { "GAPLESSPLAYBACK" } },
|
||||
{ TagType::Genre, { "GENRE" } },
|
||||
{ TagType::Grouping, { "GROUPING" } },
|
||||
{ TagType::InitialKey, { "INITIALKEY" } },
|
||||
{ TagType::ISRC, { "ISRC" } },
|
||||
{ TagType::Language, { "LANGUAGE" } },
|
||||
{ TagType::License, { "LICENSE" } },
|
||||
{ TagType::Lyricist, { "LYRICIST" } },
|
||||
{ TagType::LyricistSortOrder, { "LYRICISTSORT" } },
|
||||
{ TagType::Lyricists, { "LYRICISTS" } },
|
||||
{ TagType::LyricistsSortOrder, { "LYRICISTSSORT" } },
|
||||
{ TagType::Lyrics, { "LYRICS" } },
|
||||
{ TagType::Media, { "MEDIA" } },
|
||||
{ TagType::MixDJ, { "DJMIXER" } },
|
||||
{ TagType::Mixer, { "MIXER" } },
|
||||
{ TagType::MixerSortOrder, { "MIXERSORT" } },
|
||||
{ TagType::Mixers, { "MIXERS" } },
|
||||
{ TagType::MixersSortOrder, { "MIXERSSORT" } },
|
||||
{ TagType::Mood, { "MOOD" } },
|
||||
{ TagType::Movement, { "MOVEMENT", "MOVEMENTNAME" } },
|
||||
{ TagType::MovementCount, { "MOVEMENTCOUNT" } },
|
||||
{ TagType::MovementNumber, { "MOVEMENTNUMBER" } },
|
||||
{ TagType::MusicBrainzArtistID, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" } },
|
||||
{ TagType::MusicBrainzDiscID, { "MUSICBRAINZ_DISCID", "MUSICBRAINZ DISC ID", "MUSICBRAINZ/DISC ID" } },
|
||||
{ TagType::MusicBrainzOriginalArtistID, { "MUSICBRAINZ_ORIGINALARTISTID", "MUSICBRAINZ ORIGINAL ARTIST ID", "MUSICBRAINZ/ORIGINAL ARTIST ID" } },
|
||||
{ TagType::MusicBrainzOriginalReleaseID, { "MUSICBRAINZ_ORIGINALRELEASEID", "MUSICBRAINZ ORIGINAL RELEASE ID", "MUSICBRAINZ/ORIGINAL RELEASE ID" } },
|
||||
{ TagType::MusicBrainzRecordingID, { "MUSICBRAINZ_TRACKID", "MUSICBRAINZ TRACK ID", "MUSICBRAINZ/TRACK ID" } },
|
||||
{ TagType::MusicBrainzReleaseArtistID, { "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID" } },
|
||||
{ TagType::MusicBrainzReleaseGroupID, { "MUSICBRAINZ_RELEASEGROUPID", "MUSICBRAINZ RELEASE GROUP ID", "MUSICBRAINZ/RELEASE GROUP ID" } },
|
||||
{ TagType::MusicBrainzReleaseID, { "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ/ALBUM ID" } },
|
||||
{ TagType::MusicBrainzTrackID, { "MUSICBRAINZ_RELEASETRACKID", "MUSICBRAINZ RELEASE TRACK ID", "MUSICBRAINZ/RELEASE TRACK ID" } },
|
||||
{ TagType::MusicBrainzWorkID, { "MUSICBRAINZ_WORKID", "MUSICBRAINZ WORK ID", "MUSICBRAINZ/WORK ID" } },
|
||||
{ TagType::OriginalArtist, { "ORIGINALARTIST" } },
|
||||
{ TagType::OriginalFilename, { "ORIGINALFILENAME" } },
|
||||
{ TagType::OriginalReleaseDate, { "ORIGINALDATE" } },
|
||||
{ TagType::OriginalReleaseYear, { "ORIGINALYEAR" } },
|
||||
{ TagType::Podcast, { "PODCAST" } },
|
||||
{ TagType::PodcastURL, { "PODCASTURL" } },
|
||||
{ TagType::Producer, { "PRODUCER" } },
|
||||
{ TagType::ProducerSortOrder, { "PRODUCERSORTORDER" } },
|
||||
{ TagType::Producers, { "PRODUCERS" } },
|
||||
{ TagType::ProducersSortOrder, { "PRODUCERSSORTORDER" } },
|
||||
{ TagType::RecordLabel, { "LABEL" } },
|
||||
{ TagType::ReleaseCountry, { "RELEASECOUNTRY" } },
|
||||
{ TagType::ReleaseDate, { "RELEASEDATE" } },
|
||||
{ TagType::ReleaseStatus, { "RELEASESTATUS" } },
|
||||
{ TagType::ReleaseType, { "RELEASETYPE", "MUSICBRAINZ_ALBUMTYPE", "MUSICBRAINZ ALBUM TYPE", "MUSICBRAINZ/ALBUM TYPE" } },
|
||||
{ TagType::Remixer, { "REMIXER", "MODIFIEDBY", "MIXARTIST" } },
|
||||
{ TagType::RemixerSortOrder, { "REMIXERSORTORDER", "MIXARTISTSORTORDER" } },
|
||||
{ TagType::Remixers, { "REMIXERS" } },
|
||||
{ TagType::RemixersSortOrder, { "REMIXERSSORTORDER", "MIXARTISTSSORTORDER" } },
|
||||
{ TagType::ReplayGainAlbumGain, { "REPLAYGAIN_ALBUM_GAIN" } },
|
||||
{ TagType::ReplayGainAlbumPeak, { "REPLAYGAIN_ALBUM_PEAK" } },
|
||||
{ TagType::ReplayGainAlbumRange, { "REPLAYGAIN_ALBUM_RANGE" } },
|
||||
{ TagType::ReplayGainReferenceLoudness, { "REPLAYGAIN_REFERENCE_LOUDNESS" } },
|
||||
{ TagType::ReplayGainTrackGain, { "REPLAYGAIN_TRACK_GAIN" } },
|
||||
{ TagType::ReplayGainTrackPeak, { "REPLAYGAIN_TRACK_PEAK" } },
|
||||
{ TagType::ReplayGainTrackRange, { "REPLAYGAIN_TRACK_RANGE" } },
|
||||
{ TagType::Script, { "SCRIPT" } },
|
||||
{ TagType::ShowWorkAndMovement, { "SHOWWORKMOVEMENT", "SHOWMOVEMENT" } },
|
||||
{ TagType::Subtitle, { "SUBTITLE" } },
|
||||
{ TagType::TotalDiscs, { "DISCTOTAL", "TOTALDISCS"} },
|
||||
{ TagType::TotalTracks, { "TRACKTOTAL", "TOTALTRACKS" } },
|
||||
{ TagType::TrackNumber, { "TRACKNUMBER" } },
|
||||
{ TagType::TrackTitle, { "TITLE" } },
|
||||
{ TagType::TrackTitleSortOrder, { "TITLESORT" } },
|
||||
{ TagType::WorkTitle, { "WORK" } },
|
||||
{ TagType::Writer, { "WRITER" } },
|
||||
};
|
||||
|
||||
TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(ParserReadStyle readStyle)
|
||||
{
|
||||
switch (readStyle)
|
||||
{
|
||||
case ParserReadStyle::Fast: return TagLib::AudioProperties::ReadStyle::Fast;
|
||||
case ParserReadStyle::Average: return TagLib::AudioProperties::ReadStyle::Average;
|
||||
case ParserReadStyle::Accurate: return TagLib::AudioProperties::ReadStyle::Accurate;
|
||||
}
|
||||
|
||||
throw LmsException{ "Cannot convert read style" };
|
||||
}
|
||||
|
||||
void mergeTagMaps(TagLib::PropertyMap& dst, TagLib::PropertyMap&& src)
|
||||
{
|
||||
for (auto&& [tag, values] : src)
|
||||
{
|
||||
if (dst.find(tag) == std::cend(dst))
|
||||
dst[tag] = std::move(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TagLibTagReader::TagLibTagReader(const std::filesystem::path& p, ParserReadStyle parserReadStyle, bool debug)
|
||||
: _file{ p.string().c_str()
|
||||
, true // read audio properties
|
||||
, readStyleToTagLibReadStyle(parserReadStyle) }
|
||||
{
|
||||
if (_file.isNull())
|
||||
{
|
||||
LMS_LOG(METADATA, ERROR, "File '" << p.string() << "': parsing failed");
|
||||
throw ParsingFailedException{};
|
||||
}
|
||||
|
||||
if (!_file.audioProperties())
|
||||
{
|
||||
LMS_LOG(METADATA, ERROR, "File '" << p.string() << "': no audio properties");
|
||||
throw ParsingFailedException{};
|
||||
}
|
||||
|
||||
_propertyMap = _file.file()->properties();
|
||||
|
||||
// Some tags may not be known by TagLib
|
||||
auto getAPETags = [&](const TagLib::APE::Tag* apeTag)
|
||||
{
|
||||
if (!apeTag)
|
||||
return;
|
||||
|
||||
mergeTagMaps(_propertyMap, apeTag->properties());
|
||||
};
|
||||
|
||||
// Not that good embedded pictures handling
|
||||
// + get some extra tags that may not be known by taglib
|
||||
|
||||
// WMA
|
||||
if (TagLib::ASF::File * asfFile{ dynamic_cast<TagLib::ASF::File*>(_file.file()) })
|
||||
{
|
||||
if (const TagLib::ASF::Tag * tag{ asfFile->tag() })
|
||||
{
|
||||
if (tag->attributeListMap().contains("WM/Picture"))
|
||||
_hasEmbeddedCover = true;
|
||||
|
||||
for (const auto& [name, attributeList] : tag->attributeListMap())
|
||||
{
|
||||
if (attributeList.isEmpty())
|
||||
continue;
|
||||
|
||||
std::string strName{ StringUtils::stringToUpper(name.to8Bit(true)) };
|
||||
if (strName.find("WM/") == 0 || _propertyMap.find(strName) != std::cend(_propertyMap))
|
||||
continue;
|
||||
|
||||
TagLib::StringList attributes;
|
||||
for (const TagLib::ASF::Attribute& attribute : attributeList)
|
||||
{
|
||||
if (attribute.type() == TagLib::ASF::Attribute::AttributeTypes::UnicodeType)
|
||||
attributes.append(attribute.toString());
|
||||
}
|
||||
|
||||
if (!attributes.isEmpty())
|
||||
_propertyMap[strName] = std::move(attributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString().to8Bit(true) };
|
||||
}
|
||||
|
||||
getAPETags(mp3File->APETag());
|
||||
}
|
||||
//MP4
|
||||
else if (TagLib::MP4::File * mp4File{ dynamic_cast<TagLib::MP4::File*>(_file.file()) })
|
||||
{
|
||||
TagLib::MP4::Item coverItem{ mp4File->tag()->item("covr") };
|
||||
TagLib::MP4::CoverArtList coverArtList{ coverItem.toCoverArtList() };
|
||||
if (!coverArtList.isEmpty())
|
||||
_hasEmbeddedCover = true;
|
||||
}
|
||||
// MPC
|
||||
else if (TagLib::MPC::File * mpcFile{ dynamic_cast<TagLib::MPC::File*>(_file.file()) })
|
||||
{
|
||||
getAPETags(mpcFile->APETag());
|
||||
}
|
||||
// WavPack
|
||||
else if (TagLib::WavPack::File * wavPackFile{ dynamic_cast<TagLib::WavPack::File*>(_file.file()) })
|
||||
{
|
||||
getAPETags(wavPackFile->APETag());
|
||||
}
|
||||
// FLAC
|
||||
else if (TagLib::FLAC::File * flacFile{ dynamic_cast<TagLib::FLAC::File*>(_file.file()) })
|
||||
{
|
||||
if (!flacFile->pictureList().isEmpty())
|
||||
_hasEmbeddedCover = true;
|
||||
}
|
||||
else if (TagLib::Ogg::Vorbis::File * vorbisFile{ dynamic_cast<TagLib::Ogg::Vorbis::File*>(_file.file()) })
|
||||
{
|
||||
if (!vorbisFile->tag()->pictureList().isEmpty())
|
||||
_hasEmbeddedCover = true;
|
||||
}
|
||||
else if (TagLib::Ogg::Opus::File * opusFile{ dynamic_cast<TagLib::Ogg::Opus::File*>(_file.file()) })
|
||||
{
|
||||
if (!opusFile->tag()->pictureList().isEmpty())
|
||||
_hasEmbeddedCover = true;
|
||||
}
|
||||
|
||||
if (debug && Service<ILogger>::get()->isSeverityActive(Severity::DEBUG))
|
||||
{
|
||||
for (const auto& [key, values] : _propertyMap)
|
||||
{
|
||||
for (const auto& value : values)
|
||||
LMS_LOG(METADATA, DEBUG, "Key = '" << key << "', value = '" << value.to8Bit(true) << "'");
|
||||
}
|
||||
}
|
||||
|
||||
_hasMultiValuedTags = std::any_of(std::cbegin(_propertyMap), std::cend(_propertyMap), [](const auto& entry) { return entry.second.size() > 1; });
|
||||
}
|
||||
|
||||
void TagLibTagReader::visitTagValues(TagType tag, TagValueVisitor visitor) const
|
||||
{
|
||||
auto itTagNames{ tagMapping.find(tag) };
|
||||
if (itTagNames == std::cend(tagMapping))
|
||||
return;
|
||||
|
||||
for (const std::string& tagName : itTagNames->second)
|
||||
{
|
||||
bool visited{};
|
||||
|
||||
visitTagValues(tagName, [&](std::string_view value)
|
||||
{
|
||||
visited = true;
|
||||
visitor(value);
|
||||
});
|
||||
|
||||
if (visited)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void TagLibTagReader::visitTagValues(std::string_view tag, TagValueVisitor visitor) const
|
||||
{
|
||||
TagLib::String key{ tag.data() /* assume null terminated */, TagLib::String::Type::UTF8 };
|
||||
|
||||
auto itValues{ _propertyMap.find(key) };
|
||||
if (itValues == std::cend(_propertyMap))
|
||||
return;
|
||||
|
||||
for (const TagLib::String& value : itValues->second)
|
||||
visitor(value.to8Bit(true));
|
||||
}
|
||||
|
||||
void TagLibTagReader::visitPerformerTags(PerformerVisitor visitor) const
|
||||
{
|
||||
visitTagValues("PERFORMER", [&](std::string_view value)
|
||||
{
|
||||
visitor("", value);
|
||||
});
|
||||
|
||||
for (const auto& [key, values] : _propertyMap)
|
||||
{
|
||||
if (key.startsWith("PERFORMER:")) // startsWith is not case sensitive
|
||||
{
|
||||
std::string performerStr{ key.to8Bit(true) };
|
||||
const std::size_t rolePos{ performerStr.find(':') };
|
||||
assert(rolePos != std::string::npos);
|
||||
|
||||
std::string_view role{ std::string_view{ performerStr }.substr(rolePos + 1) };
|
||||
for (const TagLib::String& value : values)
|
||||
{
|
||||
const std::string name{ value.to8Bit(true) };
|
||||
visitor(role, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::chrono::milliseconds TagLibTagReader::getDuration() const
|
||||
{
|
||||
return std::chrono::milliseconds{ _file.audioProperties()->lengthInMilliseconds() };
|
||||
}
|
||||
|
||||
std::size_t TagLibTagReader::getBitrate() const
|
||||
{
|
||||
return static_cast<std::size_t>(_file.audioProperties()->bitrate() * 1000);
|
||||
}
|
||||
|
||||
std::size_t TagLibTagReader::getBitsPerSample() const
|
||||
{
|
||||
return 0; // TODO
|
||||
}
|
||||
|
||||
std::size_t TagLibTagReader::getSampleRate() const
|
||||
{
|
||||
return static_cast<std::size_t>(_file.audioProperties()->sampleRate());
|
||||
}
|
||||
} // namespace MetaData
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2018 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include <taglib/fileref.h>
|
||||
#include <taglib/tpropertymap.h>
|
||||
|
||||
#include "metadata/IParser.hpp"
|
||||
#include "ITagReader.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
class TagLibTagReader : public ITagReader
|
||||
{
|
||||
public:
|
||||
TagLibTagReader(const std::filesystem::path& path, ParserReadStyle parserReadStyle, bool debug);
|
||||
|
||||
private:
|
||||
TagLibTagReader(const TagLibTagReader&) = delete;
|
||||
TagLibTagReader& operator=(const TagLibTagReader&) = delete;
|
||||
|
||||
bool hasMultiValuedTags() const override { return _hasMultiValuedTags; }
|
||||
void visitTagValues(TagType tag, TagValueVisitor visitor) const override;
|
||||
void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override;
|
||||
void visitPerformerTags(PerformerVisitor visitor) const override;
|
||||
bool hasEmbeddedCover() const override { return _hasEmbeddedCover; }
|
||||
|
||||
std::chrono::milliseconds getDuration() const override;
|
||||
std::size_t getBitrate() const override;
|
||||
std::size_t getBitsPerSample() const override;
|
||||
std::size_t getSampleRate() const override;
|
||||
|
||||
TagLib::FileRef _file;
|
||||
TagLib::PropertyMap _propertyMap; // case-insensitive keys
|
||||
bool _hasEmbeddedCover{};
|
||||
bool _hasMultiValuedTags{};
|
||||
};
|
||||
} // namespace MetaData
|
||||
+13
-11
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Emeric Poupon
|
||||
* Copyright (C) 2024 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
@@ -19,17 +19,19 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "metadata/IParser.hpp"
|
||||
#include "utils/Exception.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
class Exception : public LmsException
|
||||
{
|
||||
public:
|
||||
using LmsException::LmsException;
|
||||
};
|
||||
|
||||
// Parse that makes use of AvFormat
|
||||
class AvFormatParser : public IParser
|
||||
{
|
||||
public:
|
||||
std::optional<Track> parse(const std::filesystem::path& p, bool debug = false) override;
|
||||
};
|
||||
|
||||
} // namespace MetaData
|
||||
|
||||
class ParseException : public Exception
|
||||
{
|
||||
public:
|
||||
using Exception::Exception;
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
@@ -44,6 +45,8 @@ namespace MetaData
|
||||
|
||||
Artist(std::string_view _name) : name{ _name } {}
|
||||
Artist(std::optional<UUID> _mbid, std::string_view _name, std::optional<std::string> _sortName) : mbid{ std::move(_mbid) }, name{ _name }, sortName{ std::move(_sortName) } {}
|
||||
|
||||
bool operator<=>(const Artist&) const = default;
|
||||
};
|
||||
|
||||
using PerformerContainer = std::map<std::string /*role*/, std::vector<Artist>>;
|
||||
@@ -56,16 +59,26 @@ namespace MetaData
|
||||
std::vector<Artist> artists;
|
||||
std::optional<std::size_t> mediumCount;
|
||||
std::vector<std::string> releaseTypes;
|
||||
|
||||
bool operator<=>(const Release&) const = default;
|
||||
};
|
||||
|
||||
struct Medium
|
||||
{
|
||||
std::string type; // CD, etc.
|
||||
std::string media; // CD, etc.
|
||||
std::string name;
|
||||
std::optional<Release> release;
|
||||
std::optional<std::size_t> position; // in release
|
||||
std::optional<std::size_t> trackCount;
|
||||
std::optional<float> replayGain;
|
||||
|
||||
bool operator<=>(const Medium&) const = default;
|
||||
|
||||
bool isDefault() const
|
||||
{
|
||||
static Medium defaultMedium;
|
||||
return *this == defaultMedium;
|
||||
}
|
||||
};
|
||||
|
||||
struct Track
|
||||
@@ -75,9 +88,10 @@ namespace MetaData
|
||||
std::string title;
|
||||
std::optional<Medium> medium;
|
||||
std::optional<std::size_t> position; // in medium
|
||||
std::vector<std::string> grouping;
|
||||
std::vector<std::string> groupings;
|
||||
std::vector<std::string> genres;
|
||||
std::vector<std::string> moods;
|
||||
std::vector<std::string> labels;
|
||||
std::vector<std::string> languages;
|
||||
Tags userExtraTags;
|
||||
std::chrono::milliseconds duration{};
|
||||
@@ -107,15 +121,14 @@ namespace MetaData
|
||||
public:
|
||||
virtual ~IParser() = default;
|
||||
|
||||
virtual std::optional<Track> parse(const std::filesystem::path& p, bool debug = false) = 0;
|
||||
virtual std::unique_ptr<Track> parse(const std::filesystem::path& p, bool debug = false) = 0;
|
||||
|
||||
void setUserExtraTags(const std::vector<std::string>& extraTags) { _userExtraTags = std::vector(extraTags.cbegin(), extraTags.cend()); }
|
||||
|
||||
protected:
|
||||
std::vector<std::string> _userExtraTags;
|
||||
virtual void setUserExtraTags(std::span<const std::string> extraTags) = 0;
|
||||
virtual void setArtistTagDelimiters(std::span<const std::string> delimiters) = 0;
|
||||
virtual void setDefaultTagDelimiters(std::span<const std::string> delimiters) = 0;
|
||||
};
|
||||
|
||||
enum class ParserType
|
||||
enum class ParserBackend
|
||||
{
|
||||
TagLib,
|
||||
AvFormat,
|
||||
@@ -127,5 +140,5 @@ namespace MetaData
|
||||
Average,
|
||||
Accurate,
|
||||
};
|
||||
std::unique_ptr<IParser> createParser(ParserType parserType, ParserReadStyle parserReadStyle);
|
||||
std::unique_ptr<IParser> createParser(ParserBackend parserBackend, ParserReadStyle parserReadStyle);
|
||||
} // namespace MetaData
|
||||
|
||||
@@ -2,6 +2,7 @@ include(GoogleTest)
|
||||
|
||||
add_executable(test-metadata
|
||||
Metadata.cpp
|
||||
Parser.cpp
|
||||
Utils.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "TestTagReader.hpp"
|
||||
#include "Parser.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
TEST(Parser, generalTest)
|
||||
{
|
||||
Parser parser;
|
||||
TestTagReader testTags{
|
||||
{
|
||||
{ TagType::AcoustID, { "e987a441-e134-4960-8019-274eddacc418" } },
|
||||
{ TagType::Album, { "MyAlbum" } },
|
||||
{ TagType::Artist, { "MyArtist1 & MyArtist2" } },
|
||||
{ TagType::Artists, { "MyArtist1", "MyArtist2" } },
|
||||
{ TagType::ArtistSortOrder, { "MyArtist1SortName", "MyArtist2SortName" } },
|
||||
{ TagType::AlbumArtist, { "MyAlbumArtist1 & MyAlbumArtist2" } },
|
||||
{ TagType::AlbumArtists, { "MyAlbumArtist1", "MyAlbumArtist2" } },
|
||||
{ TagType::AlbumArtistsSortOrder, { "MyAlbumArtist1SortName", "MyAlbumArtist2SortName" } },
|
||||
{ TagType::Composer, { "MyComposer1", "MyComposer2" } },
|
||||
{ TagType::ComposerSortOrder, { "MyComposerSortOrder1", "MyComposerSortOrder2" } },
|
||||
{ TagType::Conductor, { "MyConductor1", "MyConductor2" } },
|
||||
{ TagType::Copyright, { "MyCopyright" } },
|
||||
{ TagType::CopyrightURL, { "MyCopyrightURL" } },
|
||||
{ TagType::Date, { "2020/03/04" } },
|
||||
{ TagType::DiscNumber, { "2" } },
|
||||
{ TagType::DiscSubtitle, { "MySubtitle" } },
|
||||
{ TagType::Genre, { "Genre1", "Genre2" } },
|
||||
{ TagType::Grouping, { "Grouping1", "Grouping2" } },
|
||||
{ TagType::Media, { "CD" } },
|
||||
{ TagType::Mixer, { "MyMixer1", "MyMixer2" } },
|
||||
{ TagType::Mood, { "Mood1", "Mood2" } },
|
||||
{ TagType::MusicBrainzArtistID, { "9d2e0c8c-8c5e-4372-a061-590955eaeaae", "5e2cf87f-c8d7-4504-8a86-954dc0840229" } },
|
||||
{ TagType::MusicBrainzTrackID, { "0afb190a-6735-46df-a16d-199f48206e4a" } },
|
||||
{ TagType::MusicBrainzReleaseArtistID, { "6fbf097c-1487-43e8-874b-50dd074398a7", "5ed3d6b3-2aed-4a03-828c-3c4d4f7406e1" } },
|
||||
{ TagType::MusicBrainzReleaseID, { "3fa39992-b786-4585-a70e-85d5cc15ef69" } },
|
||||
{ TagType::MusicBrainzRecordingID, { "bd3fc666-89de-4ac8-93f6-2dbf028ad8d5" } },
|
||||
{ TagType::Producer, { "MyProducer1", "MyProducer2" } },
|
||||
{ TagType::Remixer, { "MyRemixer1", "MyRemixer2" } },
|
||||
{ TagType::RecordLabel, { "Label1", "Label2" } },
|
||||
{ TagType::Language, { "Language1", "Language2" } },
|
||||
{ TagType::Lyricist, { "MyLyricist1", "MyLyricist2" } },
|
||||
{ TagType::OriginalReleaseDate, { "2019/02/03" } },
|
||||
{ TagType::ReleaseType, {"Album", "Compilation"} },
|
||||
{ TagType::ReplayGainTrackGain, {"-0.33"} },
|
||||
{ TagType::ReplayGainAlbumGain, {"-0.5"} },
|
||||
{ TagType::TrackTitle, {"MyTitle"} },
|
||||
{ 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" } }
|
||||
}
|
||||
};
|
||||
|
||||
static_cast<IParser&>(parser).setUserExtraTags(std::vector<std::string>{ "MY_AWESOME_TAG_A", "MY_AWESOME_TAG_B", "MY_AWESOME_MISSING_TAG" });
|
||||
|
||||
std::unique_ptr<Track> track{ parser.parse(testTags) };
|
||||
|
||||
EXPECT_EQ(track->acoustID, UUID::fromString("e987a441-e134-4960-8019-274eddacc418"));
|
||||
EXPECT_EQ(track->artistDisplayName, "MyArtist1 & MyArtist2");
|
||||
ASSERT_EQ(track->artists.size(), 2);
|
||||
EXPECT_EQ(track->artists[0].name, "MyArtist1");
|
||||
EXPECT_EQ(track->artists[0].sortName, "MyArtist1SortName");
|
||||
EXPECT_EQ(track->artists[0].mbid, UUID::fromString("9d2e0c8c-8c5e-4372-a061-590955eaeaae"));
|
||||
EXPECT_EQ(track->artists[1].name, "MyArtist2");
|
||||
EXPECT_EQ(track->artists[1].sortName, "MyArtist2SortName");
|
||||
EXPECT_EQ(track->artists[1].mbid, UUID::fromString("5e2cf87f-c8d7-4504-8a86-954dc0840229"));
|
||||
EXPECT_EQ(track->bitrate, TestTagReader::trackBitrate);
|
||||
ASSERT_EQ(track->composerArtists.size(), 2);
|
||||
EXPECT_EQ(track->composerArtists[0].name, "MyComposer1");
|
||||
EXPECT_EQ(track->composerArtists[0].sortName, "MyComposerSortOrder1");
|
||||
EXPECT_EQ(track->composerArtists[1].name, "MyComposer2");
|
||||
EXPECT_EQ(track->composerArtists[1].sortName, "MyComposerSortOrder2");
|
||||
ASSERT_EQ(track->conductorArtists.size(), 2);
|
||||
EXPECT_EQ(track->conductorArtists[0].name, "MyConductor1");
|
||||
EXPECT_EQ(track->conductorArtists[1].name, "MyConductor2");
|
||||
EXPECT_EQ(track->copyright, "MyCopyright");
|
||||
EXPECT_EQ(track->copyrightURL, "MyCopyrightURL");
|
||||
ASSERT_TRUE(track->date.isValid());
|
||||
EXPECT_EQ(track->date.year(), 2020);
|
||||
EXPECT_EQ(track->date.month(), 3);
|
||||
EXPECT_EQ(track->date.day(), 4);
|
||||
EXPECT_EQ(track->duration, TestTagReader::trackDuration);
|
||||
EXPECT_FALSE(track->hasCover);
|
||||
ASSERT_EQ(track->genres.size(), 2);
|
||||
EXPECT_EQ(track->genres[0], "Genre1");
|
||||
EXPECT_EQ(track->genres[1], "Genre2");
|
||||
ASSERT_EQ(track->groupings.size(), 2);
|
||||
EXPECT_EQ(track->groupings[0], "Grouping1");
|
||||
EXPECT_EQ(track->groupings[1], "Grouping2");
|
||||
ASSERT_EQ(track->labels.size(), 2);
|
||||
EXPECT_EQ(track->labels[0], "Label1");
|
||||
EXPECT_EQ(track->labels[1], "Label2");
|
||||
ASSERT_EQ(track->languages.size(), 2);
|
||||
EXPECT_EQ(track->languages[0], "Language1");
|
||||
EXPECT_EQ(track->languages[1], "Language2");
|
||||
ASSERT_EQ(track->lyricistArtists.size(), 2);
|
||||
EXPECT_EQ(track->lyricistArtists[0].name, "MyLyricist1");
|
||||
EXPECT_EQ(track->lyricistArtists[1].name, "MyLyricist2");
|
||||
ASSERT_TRUE(track->mbid.has_value());
|
||||
EXPECT_EQ(track->mbid.value(), UUID::fromString("0afb190a-6735-46df-a16d-199f48206e4a"));
|
||||
ASSERT_EQ(track->mixerArtists.size(), 2);
|
||||
EXPECT_EQ(track->mixerArtists[0].name, "MyMixer1");
|
||||
EXPECT_EQ(track->mixerArtists[1].name, "MyMixer2");
|
||||
ASSERT_EQ(track->moods.size(), 2);
|
||||
EXPECT_EQ(track->moods[0], "Mood1");
|
||||
EXPECT_EQ(track->moods[1], "Mood2");
|
||||
ASSERT_TRUE(track->originalDate.isValid());
|
||||
EXPECT_EQ(track->originalDate.year(), 2019);
|
||||
EXPECT_EQ(track->originalDate.month(), 2);
|
||||
EXPECT_EQ(track->originalDate.day(), 3);
|
||||
ASSERT_TRUE(track->originalYear.has_value());
|
||||
EXPECT_EQ(track->originalYear.value(), 2019);
|
||||
ASSERT_TRUE(track->performerArtists.contains("Rolea"));
|
||||
ASSERT_EQ(track->performerArtists["Rolea"].size(), 2);
|
||||
EXPECT_EQ(track->performerArtists["Rolea"][0].name, "MyPerformer1ForRoleA");
|
||||
EXPECT_EQ(track->performerArtists["Rolea"][1].name, "MyPerformer2ForRoleA");
|
||||
ASSERT_EQ(track->performerArtists["Roleb"].size(), 2);
|
||||
EXPECT_EQ(track->performerArtists["Roleb"][0].name, "MyPerformer1ForRoleB");
|
||||
EXPECT_EQ(track->performerArtists["Roleb"][1].name, "MyPerformer2ForRoleB");
|
||||
ASSERT_TRUE(track->position.has_value());
|
||||
EXPECT_EQ(track->position.value(), 7);
|
||||
ASSERT_EQ(track->producerArtists.size(), 2);
|
||||
EXPECT_EQ(track->producerArtists[0].name, "MyProducer1");
|
||||
EXPECT_EQ(track->producerArtists[1].name, "MyProducer2");
|
||||
ASSERT_TRUE(track->recordingMBID.has_value());
|
||||
EXPECT_EQ(track->recordingMBID.value(), UUID::fromString("bd3fc666-89de-4ac8-93f6-2dbf028ad8d5"));
|
||||
ASSERT_TRUE(track->replayGain.has_value());
|
||||
EXPECT_FLOAT_EQ(track->replayGain.value(), -0.33);
|
||||
ASSERT_EQ(track->remixerArtists.size(), 2);
|
||||
EXPECT_EQ(track->remixerArtists[0].name, "MyRemixer1");
|
||||
EXPECT_EQ(track->remixerArtists[1].name, "MyRemixer2");
|
||||
EXPECT_EQ(track->title, "MyTitle");
|
||||
ASSERT_EQ(track->userExtraTags["MY_AWESOME_TAG_A"].size(), 2);
|
||||
EXPECT_EQ(track->userExtraTags["MY_AWESOME_TAG_A"][0], "MyTagValue1ForTagA");
|
||||
EXPECT_EQ(track->userExtraTags["MY_AWESOME_TAG_A"][1], "MyTagValue2ForTagA");
|
||||
ASSERT_EQ(track->userExtraTags["MY_AWESOME_TAG_B"].size(), 2);
|
||||
EXPECT_EQ(track->userExtraTags["MY_AWESOME_TAG_B"][0], "MyTagValue1ForTagB");
|
||||
EXPECT_EQ(track->userExtraTags["MY_AWESOME_TAG_B"][1], "MyTagValue2ForTagB");
|
||||
ASSERT_TRUE(track->year.has_value());
|
||||
EXPECT_EQ(track->year.value(), 2020);
|
||||
|
||||
// Medium
|
||||
ASSERT_TRUE(track->medium.has_value());
|
||||
EXPECT_EQ(track->medium->media, "CD");
|
||||
EXPECT_EQ(track->medium->name, "MySubtitle");
|
||||
ASSERT_TRUE(track->medium->position.has_value());
|
||||
EXPECT_EQ(track->medium->position.value(), 2);
|
||||
ASSERT_TRUE(track->medium->replayGain.has_value());
|
||||
EXPECT_FLOAT_EQ(track->medium->replayGain.value(), -0.5);
|
||||
ASSERT_TRUE(track->medium->trackCount.has_value());
|
||||
EXPECT_EQ(track->medium->trackCount.value(), 12);
|
||||
|
||||
// Release
|
||||
ASSERT_TRUE(track->medium->release.has_value());
|
||||
EXPECT_EQ(track->medium->release->artistDisplayName, "MyAlbumArtist1 & MyAlbumArtist2");
|
||||
ASSERT_EQ(track->medium->release->artists.size(), 2);
|
||||
EXPECT_EQ(track->medium->release->artists[0].name, "MyAlbumArtist1");
|
||||
EXPECT_EQ(track->medium->release->artists[0].sortName, "MyAlbumArtist1SortName");
|
||||
EXPECT_EQ(track->medium->release->artists[0].mbid, UUID::fromString("6fbf097c-1487-43e8-874b-50dd074398a7"));
|
||||
EXPECT_EQ(track->medium->release->artists[1].name, "MyAlbumArtist2");
|
||||
EXPECT_EQ(track->medium->release->artists[1].sortName, "MyAlbumArtist2SortName");
|
||||
EXPECT_EQ(track->medium->release->artists[1].mbid, UUID::fromString("5ed3d6b3-2aed-4a03-828c-3c4d4f7406e1"));
|
||||
ASSERT_TRUE(track->medium->release->mbid.has_value());
|
||||
EXPECT_EQ(track->medium->release->mbid.value(), UUID::fromString("3fa39992-b786-4585-a70e-85d5cc15ef69"));
|
||||
EXPECT_EQ(track->medium->release->mediumCount, 3);
|
||||
EXPECT_EQ(track->medium->release->name, "MyAlbum");
|
||||
{
|
||||
std::vector<std::string> expectedReleaseTypes{ "Album", "Compilation" };
|
||||
EXPECT_EQ(track->medium->release->releaseTypes, expectedReleaseTypes);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(Parser, trim)
|
||||
{
|
||||
const TestTagReader testTags{
|
||||
{
|
||||
{ TagType::Genre, { "Genre1 ", " Genre2", " Genre3 " } },
|
||||
}
|
||||
};
|
||||
|
||||
std::unique_ptr<Track> track{ Parser{}.parse(testTags) };
|
||||
|
||||
ASSERT_EQ(track->genres.size(), 3);
|
||||
EXPECT_EQ(track->genres[0], "Genre1");
|
||||
EXPECT_EQ(track->genres[1], "Genre2");
|
||||
EXPECT_EQ(track->genres[2], "Genre3");
|
||||
}
|
||||
|
||||
TEST(Parser, customDelimiters)
|
||||
{
|
||||
const TestTagReader testTags{
|
||||
{
|
||||
{ TagType::Genre, { "Genre1; Genre2" } },
|
||||
{ TagType::Language, { " Lang1 ; Lang2 ; " } },
|
||||
{ TagType::Artist, { " This / is ; One Artist \\ Other Artist " } },
|
||||
}
|
||||
};
|
||||
|
||||
Parser parser;
|
||||
static_cast<IParser&>(parser).setDefaultTagDelimiters(std::vector<std::string>{ " ; " });
|
||||
static_cast<IParser&>(parser).setArtistTagDelimiters(std::vector<std::string>{ " \\ ", " / " });
|
||||
std::unique_ptr<Track> track{ parser.parse(testTags) };
|
||||
|
||||
ASSERT_EQ(track->genres.size(), 1);
|
||||
EXPECT_EQ(track->genres[0], "Genre1; Genre2");
|
||||
ASSERT_EQ(track->languages.size(), 2);
|
||||
EXPECT_EQ(track->languages[0], "Lang1");
|
||||
EXPECT_EQ(track->languages[1], "Lang2");
|
||||
ASSERT_EQ(track->artists.size(), 2);
|
||||
EXPECT_EQ(track->artists[0].name, "This / is ; One Artist");
|
||||
EXPECT_EQ(track->artists[1].name, "Other Artist");
|
||||
EXPECT_EQ(track->artistDisplayName, "This / is ; One Artist \\ Other Artist");
|
||||
}
|
||||
|
||||
TEST(Parser, customDelimiters_notWithMultiValuedTags)
|
||||
{
|
||||
const TestTagReader testTags{
|
||||
{
|
||||
{ TagType::Genre, { "Genre1 ; Genre2" } },
|
||||
{ TagType::Language, { "Lang1", "Lang2" } },
|
||||
}
|
||||
};
|
||||
|
||||
Parser parser;
|
||||
static_cast<IParser&>(parser).setDefaultTagDelimiters(std::vector<std::string>{ " ; " });
|
||||
std::unique_ptr<Track> track{ parser.parse(testTags) };
|
||||
|
||||
ASSERT_EQ(track->genres.size(), 1);
|
||||
EXPECT_EQ(track->genres[0], "Genre1 ; Genre2");
|
||||
ASSERT_EQ(track->languages.size(), 2);
|
||||
EXPECT_EQ(track->languages[0], "Lang1");
|
||||
EXPECT_EQ(track->languages[1], "Lang2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "Parser.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
template <typename TagMapType>
|
||||
bool tagMapHasMultiValuedTags(const TagMapType& m)
|
||||
{
|
||||
return std::any_of(std::cbegin(m), std::cend(m), [](const auto& tagPair)
|
||||
{
|
||||
return tagPair.second.size() > 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TestTagReader : public ITagReader
|
||||
{
|
||||
public:
|
||||
static constexpr std::chrono::milliseconds trackDuration{ 180 };
|
||||
static constexpr std::size_t trackBitrate{ 128000 };
|
||||
static constexpr std::size_t trackBitsPerSample{ 16 };
|
||||
static constexpr std::size_t trackSampleRate{ 44000 };
|
||||
|
||||
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 ExtraUserTags = std::unordered_map<std::string_view, std::vector<std::string_view>>;
|
||||
TestTagReader(Tags&& tags, Performers&& performers = {}, ExtraUserTags&& extraUserTags = {})
|
||||
: _tags{ std::move(tags) }
|
||||
, _performers{ std::move(performers) }
|
||||
, _extraUserTags{ std::move(extraUserTags) }
|
||||
{
|
||||
_hasMultiValuedTags = tagMapHasMultiValuedTags(_tags)
|
||||
|| tagMapHasMultiValuedTags(_performers)
|
||||
|| tagMapHasMultiValuedTags(_extraUserTags);
|
||||
}
|
||||
|
||||
bool hasMultiValuedTags() const override
|
||||
{
|
||||
return _hasMultiValuedTags;
|
||||
}
|
||||
|
||||
void visitTagValues(TagType tag, TagValueVisitor visitor) const override
|
||||
{
|
||||
auto itValues{ _tags.find(tag) };
|
||||
if (itValues != std::cend(_tags))
|
||||
{
|
||||
for (std::string_view value : itValues->second)
|
||||
visitor(value);
|
||||
}
|
||||
}
|
||||
void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override
|
||||
{
|
||||
auto itValues{ _extraUserTags.find(tag) };
|
||||
if (itValues == std::cend(_extraUserTags))
|
||||
return;
|
||||
|
||||
for (std::string_view value : itValues->second)
|
||||
visitor(value);
|
||||
}
|
||||
|
||||
void visitPerformerTags(PerformerVisitor visitor) const override
|
||||
{
|
||||
for (const auto& [role, names] : _performers)
|
||||
{
|
||||
for (const auto& name : names)
|
||||
visitor(role, name);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasEmbeddedCover() const override { return false; };
|
||||
|
||||
std::chrono::milliseconds getDuration() const override { return trackDuration; }
|
||||
std::size_t getBitrate() const override { return trackBitrate; }
|
||||
std::size_t getBitsPerSample() const override { return trackBitsPerSample; }
|
||||
std::size_t getSampleRate() const override { return trackSampleRate; }
|
||||
|
||||
private:
|
||||
const Tags _tags;
|
||||
const Performers _performers;
|
||||
const ExtraUserTags _extraUserTags;
|
||||
bool _hasMultiValuedTags;
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
#include "ScanStepScanFiles.hpp"
|
||||
|
||||
#include "metadata/IParser.hpp"
|
||||
#include "database/Artist.hpp"
|
||||
#include "database/Cluster.hpp"
|
||||
#include "database/Db.hpp"
|
||||
@@ -29,15 +28,17 @@
|
||||
#include "database/Track.hpp"
|
||||
#include "database/TrackFeatures.hpp"
|
||||
#include "database/TrackArtistLink.hpp"
|
||||
#include "metadata/Exception.hpp"
|
||||
#include "metadata/IParser.hpp"
|
||||
#include "utils/Exception.hpp"
|
||||
#include "utils/IConfig.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
#include "utils/Path.hpp"
|
||||
|
||||
using namespace Database;
|
||||
|
||||
namespace Scanner
|
||||
{
|
||||
using namespace Database;
|
||||
|
||||
namespace
|
||||
{
|
||||
Artist::pointer createArtist(Session& session, const MetaData::Artist& artistInfo)
|
||||
@@ -179,25 +180,34 @@ namespace Scanner
|
||||
return Release::pointer{};
|
||||
}
|
||||
|
||||
std::vector<Cluster::pointer> getOrCreateClusters(Session& session, const MetaData::Tags& tags)
|
||||
std::vector<Cluster::pointer> getOrCreateClusters(Session& session, const MetaData::Track& track)
|
||||
{
|
||||
std::vector<Cluster::pointer> clusters;
|
||||
|
||||
for (const auto& [tag, values] : tags)
|
||||
auto getOrCreateClusters {[&](std::string tag, std::span<const std::string> values)
|
||||
{
|
||||
auto clusterType = ClusterType::find(session, tag);
|
||||
if (!clusterType)
|
||||
clusterType = session.create<ClusterType>(tag);
|
||||
|
||||
for (const auto& clusterName : values)
|
||||
for (const auto& value : values)
|
||||
{
|
||||
auto cluster = clusterType->getCluster(clusterName);
|
||||
auto cluster{ clusterType->getCluster(value) };
|
||||
if (!cluster)
|
||||
cluster = session.create<Cluster>(clusterType, clusterName);
|
||||
cluster = session.create<Cluster>(clusterType, value);
|
||||
|
||||
clusters.push_back(cluster);
|
||||
}
|
||||
}
|
||||
}};
|
||||
|
||||
// TODO: migrate these fields in dedicated tables in DB
|
||||
getOrCreateClusters("GENRE", track.genres);
|
||||
getOrCreateClusters("MOOD", track.genres);
|
||||
getOrCreateClusters("LANGUAGE", track.languages);
|
||||
getOrCreateClusters("GROUPING", track.groupings);
|
||||
|
||||
for (const auto& [tag, values] : track.userExtraTags)
|
||||
getOrCreateClusters(tag, values);
|
||||
|
||||
return clusters;
|
||||
}
|
||||
@@ -232,7 +242,7 @@ namespace Scanner
|
||||
, _scanContextRunner{ _scanContext, threadCount }
|
||||
{}
|
||||
|
||||
void ScanStepScanFiles::MetadataScanQueue::pushScanRequest(const std::filesystem::path path)
|
||||
void ScanStepScanFiles::MetadataScanQueue::pushScanRequest(const std::filesystem::path& path)
|
||||
{
|
||||
{
|
||||
std::scoped_lock lock{ _mutex };
|
||||
@@ -241,12 +251,21 @@ namespace Scanner
|
||||
|
||||
_scanContext.post([=, this]
|
||||
{
|
||||
std::optional<MetaData::Track> trackMetaData{ _metadataParser.parse(path) };
|
||||
std::unique_ptr<MetaData::Track> track;
|
||||
|
||||
try
|
||||
{
|
||||
track = _metadataParser.parse(path);
|
||||
}
|
||||
catch(const MetaData::Exception& e)
|
||||
{
|
||||
LMS_LOG(DBUPDATER, INFO, "Failed to parse '" << path.string() << "'");
|
||||
}
|
||||
|
||||
{
|
||||
std::scoped_lock lock{ _mutex };
|
||||
|
||||
_scanResults.emplace_back(std::make_unique<MetaDataScanResult>(path, std::move(trackMetaData)));
|
||||
_scanResults.emplace_back(MetaDataScanResult{ std::move(path), std::move(track) });
|
||||
_ongoingScanCount -= 1;
|
||||
}
|
||||
_condVar.notify_all();
|
||||
@@ -259,7 +278,7 @@ namespace Scanner
|
||||
return _scanResults.size();
|
||||
}
|
||||
|
||||
size_t ScanStepScanFiles::MetadataScanQueue::popResults(std::vector<std::unique_ptr<MetaDataScanResult>>& results, std::size_t maxCount)
|
||||
size_t ScanStepScanFiles::MetadataScanQueue::popResults(std::vector<MetaDataScanResult>& results, std::size_t maxCount)
|
||||
{
|
||||
results.clear();
|
||||
results.reserve(maxCount);
|
||||
@@ -285,7 +304,7 @@ namespace Scanner
|
||||
|
||||
ScanStepScanFiles::ScanStepScanFiles(InitParams& initParams)
|
||||
: ScanStepBase{ initParams }
|
||||
, _metadataParser{ MetaData::createParser(MetaData::ParserType::TagLib, getParserReadStyle()) } // For now, always use TagLib
|
||||
, _metadataParser{ MetaData::createParser(MetaData::ParserBackend::TagLib, getParserReadStyle()) } // For now, always use TagLib
|
||||
, _metadataScanQueue{ *_metadataParser, getScanMetaDataThreadCount() }
|
||||
{
|
||||
LMS_LOG(DBUPDATER, INFO, "Using " << _metadataScanQueue.getThreadCount() << " thread(s) for scanning file metadata");
|
||||
@@ -300,9 +319,11 @@ namespace Scanner
|
||||
std::vector<std::string> tagsToParse{ _extraTagsToParse };
|
||||
tagsToParse.insert(std::end(tagsToParse), std::cbegin(_settings.extraTags), std::cend(_settings.extraTags));
|
||||
_metadataParser->setUserExtraTags(tagsToParse);
|
||||
_metadataParser->setArtistTagDelimiters(_settings.artistTagDelimiters);
|
||||
_metadataParser->setDefaultTagDelimiters(_settings.defaultTagDelimiters);
|
||||
}
|
||||
|
||||
std::vector<std::unique_ptr<MetaDataScanResult>> scanResults;
|
||||
std::vector<MetaDataScanResult> scanResults;
|
||||
context.currentStepStats.totalElems = context.stats.filesScanned;
|
||||
|
||||
for (const ScannerSettings::MediaLibraryInfo& mediaLibrary : _settings.mediaLibraries)
|
||||
@@ -400,21 +421,21 @@ namespace Scanner
|
||||
return true; // need to scan
|
||||
}
|
||||
|
||||
void ScanStepScanFiles::processMetaDataScanResults(ScanContext& context, std::span<const std::unique_ptr<MetaDataScanResult>> scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo)
|
||||
void ScanStepScanFiles::processMetaDataScanResults(ScanContext& context, std::span<const MetaDataScanResult> scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo)
|
||||
{
|
||||
Database::Session& dbSession{ _db.getTLSSession() };
|
||||
auto transaction{ dbSession.createWriteTransaction() };
|
||||
|
||||
for (const auto& scanResult : scanResults)
|
||||
for (const MetaDataScanResult& scanResult : scanResults)
|
||||
{
|
||||
if (_abortScan)
|
||||
return;
|
||||
|
||||
if (scanResult->trackMetaData)
|
||||
if (scanResult.trackMetaData)
|
||||
{
|
||||
context.stats.scans++;
|
||||
|
||||
processFileMetaData(context, scanResult->path, *scanResult->trackMetaData, libraryInfo);
|
||||
processFileMetaData(context, scanResult.path, *scanResult.trackMetaData, libraryInfo);
|
||||
|
||||
// optimize the database during scan (if we import a very large database, it may be too late to do it once at end)
|
||||
if ((context.stats.scans % 1'000) == 0)
|
||||
@@ -422,7 +443,7 @@ namespace Scanner
|
||||
}
|
||||
else
|
||||
{
|
||||
context.stats.errors.emplace_back(scanResult->path, ScanErrorType::CannotParseFile);
|
||||
context.stats.errors.emplace_back(scanResult.path, ScanErrorType::CannotParseFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -583,7 +604,7 @@ namespace Scanner
|
||||
track.modify()->setTotalTrack(trackMetadata.medium ? trackMetadata.medium->trackCount : std::nullopt);
|
||||
track.modify()->setReleaseReplayGain(trackMetadata.medium ? trackMetadata.medium->replayGain : std::nullopt);
|
||||
track.modify()->setDiscSubtitle(trackMetadata.medium ? trackMetadata.medium->name : "");
|
||||
track.modify()->setClusters(getOrCreateClusters(dbSession, trackMetadata.userExtraTags));
|
||||
track.modify()->setClusters(getOrCreateClusters(dbSession, trackMetadata));
|
||||
track.modify()->setLastWriteTime(lastWriteTime);
|
||||
track.modify()->setName(title);
|
||||
track.modify()->setDuration(trackMetadata.duration);
|
||||
|
||||
@@ -47,13 +47,13 @@ namespace Scanner
|
||||
struct MetaDataScanResult
|
||||
{
|
||||
std::filesystem::path path;
|
||||
std::optional<MetaData::Track> trackMetaData;
|
||||
std::unique_ptr<MetaData::Track> trackMetaData;
|
||||
};
|
||||
void processMetaDataScanResults(ScanContext& context, std::span<const std::unique_ptr<MetaDataScanResult>> scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo);
|
||||
void processMetaDataScanResults(ScanContext& context, std::span<const MetaDataScanResult> scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo);
|
||||
void processFileMetaData(ScanContext& context, const std::filesystem::path& file, const MetaData::Track& trackMetadata, const ScannerSettings::MediaLibraryInfo& libraryInfo);
|
||||
|
||||
std::unique_ptr<MetaData::IParser> _metadataParser;
|
||||
const std::vector<std::string> _extraTagsToParse{ "GENRE", "MOOD", "LANGUAGE", "ALBUMGROUPING" };
|
||||
const std::vector<std::string> _extraTagsToParse;
|
||||
|
||||
class MetadataScanQueue
|
||||
{
|
||||
@@ -62,10 +62,10 @@ namespace Scanner
|
||||
|
||||
std::size_t getThreadCount() const { return _scanContextRunner.getThreadCount(); }
|
||||
|
||||
void pushScanRequest(const std::filesystem::path path);
|
||||
void pushScanRequest(const std::filesystem::path& path);
|
||||
|
||||
std::size_t getResultsCount() const;
|
||||
size_t popResults(std::vector<std::unique_ptr<MetaDataScanResult>>& results, std::size_t maxCount);
|
||||
size_t popResults(std::vector<MetaDataScanResult>& results, std::size_t maxCount);
|
||||
|
||||
void wait(std::size_t maxScanRequestCount = 0); // wait until ongoing scan request count <= maxScanRequestCount
|
||||
|
||||
@@ -76,11 +76,11 @@ namespace Scanner
|
||||
|
||||
mutable std::mutex _mutex ;
|
||||
std::size_t _ongoingScanCount{};
|
||||
std::deque<std::unique_ptr<MetaDataScanResult>> _scanResults;
|
||||
std::deque<MetaDataScanResult> _scanResults;
|
||||
std::condition_variable _condVar;
|
||||
};
|
||||
MetadataScanQueue _metadataScanQueue;
|
||||
|
||||
std::deque<std::unique_ptr<MetaDataScanResult>> _metaDataScanResults;
|
||||
std::deque<MetaDataScanResult> _metaDataScanResults;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,6 +383,9 @@ namespace Scanner
|
||||
const auto& tags{ scanSettings->getExtraTagsToScan() };
|
||||
std::transform(std::cbegin(tags), std::cend(tags), std::back_inserter(newSettings.extraTags), [](std::string_view tag) { return std::string{ tag };});
|
||||
}
|
||||
|
||||
newSettings.artistTagDelimiters = scanSettings->getArtistTagDelimiters();
|
||||
newSettings.defaultTagDelimiters = scanSettings->getDefaultTagDelimiters();
|
||||
}
|
||||
|
||||
return newSettings;
|
||||
|
||||
@@ -30,31 +30,24 @@ namespace Scanner
|
||||
{
|
||||
struct ScannerSettings
|
||||
{
|
||||
std::size_t scanVersion{};
|
||||
Wt::WTime startTime;
|
||||
Database::ScanSettings::UpdatePeriod updatePeriod{ Database::ScanSettings::UpdatePeriod::Never };
|
||||
std::vector<std::filesystem::path> supportedExtensions;
|
||||
bool skipDuplicateMBID{};
|
||||
std::vector<std::string> extraTags;
|
||||
std::size_t scanVersion{};
|
||||
Wt::WTime startTime;
|
||||
Database::ScanSettings::UpdatePeriod updatePeriod{ Database::ScanSettings::UpdatePeriod::Never };
|
||||
std::vector<std::filesystem::path> supportedExtensions;
|
||||
bool skipDuplicateMBID{};
|
||||
std::vector<std::string> extraTags;
|
||||
std::vector<std::string> artistTagDelimiters;
|
||||
std::vector<std::string> defaultTagDelimiters;
|
||||
|
||||
struct MediaLibraryInfo
|
||||
{
|
||||
Database::MediaLibraryId id;
|
||||
std::filesystem::path rootDirectory;
|
||||
|
||||
bool operator==(const MediaLibraryInfo& other) const { return id == other.id && rootDirectory == other.rootDirectory; }
|
||||
bool operator<=>(const MediaLibraryInfo& other) const = default;
|
||||
};
|
||||
std::vector<MediaLibraryInfo> mediaLibraries;
|
||||
std::vector<MediaLibraryInfo> mediaLibraries;
|
||||
|
||||
bool operator==(const ScannerSettings& rhs) const
|
||||
{
|
||||
return scanVersion == rhs.scanVersion
|
||||
&& startTime == rhs.startTime
|
||||
&& updatePeriod == rhs.updatePeriod
|
||||
&& supportedExtensions == rhs.supportedExtensions
|
||||
&& mediaLibraries == rhs.mediaLibraries
|
||||
&& skipDuplicateMBID == rhs.skipDuplicateMBID
|
||||
&& extraTags == rhs.extraTags;
|
||||
}
|
||||
bool operator<=>(const ScannerSettings& rhs) const = default;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace StringUtils
|
||||
std::optional<API::Subsonic::ProtocolVersion> readAs(std::string_view str)
|
||||
{
|
||||
// Expects "X.Y.Z"
|
||||
const auto numbers{ StringUtils::splitString(str, ".") };
|
||||
const auto numbers{ StringUtils::splitString(str, '.') };
|
||||
if (numbers.size() < 2 || numbers.size() > 3)
|
||||
return std::nullopt;
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ namespace StringUtils
|
||||
template<>
|
||||
std::optional<Database::ArtistId> readAs(std::string_view str)
|
||||
{
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, "-") };
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, '-') };
|
||||
if (values.size() != 2)
|
||||
return std::nullopt;
|
||||
|
||||
@@ -88,7 +88,7 @@ namespace StringUtils
|
||||
template<>
|
||||
std::optional<Database::ReleaseId> readAs(std::string_view str)
|
||||
{
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, "-") };
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, '-') };
|
||||
if (values.size() != 2)
|
||||
return std::nullopt;
|
||||
|
||||
@@ -113,7 +113,7 @@ namespace StringUtils
|
||||
template<>
|
||||
std::optional<Database::TrackId> readAs(std::string_view str)
|
||||
{
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, "-") };
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, '-') };
|
||||
if (values.size() != 2)
|
||||
return std::nullopt;
|
||||
|
||||
@@ -129,7 +129,7 @@ namespace StringUtils
|
||||
template<>
|
||||
std::optional<Database::TrackListId> readAs(std::string_view str)
|
||||
{
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, "-") };
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, '-') };
|
||||
if (values.size() != 2)
|
||||
return std::nullopt;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ namespace API::Subsonic
|
||||
if (context.clientInfo.name == "Symfonium")
|
||||
query = StringUtils::stringTrim(query, "\"");
|
||||
|
||||
std::vector<std::string_view> keywords{ StringUtils::splitString(query, " ") };
|
||||
std::vector<std::string_view> keywords{ StringUtils::splitString(query, ' ') };
|
||||
|
||||
// Optional params
|
||||
std::size_t artistCount{ getParameterAs<std::size_t>(context.parameters, "artistCount").value_or(20) };
|
||||
|
||||
@@ -68,6 +68,7 @@ Log::Log(ILogger& logger, Module module, Severity severity)
|
||||
|
||||
Log::~Log()
|
||||
{
|
||||
assert(_logger.isSeverityActive(_severity));
|
||||
_logger.processLog(*this);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <cassert>
|
||||
#include <thread>
|
||||
|
||||
#include "utils/StreamLogger.hpp"
|
||||
@@ -29,7 +30,7 @@ StreamLogger::StreamLogger(std::ostream& os, EnumSet<Severity> severities)
|
||||
|
||||
void StreamLogger::processLog(const Log& log)
|
||||
{
|
||||
if (_severities.contains(log.getSeverity()))
|
||||
_os << std::this_thread::get_id() << " [" << getSeverityName(log.getSeverity()) << "] [" << getModuleName(log.getModule()) << "] " << log.getMessage() << std::endl;
|
||||
assert(isSeverityActive(log.getSeverity()));
|
||||
_os << std::this_thread::get_id() << " [" << getSeverityName(log.getSeverity()) << "] [" << getModuleName(log.getModule()) << "] " << log.getMessage() << std::endl;
|
||||
}
|
||||
|
||||
|
||||
+131
-67
@@ -23,17 +23,13 @@
|
||||
#include <iomanip>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/algorithm/string/split.hpp>
|
||||
#include <boost/algorithm/string/join.hpp>
|
||||
#include <boost/algorithm/string.hpp>
|
||||
|
||||
#include <Wt/WDateTime.h>
|
||||
#include <Wt/WDate.h>
|
||||
|
||||
namespace StringUtils
|
||||
{
|
||||
|
||||
namespace
|
||||
namespace details
|
||||
{
|
||||
constexpr std::pair<char, std::string_view> jsEscapeChars[]
|
||||
{
|
||||
@@ -87,32 +83,23 @@ namespace StringUtils
|
||||
os << c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool readList(const std::string& str, const std::string& separators, std::list<std::string>& results)
|
||||
{
|
||||
std::string curStr;
|
||||
|
||||
for (char c : str)
|
||||
template <typename StringType>
|
||||
std::string joinStrings(std::span<const StringType> strings, std::string_view delimiter)
|
||||
{
|
||||
if (separators.find(c) != std::string::npos) {
|
||||
if (!curStr.empty()) {
|
||||
results.push_back(curStr);
|
||||
curStr.clear();
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (curStr.empty() && std::isspace(c))
|
||||
continue;
|
||||
std::string res;
|
||||
bool first{ true };
|
||||
|
||||
curStr.push_back(c);
|
||||
for (const StringType& str : strings)
|
||||
{
|
||||
if (!first)
|
||||
res += delimiter;
|
||||
res += str;
|
||||
first = false;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
if (!curStr.empty())
|
||||
results.push_back(curStr);
|
||||
|
||||
return !str.empty();
|
||||
}
|
||||
|
||||
template<>
|
||||
@@ -138,57 +125,102 @@ namespace StringUtils
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<std::string> splitStringCopy(std::string_view string, std::string_view separators)
|
||||
std::vector<std::string_view> splitString(std::string_view str, char separator)
|
||||
{
|
||||
std::string str{ stringTrim(string, separators) };
|
||||
|
||||
std::vector<std::string> res;
|
||||
boost::algorithm::split(res, str, boost::is_any_of(separators), boost::token_compress_on);
|
||||
|
||||
return res;
|
||||
return splitString(str, std::string_view{ &separator, 1 });
|
||||
}
|
||||
|
||||
std::vector<std::string_view> splitString(std::string_view str, std::string_view separators)
|
||||
std::vector<std::string_view> splitString(std::string_view str, std::string_view separator)
|
||||
{
|
||||
std::vector<std::string_view> res;
|
||||
|
||||
std::string_view::size_type strBegin{};
|
||||
if (separator.empty())
|
||||
return { str };
|
||||
|
||||
while ((strBegin = str.find_first_not_of(separators, strBegin)) != std::string_view::npos)
|
||||
size_t pos{};
|
||||
size_t found{ str.find(separator) };
|
||||
|
||||
while (found != std::string_view::npos)
|
||||
{
|
||||
auto strEnd{ str.find_first_of(separators, strBegin + 1) };
|
||||
if (strEnd == std::string_view::npos)
|
||||
res.push_back(str.substr(pos, found - pos));
|
||||
pos = found + separator.size();
|
||||
found = str.find(separator, pos);
|
||||
}
|
||||
|
||||
res.push_back(str.substr(pos));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::string joinStrings(std::span<const std::string_view> strings, std::string_view delimiter)
|
||||
{
|
||||
return details::joinStrings(strings, delimiter);
|
||||
}
|
||||
|
||||
std::string joinStrings(std::span<const std::string> strings, std::string_view delimiter)
|
||||
{
|
||||
return details::joinStrings(strings, delimiter);
|
||||
}
|
||||
|
||||
std::string joinStrings(std::span<const std::string> strings, char delimiter)
|
||||
{
|
||||
return details::joinStrings(strings, std::string_view{ &delimiter, 1 });
|
||||
}
|
||||
|
||||
std::string joinStrings(std::span<const std::string_view> strings, char delimiter)
|
||||
{
|
||||
return details::joinStrings(strings, std::string_view{ &delimiter, 1 });
|
||||
}
|
||||
|
||||
std::string escapeAndJoinStrings(std::span<const std::string_view> strings, char delimiter, char escapeChar)
|
||||
{
|
||||
std::string result;
|
||||
for (const std::string_view str : strings)
|
||||
{
|
||||
if (!result.empty())
|
||||
result.push_back(delimiter);
|
||||
|
||||
for (char c : str)
|
||||
{
|
||||
res.push_back(str.substr(strBegin, str.size() - strBegin));
|
||||
break;
|
||||
if (c == delimiter || c == escapeChar)
|
||||
result.push_back(escapeChar);
|
||||
|
||||
result.push_back(c);
|
||||
}
|
||||
|
||||
res.push_back(str.substr(strBegin, strEnd - strBegin));
|
||||
strBegin = strEnd + 1;
|
||||
}
|
||||
|
||||
return res;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string joinStrings(const std::vector<std::string_view>& strings, std::string_view delimiter)
|
||||
std::vector<std::string> splitEscapedStrings(std::string_view str, char delimiter, char escapeChar)
|
||||
{
|
||||
std::string res;
|
||||
bool first{ true };
|
||||
std::vector<std::string> result;
|
||||
std::string current;
|
||||
bool escaped{};
|
||||
|
||||
for (std::string_view str : strings)
|
||||
for (char c : str)
|
||||
{
|
||||
if (!first)
|
||||
res += delimiter;
|
||||
res += str;
|
||||
first = false;
|
||||
if (escaped) {
|
||||
current.push_back(c);
|
||||
escaped = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == delimiter)
|
||||
{
|
||||
result.push_back(std::move(current));
|
||||
current.clear();
|
||||
}
|
||||
else if (c == escapeChar)
|
||||
escaped = true;
|
||||
else
|
||||
current.push_back(c);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
if (!current.empty())
|
||||
result.push_back(std::move(current));
|
||||
|
||||
std::string joinStrings(const std::vector<std::string>& strings, const std::string& delimiter)
|
||||
{
|
||||
return boost::algorithm::join(strings, delimiter);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string_view stringTrim(std::string_view str, std::string_view whitespaces)
|
||||
@@ -237,7 +269,7 @@ namespace StringUtils
|
||||
return res;
|
||||
}
|
||||
|
||||
std::string bufferToString(const std::vector<unsigned char>& data)
|
||||
std::string bufferToString(std::span<const unsigned char> data)
|
||||
{
|
||||
std::ostringstream oss;
|
||||
|
||||
@@ -253,7 +285,7 @@ namespace StringUtils
|
||||
{
|
||||
if (strA.size() != strB.size())
|
||||
return false;
|
||||
|
||||
|
||||
for (std::size_t i{}; i < strA.size(); ++i)
|
||||
{
|
||||
if (std::tolower(strA[i]) != std::tolower(strB[i]))
|
||||
@@ -293,22 +325,22 @@ namespace StringUtils
|
||||
|
||||
std::string jsEscape(std::string_view str)
|
||||
{
|
||||
return escape(str, jsEscapeChars);
|
||||
return details::escape(str, details::jsEscapeChars);
|
||||
}
|
||||
|
||||
void writeJSEscapedString(std::ostream& os, std::string_view str)
|
||||
{
|
||||
writeEscapedString(os, str, jsEscapeChars);
|
||||
details::writeEscapedString(os, str, details::jsEscapeChars);
|
||||
}
|
||||
|
||||
|
||||
std::string jsonEscape(std::string_view str)
|
||||
{
|
||||
return escape(str, jsonEscapeChars);
|
||||
return details::escape(str, details::jsonEscapeChars);
|
||||
}
|
||||
|
||||
void writeJsonEscapedString(std::ostream& os, std::string_view str)
|
||||
{
|
||||
writeEscapedString(os, str, jsonEscapeChars);
|
||||
details::writeEscapedString(os, str, details::jsonEscapeChars);
|
||||
}
|
||||
|
||||
std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar)
|
||||
@@ -327,9 +359,41 @@ namespace StringUtils
|
||||
return res;
|
||||
}
|
||||
|
||||
bool stringEndsWith(const std::string& str, const std::string& ending)
|
||||
std::string unescapeString(std::string_view str, char escapeChar)
|
||||
{
|
||||
return boost::algorithm::ends_with(str, ending);
|
||||
std::string res;
|
||||
res.reserve(str.size());
|
||||
|
||||
bool escaped{};
|
||||
|
||||
for (char c : str)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
res += c;
|
||||
escaped = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == escapeChar)
|
||||
escaped = true;
|
||||
else
|
||||
res += c;
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped)
|
||||
res += escapeChar;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
bool stringEndsWith(std::string_view str, std::string_view ending)
|
||||
{
|
||||
if (str.length() < ending.length())
|
||||
return false;
|
||||
|
||||
return str.substr(str.length() - ending.length()) == ending;
|
||||
}
|
||||
|
||||
std::optional<std::string> stringFromHex(const std::string& str)
|
||||
|
||||
@@ -25,11 +25,12 @@
|
||||
class StreamLogger final : public ILogger
|
||||
{
|
||||
public:
|
||||
static constexpr EnumSet<Severity> allSeverities{ Severity::FATAL, Severity::ERROR, Severity::WARNING, Severity::INFO, Severity::DEBUG };
|
||||
static constexpr EnumSet<Severity> defaultSeverities{ Severity::FATAL, Severity::ERROR, Severity::WARNING, Severity::INFO };
|
||||
|
||||
StreamLogger(std::ostream& oss, EnumSet<Severity> severities = defaultSeverities);
|
||||
|
||||
bool isSeverityActive(Severity) const override { return true; }
|
||||
bool isSeverityActive(Severity severity) const override { return _severities.contains(severity); }
|
||||
void processLog(const Log& log) override;
|
||||
|
||||
private:
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include <initializer_list>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <sstream>
|
||||
@@ -35,13 +36,18 @@ namespace Wt
|
||||
class WDateTime;
|
||||
}
|
||||
|
||||
namespace StringUtils {
|
||||
namespace StringUtils
|
||||
{
|
||||
[[nodiscard]] std::vector<std::string_view> splitString(std::string_view string, char separator);
|
||||
[[nodiscard]] std::vector<std::string_view> splitString(std::string_view string, std::string_view separator);
|
||||
|
||||
[[nodiscard]] std::vector<std::string> splitStringCopy(std::string_view string, std::string_view separators);
|
||||
[[nodiscard]] std::vector<std::string_view> splitString(std::string_view string, std::string_view separators);
|
||||
[[nodiscard]] std::string joinStrings(std::span<const std::string> strings, std::string_view delimiter);
|
||||
[[nodiscard]] std::string joinStrings(std::span<const std::string_view> strings, std::string_view delimiter);
|
||||
[[nodiscard]] std::string joinStrings(std::span<const std::string> strings, char delimiter);
|
||||
[[nodiscard]] std::string joinStrings(std::span<const std::string_view> strings, char delimiter);
|
||||
|
||||
[[nodiscard]] std::string joinStrings(const std::vector<std::string_view>& strings, std::string_view delimiter);
|
||||
[[nodiscard]] std::string joinStrings(const std::vector<std::string>& strings, const std::string& delimiter);
|
||||
[[nodiscard]] std::string escapeAndJoinStrings(std::span<const std::string_view> strings, char delimiter, char escapeChar);
|
||||
[[nodiscard]] std::vector<std::string> splitEscapedStrings(std::string_view string, char delimiter, char escapeChar);
|
||||
|
||||
[[nodiscard]] std::string_view stringTrim(std::string_view str, std::string_view whitespaces = " \t");
|
||||
[[nodiscard]] std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t");
|
||||
@@ -50,7 +56,7 @@ namespace StringUtils {
|
||||
void stringToLower(std::string& str);
|
||||
[[nodiscard]] std::string stringToUpper(const std::string& str);
|
||||
|
||||
[[nodiscard]] std::string bufferToString(const std::vector<unsigned char>& data);
|
||||
[[nodiscard]] std::string bufferToString(std::span<const unsigned char> data);
|
||||
|
||||
[[nodiscard]] bool stringCaseInsensitiveEqual(std::string_view strA, std::string_view strB);
|
||||
|
||||
@@ -86,8 +92,9 @@ namespace StringUtils {
|
||||
void writeJsonEscapedString(std::ostream& os, std::string_view str);
|
||||
|
||||
[[nodiscard]] std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar);
|
||||
[[nodiscard]] std::string unescapeString(std::string_view str, char escapeChar);
|
||||
|
||||
[[nodiscard]] bool stringEndsWith(const std::string& str, const std::string& ending);
|
||||
[[nodiscard]] bool stringEndsWith(std::string_view str, std::string_view ending);
|
||||
|
||||
[[nodiscard]] std::optional<std::string> stringFromHex(const std::string& str);
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ public:
|
||||
|
||||
std::string_view getAsString() const { return _value; }
|
||||
|
||||
bool operator<=>(const UUID&) const = default;
|
||||
|
||||
private:
|
||||
UUID(std::string_view value);
|
||||
std::string _value;
|
||||
|
||||
+132
-60
@@ -24,80 +24,77 @@
|
||||
#include <Wt/WTime.h>
|
||||
#include "utils/String.hpp"
|
||||
|
||||
TEST(StringUtils, splitString)
|
||||
|
||||
TEST(StringUtils, splitString_charDelim)
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
const std::string test{ "a" };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, "") };
|
||||
ASSERT_EQ(strings.size(), 1);
|
||||
EXPECT_EQ(strings.front(), "a");
|
||||
}
|
||||
std::string_view input;
|
||||
char delimiter;
|
||||
std::vector<std::string_view> expectedOutput;
|
||||
};
|
||||
|
||||
TestCase tests[]
|
||||
{
|
||||
const std::string test{ "a b" };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, "|") };
|
||||
ASSERT_EQ(strings.size(), 1);
|
||||
EXPECT_EQ(strings.front(), "a b");
|
||||
}
|
||||
{"abc", '-', {"abc"}},
|
||||
{"a", '-', {"a"}},
|
||||
{"", '-', {""}},
|
||||
{"a-b-c", '-', {"a", "b", "c"}},
|
||||
{"a|b|c", '|', {"a", "b", "c"}},
|
||||
{"a;b;c", ';', {"a", "b", "c"}},
|
||||
{";b;c", ';', {"", "b", "c"}},
|
||||
{" ;b;c", ';', {" ", "b", "c"}},
|
||||
{" ;;c", ';', {" ", "", "c"}},
|
||||
{" ; ;c", ';', {" ", " ", "c"}},
|
||||
{"a;b; ", ';', {"a", "b", " "}},
|
||||
{"a;b", ';', {"a", "b"}},
|
||||
{";b", ';', {"", "b"}},
|
||||
{";", ';', {"", ""}},
|
||||
{";;", ';', {"", "", ""}},
|
||||
{";;;", ';', {"", "", "", ""}},
|
||||
{";;a;;b;;", ';', {"", "", "a", "", "b", "", ""}},
|
||||
{"a b", ' ', {"a", "b"}},
|
||||
{"", ' ', {""}},
|
||||
{"a-b|c", '-', {"a","b|c"}},
|
||||
{"a|b-c", '-', {"a|b", "c"}},
|
||||
{"test=foo bar", '=', {"test", "foo bar"}},
|
||||
};
|
||||
|
||||
for (const TestCase& test : tests)
|
||||
{
|
||||
const std::string test{ " a" };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, " ") };
|
||||
ASSERT_EQ(strings.size(), 1);
|
||||
EXPECT_EQ(strings.front(), "a");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string test{ "a " };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, " ") };
|
||||
ASSERT_EQ(strings.size(), 1);
|
||||
EXPECT_EQ(strings.front(), "a");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string test{ "a b" };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, " ") };
|
||||
ASSERT_EQ(strings.size(), 2);
|
||||
EXPECT_EQ(strings.front(), "a");
|
||||
EXPECT_EQ(strings.back(), "b");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string test{ "a b,c|defgh " };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, " ,|") };
|
||||
ASSERT_EQ(strings.size(), 4);
|
||||
EXPECT_EQ(strings[0], "a");
|
||||
EXPECT_EQ(strings[1], "b");
|
||||
EXPECT_EQ(strings[2], "c");
|
||||
EXPECT_EQ(strings[3], "defgh");
|
||||
const std::vector<std::string_view> res{ StringUtils::splitString(test.input, test.delimiter) };
|
||||
EXPECT_EQ(res, test.expectedOutput) << "Input = '" << test.input << "', delims = '" << test.delimiter << "'";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST(StringUtils, splitStringCopy)
|
||||
TEST(StringUtils, splitString_stringDelim)
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
const std::string test{ "test=foo" };
|
||||
|
||||
const std::vector<std::string> strings{ StringUtils::splitStringCopy(test, "=") };
|
||||
ASSERT_EQ(strings.size(), 2);
|
||||
EXPECT_EQ(strings[0], "test");
|
||||
EXPECT_EQ(strings[1], "foo");
|
||||
}
|
||||
std::string_view input;
|
||||
std::string_view delimiter;
|
||||
std::vector<std::string_view> expectedOutput;
|
||||
};
|
||||
|
||||
TestCase tests[]
|
||||
{
|
||||
const std::string test{ "test=foo bar" };
|
||||
{"abc", "", {"abc"}},
|
||||
{"abc", "-", {"abc"}},
|
||||
{"abc", "b", {"a", "c"}},
|
||||
{"ab/cd", "/", {"ab", "cd"}},
|
||||
{"ab/cd", "/ ", {"ab/cd"}},
|
||||
{"ab/cd", " /", {"ab/cd"}},
|
||||
{"ab /cd", " /", {"ab", "cd"}},
|
||||
{"ab/ cd", "/ ", {"ab", "cd"}},
|
||||
{"ab / cd", " / ", {"ab", "cd"}},
|
||||
{"ab/cd", " / ", {"ab/cd"}},
|
||||
{"ab/cd / ", " / ", {"ab/cd", ""}},
|
||||
};
|
||||
|
||||
const std::vector<std::string> strings{ StringUtils::splitStringCopy(test, "=") };
|
||||
ASSERT_EQ(strings.size(), 2);
|
||||
EXPECT_EQ(strings[0], "test");
|
||||
EXPECT_EQ(strings[1], "foo bar");
|
||||
for (const TestCase& test : tests)
|
||||
{
|
||||
const std::vector<std::string_view> res{ StringUtils::splitString(test.input, test.delimiter) };
|
||||
EXPECT_EQ(res, test.expectedOutput) << "Input = '" << test.input << "', delims = '" << test.delimiter << "'";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +125,58 @@ TEST(StringUtils, joinStrings)
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StringUtils, escapeAndJoinStrings)
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
std::vector<std::string_view> input;
|
||||
char delimiter;
|
||||
char escapeChar;
|
||||
std::string expectedOutput;
|
||||
};
|
||||
|
||||
TestCase tests[]
|
||||
{
|
||||
{{""}, ';', '\\', ""},
|
||||
{{";"}, ';', '\\', "\\;"},
|
||||
{{";;"}, ';', '\\', "\\;\\;"},
|
||||
{{"a;", "b"}, ';', '\\', "a\\;;b"},
|
||||
{{"a;", "b;"}, ';', '\\', "a\\;;b\\;"},
|
||||
};
|
||||
|
||||
for (const TestCase& test : tests)
|
||||
{
|
||||
const std::string str{ StringUtils::escapeAndJoinStrings(test.input, test.delimiter, test.escapeChar) };
|
||||
EXPECT_EQ(str, test.expectedOutput);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StringUtils, splitEscapedStrings)
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
std::string input;
|
||||
char delimiter;
|
||||
char escapeChar;
|
||||
std::vector<std::string> expectedOutput;
|
||||
};
|
||||
|
||||
TestCase tests[]
|
||||
{
|
||||
{"", ';', '\\', {}},
|
||||
{"\\;", ';', '\\', {";"}},
|
||||
{"\\;\\;", ';', '\\', {";;"}},
|
||||
{"a\\;;b", ';', '\\', {"a;", "b"}},
|
||||
{"a\\;;b\\;", ';', '\\', {"a;", "b;"}},
|
||||
};
|
||||
|
||||
for (const TestCase& test : tests)
|
||||
{
|
||||
const std::vector<std::string> str{ StringUtils::splitEscapedStrings(test.input, test.delimiter, test.escapeChar) };
|
||||
EXPECT_EQ(str, test.expectedOutput);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StringUtils, escapeJSString)
|
||||
{
|
||||
EXPECT_EQ(StringUtils::jsEscape(""), "");
|
||||
@@ -153,6 +202,17 @@ TEST(StringUtils, escapeString)
|
||||
EXPECT_EQ(StringUtils::escapeString("*a*", "*", '_'), "_*a_*");
|
||||
EXPECT_EQ(StringUtils::escapeString("*a|", "*|", '_'), "_*a_|");
|
||||
EXPECT_EQ(StringUtils::escapeString("**||", "*|", '_'), "_*_*_|_|");
|
||||
EXPECT_EQ(StringUtils::escapeString("one;two", ";", '\\'), "one\\;two");
|
||||
EXPECT_EQ(StringUtils::escapeString("one\\;two", ";", '\\'), "one\\\\;two");
|
||||
EXPECT_EQ(StringUtils::escapeString("one;", ";", '\\'), "one\\;");
|
||||
}
|
||||
|
||||
TEST(StringUtils, unescapeString)
|
||||
{
|
||||
EXPECT_EQ(StringUtils::unescapeString("one\\", '\\'), "one\\");
|
||||
EXPECT_EQ(StringUtils::unescapeString("\\\\one", '\\'), "\\one");
|
||||
EXPECT_EQ(StringUtils::unescapeString("one\\;two", '\\'), "one;two");
|
||||
EXPECT_EQ(StringUtils::unescapeString("one\\\\;two", '\\'), "one\\;two");
|
||||
}
|
||||
|
||||
TEST(StringUtils, readAs_bool)
|
||||
@@ -220,4 +280,16 @@ TEST(Stringutils, dateTime)
|
||||
{
|
||||
const Wt::WDateTime dateTime{ Wt::WDate {2020, 01, 03 }, Wt::WTime{9, 8, 11, 75} };
|
||||
EXPECT_EQ(StringUtils::toISO8601String(dateTime), "2020-01-03T09:08:11.075");
|
||||
}
|
||||
|
||||
TEST(StringUtils, stringEndsWith)
|
||||
{
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "Bar"));
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", ""));
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("", ""));
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "ar"));
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "FooBar"));
|
||||
EXPECT_FALSE(StringUtils::stringEndsWith("FooBar", "1FooBar"));
|
||||
EXPECT_FALSE(StringUtils::stringEndsWith("FooBar", "1FooBar"));
|
||||
EXPECT_FALSE(StringUtils::stringEndsWith("FooBar", "R"));
|
||||
}
|
||||
@@ -45,6 +45,24 @@ namespace UserInterface
|
||||
|
||||
namespace
|
||||
{
|
||||
class TagDelimitersValidator : public Wt::WValidator
|
||||
{
|
||||
private:
|
||||
Wt::WValidator::Result validate(const Wt::WString& input) const override
|
||||
{
|
||||
if (input.empty())
|
||||
return Wt::WValidator::Result{ Wt::ValidationState::Valid };
|
||||
|
||||
std::string inputStr{ input.toUTF8() };
|
||||
if (std::all_of(std::cbegin(inputStr), std::cend(inputStr), [](char c) { return std::isspace(c); }))
|
||||
return Wt::WValidator::Result{ Wt::ValidationState::Invalid, Wt::WString::tr("Lms.Admin.Database.tag-delimiter-must-not-contain-only-spaces") };
|
||||
|
||||
return Wt::WValidator::Result{ Wt::ValidationState::Valid };
|
||||
}
|
||||
|
||||
std::string javaScriptValidate() const override { return {}; }
|
||||
};
|
||||
|
||||
class DatabaseSettingsModel : public Wt::WFormModel
|
||||
{
|
||||
public:
|
||||
@@ -52,10 +70,12 @@ namespace UserInterface
|
||||
static inline constexpr Field UpdateStartTimeField{ "update-start-time" };
|
||||
static inline constexpr Field SimilarityEngineTypeField{ "similarity-engine-type" };
|
||||
static inline constexpr Field ExtraTagsField{ "extra-tags-to-scan" };
|
||||
static inline constexpr Field ArtistTagDelimiterField{ "artist-tag-delimiter" };
|
||||
static inline constexpr Field DefaultTagDelimiterField{ "default-tag-delimiter" };
|
||||
|
||||
using UpdatePeriodModel = ValueStringModel<ScanSettings::UpdatePeriod>;
|
||||
|
||||
static inline constexpr std::string_view extraTagsDelimiter{ ";" };
|
||||
static inline constexpr char extraTagsDelimiter{ ';' };
|
||||
|
||||
DatabaseSettingsModel()
|
||||
{
|
||||
@@ -65,11 +85,15 @@ namespace UserInterface
|
||||
addField(UpdateStartTimeField);
|
||||
addField(SimilarityEngineTypeField);
|
||||
addField(ExtraTagsField);
|
||||
addField(ArtistTagDelimiterField);
|
||||
addField(DefaultTagDelimiterField);
|
||||
|
||||
setValidator(UpdatePeriodField, createMandatoryValidator());
|
||||
setValidator(UpdateStartTimeField, createMandatoryValidator());
|
||||
setValidator(SimilarityEngineTypeField, createMandatoryValidator());
|
||||
setValidator(ExtraTagsField, createUppercaseValidator());
|
||||
setValidator(ArtistTagDelimiterField, std::make_unique<TagDelimitersValidator>());
|
||||
setValidator(DefaultTagDelimiterField, std::make_unique<TagDelimitersValidator>());
|
||||
|
||||
// populate the model with initial data
|
||||
loadData();
|
||||
@@ -103,8 +127,18 @@ namespace UserInterface
|
||||
if (similarityEngineTypeRow)
|
||||
setValue(SimilarityEngineTypeField, _similarityEngineTypeModel->getString(*similarityEngineTypeRow));
|
||||
|
||||
auto extraTags{ scanSettings->getExtraTagsToScan() };
|
||||
setValue(ExtraTagsField, StringUtils::joinStrings(scanSettings->getExtraTagsToScan(), extraTagsDelimiter));
|
||||
const auto extraTags{ scanSettings->getExtraTagsToScan() };
|
||||
setValue(ExtraTagsField, StringUtils::joinStrings(extraTags, extraTagsDelimiter));
|
||||
|
||||
{
|
||||
std::vector<std::string> delimiters{ scanSettings->getArtistTagDelimiters() };
|
||||
setValue(ArtistTagDelimiterField, delimiters.empty() ? "" : delimiters.front());
|
||||
}
|
||||
|
||||
{
|
||||
std::vector<std::string> delimiters{ scanSettings->getDefaultTagDelimiters() };
|
||||
setValue(DefaultTagDelimiterField, delimiters.empty() ? "" : delimiters.front());
|
||||
}
|
||||
}
|
||||
|
||||
void saveData()
|
||||
@@ -126,6 +160,20 @@ namespace UserInterface
|
||||
scanSettings.modify()->setSimilarityEngineType(_similarityEngineTypeModel->getValue(*similarityEngineTypeRow));
|
||||
|
||||
scanSettings.modify()->setExtraTagsToScan(StringUtils::splitString(valueText(ExtraTagsField).toUTF8(), extraTagsDelimiter));
|
||||
|
||||
{
|
||||
std::vector<std::string_view> artistDelimiters;
|
||||
if (std::string artistDelimiter{ valueText(ArtistTagDelimiterField).toUTF8() }; !artistDelimiter.empty())
|
||||
artistDelimiters.push_back(std::move(artistDelimiter));
|
||||
scanSettings.modify()->setArtistTagDelimiters(artistDelimiters);
|
||||
}
|
||||
|
||||
{
|
||||
std::vector<std::string_view> defaultDelimiters;
|
||||
if (std::string defaultDelimiter{ valueText(DefaultTagDelimiterField).toUTF8() }; !defaultDelimiter.empty())
|
||||
defaultDelimiters.push_back(std::move(defaultDelimiter));
|
||||
scanSettings.modify()->setDefaultTagDelimiters(defaultDelimiters);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -201,6 +249,12 @@ namespace UserInterface
|
||||
// Extra tags
|
||||
t->setFormWidget(DatabaseSettingsModel::ExtraTagsField, std::make_unique<Wt::WLineEdit>());
|
||||
|
||||
// Artist tag delimiter
|
||||
t->setFormWidget(DatabaseSettingsModel::ArtistTagDelimiterField, std::make_unique<Wt::WLineEdit>());
|
||||
|
||||
// Default tag delimiter
|
||||
t->setFormWidget(DatabaseSettingsModel::DefaultTagDelimiterField, std::make_unique<Wt::WLineEdit>());
|
||||
|
||||
// Buttons
|
||||
Wt::WPushButton* saveBtn = t->bindWidget("save-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.save")));
|
||||
Wt::WPushButton* discardBtn = t->bindWidget("discard-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.discard")));
|
||||
|
||||
@@ -22,20 +22,20 @@
|
||||
|
||||
namespace UserInterface
|
||||
{
|
||||
void
|
||||
Template::applyArguments(WWidget* widget, const std::vector<Wt::WString>& args)
|
||||
{
|
||||
for (const Wt::WString& arg : args)
|
||||
{
|
||||
const std::vector<std::string> operands {StringUtils::splitStringCopy(arg.toUTF8(), "=")};
|
||||
void Template::applyArguments(WWidget* widget, const std::vector<Wt::WString>& args)
|
||||
{
|
||||
for (const Wt::WString& argStr : args)
|
||||
{
|
||||
std::string arg{ argStr.toUTF8() };
|
||||
const std::vector<std::string_view> operands{ StringUtils::splitString(arg, '=') };
|
||||
|
||||
if (operands.size() == 2)
|
||||
{
|
||||
if (operands[0] == "class")
|
||||
widget->addStyleClass(operands[1]);
|
||||
else
|
||||
widget->setAttributeValue(operands[0], operands[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (operands.size() == 2)
|
||||
{
|
||||
if (operands[0] == "class")
|
||||
widget->addStyleClass(std::string{ operands[1] });
|
||||
else
|
||||
widget->setAttributeValue(std::string{ operands[0] }, std::string{ operands[1] });
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace UserInterface
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace UserInterface
|
||||
void DatabaseCollectorBase::setSearch(std::string_view searchText)
|
||||
{
|
||||
_searchText = searchText;
|
||||
_searchKeywords = StringUtils::splitString(_searchText, " ");
|
||||
_searchKeywords = StringUtils::splitString(_searchText, ' ');
|
||||
}
|
||||
|
||||
} // ns UserInterface
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
#include <Wt/WDate.h>
|
||||
|
||||
#include "metadata/Exception.hpp"
|
||||
#include "metadata/IParser.hpp"
|
||||
#include "utils/StreamLogger.hpp"
|
||||
|
||||
@@ -76,8 +77,8 @@ namespace
|
||||
if (medium.position)
|
||||
os << "\tPosition: " << *medium.position << std::endl;
|
||||
|
||||
if (!medium.type.empty())
|
||||
os << "\tType: " << medium.type << std::endl;
|
||||
if (!medium.media.empty())
|
||||
os << "\tMedia: " << medium.media << std::endl;
|
||||
|
||||
if (medium.trackCount)
|
||||
std::cout << "\tTrackCount: " << *medium.trackCount << std::endl;
|
||||
@@ -95,15 +96,8 @@ namespace
|
||||
{
|
||||
using namespace MetaData;
|
||||
|
||||
parser.setUserExtraTags({ "MOOD", "ALBUMGROUPING", "GENRE", "LANGUAGE" });
|
||||
|
||||
const auto start{ std::chrono::steady_clock::now() };
|
||||
std::optional<Track> track{ parser.parse(file, true) };
|
||||
if (!track)
|
||||
{
|
||||
std::cerr << "Parsing failed" << std::endl;
|
||||
return;
|
||||
}
|
||||
std::unique_ptr<Track> track{ parser.parse(file, true) };
|
||||
const auto end{ std::chrono::steady_clock::now() };
|
||||
|
||||
std::cout << "Parsing time: " << std::fixed << std::setprecision(2) << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() / 1000. << "ms" << std::endl;
|
||||
@@ -155,6 +149,21 @@ namespace
|
||||
if (track->recordingMBID)
|
||||
std::cout << "Recording MBID = " << track->recordingMBID->getAsString() << std::endl;
|
||||
|
||||
for (std::string_view genre : track->genres)
|
||||
std::cout << "Genre: " << genre << std::endl;
|
||||
|
||||
for (std::string_view genre : track->moods)
|
||||
std::cout << "Mood: " << genre << std::endl;
|
||||
|
||||
for (std::string_view grouping : track->groupings)
|
||||
std::cout << "Grouping: " << grouping << std::endl;
|
||||
|
||||
for (std::string_view language : track->languages)
|
||||
std::cout << "Language: " << language << std::endl;
|
||||
|
||||
for (std::string_view label : track->labels)
|
||||
std::cout << "Label: " << label << std::endl;
|
||||
|
||||
for (const auto& [tag, values] : track->userExtraTags)
|
||||
{
|
||||
std::cout << "Tag: " << tag << std::endl;
|
||||
@@ -209,7 +218,7 @@ int main(int argc, char* argv[])
|
||||
try
|
||||
{
|
||||
// log to stdout
|
||||
Service<ILogger> logger{ std::make_unique<StreamLogger>(std::cout) };
|
||||
Service<ILogger> logger{ std::make_unique<StreamLogger>(std::cout, StreamLogger::allSeverities) };
|
||||
|
||||
for (std::size_t i{}; i < static_cast<std::size_t>(argc - 1); ++i)
|
||||
{
|
||||
@@ -217,17 +226,28 @@ int main(int argc, char* argv[])
|
||||
|
||||
std::cout << "Parsing file '" << file << "'" << std::endl;
|
||||
|
||||
try
|
||||
{
|
||||
std::cout << "Using av:" << std::endl;
|
||||
auto parser{ MetaData::createParser(MetaData::ParserType::AvFormat, MetaData::ParserReadStyle::Accurate) };
|
||||
auto parser{ MetaData::createParser(MetaData::ParserBackend::AvFormat, MetaData::ParserReadStyle::Accurate) };
|
||||
parse(*parser, file);
|
||||
}
|
||||
catch (MetaData::Exception& e)
|
||||
{
|
||||
std::cerr << "Parsing failed: " << e.what() << std::endl;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
std::cout << "Using TagLib:" << std::endl;
|
||||
auto parser{ MetaData::createParser(MetaData::ParserType::TagLib, MetaData::ParserReadStyle::Accurate) };
|
||||
auto parser{ MetaData::createParser(MetaData::ParserBackend::TagLib, MetaData::ParserReadStyle::Accurate) };
|
||||
parse(*parser, file);
|
||||
}
|
||||
catch (MetaData::Exception& e)
|
||||
{
|
||||
std::cerr << "Parsing failed: " << e.what() << std::endl;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
catch (std::exception& e)
|
||||
|
||||
Reference in New Issue
Block a user