Merge branch 'develop' for release v3.50.0

This commit is contained in:
emeric
2024-03-04 16:30:38 +01:00
68 changed files with 2894 additions and 1424 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ A [demo instance](http://lms-demo.poupon.dev) is available. Note the administrat
## Main features
* Recommendation engine
* Audio transcoding for maximum interoperability and reduced bandwith requirements
* Multi-value tags: `genre`, `albumgenre`, `mood`, `albummood`, `albumgrouping`, ...
* Multi-valued tags: `genre`, `mood`, `artists`, ...
* Artist relationships: `composer`, `conductor`, `lyricist`, `mixer`, `performer`, `producer`, `remixer`
* [Release types](https://musicbrainz.org/doc/Release_Group/Type): `album`, `single`, `EP`, `compilation`, `live`, ...
* [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) support to handle duplicated artist and release names
@@ -45,7 +45,7 @@ It is possible to apply global filters on your collection using `genre`, `mood`,
__Note__: you can use the `lms-metadata` tool to have an idea of the tags parsed by _LMS_ using [TagLib](https://github.com/taglib/taglib).
### Multiple album artists
_LMS_ requires the `albumartists` and `albumartistssort` tags to properly handle multiple album artists on the same album. As they are a custom tags, you may need to setup your favorite tagger to add them.
_LMS_ requires the `albumartists` and `albumartistssort` tags to properly handle multiple album artists on the same album. As they are custom tags, you may need to set up your favorite tagger to add them.
__Note__: if you use [Picard](https://picard.musicbrainz.org/), add the following script to include these tags:
```
+18
View File
@@ -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>
+3
View File
@@ -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 tags (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>
+3
View File
@@ -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 tags 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>
+3
View File
@@ -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 tag 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>
+2
View File
@@ -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>
+15 -1
View File
@@ -50,7 +50,21 @@
</message>
<message id="Lms.Explore.Release.template.entry-disc">
<h4>${disc-title}</h4>
<div class="d-flex align-items-center">
<h4 class="flex-fill">${disc-title}</h4>
<div class="p-2 d-flex">
${play-btn class="d-none d-sm-block btn btn-sm btn-outline-secondary border-0"}
<div class="dropdown d-inline-block">
${more-btn data-bs-toggle="dropdown" aria-expanded="false" class="btn btn-sm btn-outline-secondary border-0"}
<ul class="dropdown-menu" aria-labelledby="${id:more-btn}">
<li class="d-block d-sm-none">${play class="dropdown-item"}</li>
<li>${play-next class="dropdown-item"}</li>
<li>${play-last class="dropdown-item"}</li>
<li>${play-shuffled class="dropdown-item"}</li>
</ul>
</div>
</div>
</div>
${tracks class="d-grid gap-1 Lms-row-container"}
</message>
+4 -1
View File
@@ -33,7 +33,7 @@ approot = "/usr/share/lms/approot";
# Location for deployment (See README if you want to deploy on a non root path)
deploy-path = "/";
# Number of threads to be used to dispatch http requests (0 means auto detect)
# Number of threads to be used to dispatch http requests (0 means number of logical CPUs)
http-server-thread-count = 0;
# ListenBrainz root API
@@ -98,3 +98,6 @@ scanner-skip-duplicate-mbid = false;
# Scanner read style for metadata, maybe be 'fast', 'average' or 'accurate'
scanner-parser-read-style = "average";
# Number of threads to use for scanning file metadata (0 means number of logical CPUs / 2)
scanner-metadata-thread-count = 0;
+48 -28
View File
@@ -66,6 +66,9 @@ LMS.mediaplayer = function () {
navigator.mediaSession.setActionHandler("nexttrack", function() {
_playNext();
});
navigator.mediaSession.setActionHandler("seekto", function(e) {
_seekTo(e.seekTime);
});
}
if (_pendingTrackParameters != null) {
@@ -81,16 +84,10 @@ LMS.mediaplayer = function () {
if (_elems.audio.paused) {
_elems.playpause.firstElementChild.classList.remove(pauseClass);
_elems.playpause.firstElementChild.classList.add(playClass);
if ("mediaSession" in navigator) {
navigator.mediaSession.playbackState = "paused";
}
}
else {
_elems.playpause.firstElementChild.classList.remove(playClass);
_elems.playpause.firstElementChild.classList.add(pauseClass);
if ("mediaSession" in navigator) {
navigator.mediaSession.playbackState = "playing";
}
}
}
@@ -202,6 +199,46 @@ LMS.mediaplayer = function () {
_gainNode.gain.value = Math.pow(10, (_settings.replayGain.preAmpGain + replayGain) / 20);
}
let _seekTo = function(seekTime) {
_initAudioCtx();
let mode = _getAudioMode();
if (!mode)
return;
switch (mode) {
case Mode.Transcoding:
_offset = seekTime;
_removeAudioSources();
_addAudioSource(_audioTranscodingSrc + "&offset=" + _offset);
_elems.audio.load();
_elems.audio.currentTime = 0;
_playTrack();
break;
case Mode.File:
_elems.audio.currentTime = seekTime;
_playTrack();
break;
}
_updateMediaSessionState();
}
let _updateMediaSessionState = function() {
if ("mediaSession" in navigator) {
navigator.mediaSession.setPositionState({
duration: _duration,
playbackRate: 1,
position: _offset + _elems.audio.currentTime,
});
if (_elems.audio.paused)
navigator.mediaSession.playbackState = "paused";
else
navigator.mediaSession.playbackState = "playing";
}
}
let init = function(root, defaultSettings) {
_root = root;
@@ -228,34 +265,17 @@ LMS.mediaplayer = function () {
_playNext();
});
_elems.seek.addEventListener("change", function() {
_initAudioCtx();
let mode = _getAudioMode();
if (!mode)
return;
let selectedOffset = parseInt(_elems.seek.value, 10);
switch (mode) {
case Mode.Transcoding:
_offset = selectedOffset;
_removeAudioSources();
_addAudioSource(_audioTranscodingSrc + "&offset=" + _offset);
_elems.audio.load();
_elems.audio.currentTime = 0;
_playTrack();
break;
case Mode.File:
_elems.audio.currentTime = selectedOffset;
_playTrack();
break;
}
_seekTo(parseInt(_elems.seek.value, 10));
});
_elems.audio.addEventListener("play", _updateControls);
_elems.audio.addEventListener("playing", _updateControls);
_elems.audio.addEventListener("pause", _updateControls);
_elems.audio.addEventListener("play", _updateMediaSessionState);
_elems.audio.addEventListener("playing", _updateMediaSessionState);
_elems.audio.addEventListener("pause", _updateMediaSessionState);
_elems.audio.addEventListener("pause", _stopTimer);
_elems.audio.addEventListener("playing", _startTimer);
_elems.audio.addEventListener("waiting", _stopTimer);
+1
View File
@@ -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;
+39 -8
View File
@@ -30,6 +30,15 @@
namespace Database
{
namespace
{
static constexpr Version LMS_DATABASE_VERSION{ 54 };
}
VersionInfo::VersionInfo()
: _version{ LMS_DATABASE_VERSION }
{}
VersionInfo::pointer VersionInfo::getOrCreate(Session& session)
{
session.checkWriteTransaction();
@@ -72,11 +81,6 @@ namespace Database::Migration
Db& _db;
};
static void migrateFromV32(Session& session)
{
ScanSettings::get(session).modify()->addAudioFileExtension(".wv");
}
static void migrateFromV33(Session& session)
{
// remove name from track_artist_link
@@ -220,7 +224,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");
}
@@ -364,7 +368,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,
@@ -399,6 +403,31 @@ 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 migrateFromV52(Session& session)
{
// Add sort name for releases
session.getDboSession().execute("ALTER TABLE release ADD sort_name TEXT NOT NULL DEFAULT ''");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession().execute("UPDATE scan_settings SET scan_version = scan_version + 1");
}
void migrateFromV53(Session& session)
{
// Add release group mbid
session.getDboSession().execute("ALTER TABLE release ADD group_mbid TEXT NOT NULL DEFAULT ''");
// Just increment the scan version of the settings to make the next scheduled scan rescan everything
session.getDboSession().execute("UPDATE scan_settings SET scan_version = scan_version + 1");
}
void doDbMigration(Session& session)
{
static const std::string outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
@@ -409,7 +438,6 @@ SELECT
const std::map<unsigned, MigrationFunction> migrationFunctions
{
{32, migrateFromV32},
{33, migrateFromV33},
{34, migrateFromV34},
{35, migrateFromV35},
@@ -428,6 +456,9 @@ SELECT
{48, migrateFromV48},
{49, migrateFromV49},
{50, migrateFromV50},
{51, migrateFromV51},
{52, migrateFromV52},
{53, migrateFromV53},
};
{
+4 -2
View File
@@ -26,12 +26,14 @@ namespace Database
class Session;
using Version = std::size_t;
static constexpr Version LMS_DATABASE_VERSION{ 51 };
class VersionInfo
{
public:
using pointer = Wt::Dbo::ptr<VersionInfo>;
VersionInfo();
static VersionInfo::pointer getOrCreate(Session& session);
static VersionInfo::pointer get(Session& session);
@@ -45,7 +47,7 @@ namespace Database
}
private:
int _version{ LMS_DATABASE_VERSION };
int _version;
};
namespace Migration
+32 -7
View File
@@ -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));
@@ -55,14 +55,19 @@ namespace Database
return res;
}
void ScanSettings::addAudioFileExtension(const std::filesystem::path& ext)
{
_audioFileExtensions += " " + ext.string();
}
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)
@@ -74,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;
+3
View File
@@ -142,6 +142,9 @@ namespace Database
if (params.trackNumber)
query.where("t.track_number = ?").bind(*params.trackNumber);
if (params.discNumber)
query.where("t.disc_number = ?").bind(*params.discNumber);
if (params.mediaLibrary.isValid())
query.where("t.media_library_id = ?").bind(params.mediaLibrary);
@@ -143,8 +143,10 @@ namespace Database
std::size_t getMeanBitrate() const;
// Accessors
const std::string& getName() const { return _name; }
std::string_view getName() const { return _name; }
std::string_view getSortName() const { return _sortName; }
std::optional<UUID> getMBID() const { return UUID::fromString(_MBID); }
std::optional<UUID> getGroupMBID() const { return UUID::fromString(_groupMBID); }
std::optional<std::size_t> getTotalDisc() const { return _totalDisc; }
std::size_t getDiscCount() const; // may not be total disc (if incomplete for example)
std::vector<DiscInfo> getDiscs() const;
@@ -157,7 +159,9 @@ namespace Database
// Setters
void setName(std::string_view name) { _name = name; }
void setSortName(std::string_view sortName) { _sortName = sortName; }
void setMBID(const std::optional<UUID>& mbid) { _MBID = mbid ? mbid->getAsString() : ""; }
void setGroupMBID(const std::optional<UUID>& mbid) { _groupMBID = mbid ? mbid->getAsString() : ""; }
void setTotalDisc(std::optional<int> totalDisc) { _totalDisc = totalDisc; }
void setArtistDisplayName(std::string_view name) { _artistDisplayName = name; }
void clearReleaseTypes();
@@ -173,7 +177,9 @@ namespace Database
void persist(Action& a)
{
Wt::Dbo::field(a, _name, "name");
Wt::Dbo::field(a, _sortName, "sort_name");
Wt::Dbo::field(a, _MBID, "mbid");
Wt::Dbo::field(a, _groupMBID, "group_mbid");
Wt::Dbo::field(a, _totalDisc, "total_disc");
Wt::Dbo::field(a, _artistDisplayName, "artist_display_name");
Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToOne, "release");
@@ -191,7 +197,9 @@ namespace Database
static constexpr std::size_t _maxNameLength{ 256 };
std::string _name;
std::string _sortName;
std::string _MBID;
std::string _groupMBID;
std::optional<int> _totalDisc{};
std::string _artistDisplayName;
@@ -20,6 +20,7 @@
#pragma once
#include <filesystem>
#include <span>
#include <string>
#include <string_view>
#include <vector>
@@ -62,19 +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 addAudioFileExtension(const std::filesystem::path& ext);
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>
@@ -86,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:
@@ -95,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
@@ -78,6 +78,7 @@ namespace Database {
std::string releaseName; // matching this release name
TrackListId trackList; // matching this trackList
std::optional<int> trackNumber; // matching this track number
std::optional<int> discNumber; // matching this disc number
MediaLibraryId mediaLibrary; // If set, tracks in this library
FindParameters& setClusters(const std::vector<ClusterId>& _clusters) { clusters = _clusters; return *this; }
@@ -94,6 +95,7 @@ namespace Database {
FindParameters& setReleaseName(std::string_view _releaseName) { releaseName = _releaseName; return *this; }
FindParameters& setTrackList(TrackListId _trackList) { trackList = _trackList; return *this; }
FindParameters& setTrackNumber(int _trackNumber) { trackNumber = _trackNumber; return *this; }
FindParameters& setDiscNumber(int _discNumber) { discNumber = _discNumber; return *this; }
FindParameters& setMediaLibrary(MediaLibraryId _mediaLibrary) { mediaLibrary = _mediaLibrary; return *this; }
};
+3 -3
View File
@@ -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
)
-250
View File
@@ -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 avformat 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", "ALBUM-SORT" } },
{ 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", "ALBUMGROUPING" } },
{ 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
-47
View File
@@ -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"};
}
}
+164
View File
@@ -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
+397
View File
@@ -0,0 +1,397 @@
/*
* 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->sortName = getTagValueAs<std::string>(tagReader, TagType::AlbumSortOrder).value_or("");
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->groupMBID = getTagValueAs<UUID>(tagReader, TagType::MusicBrainzReleaseGroupID);
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
+54
View File
@@ -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
-521
View File
@@ -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
-47
View File
@@ -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
+377
View File
@@ -0,0 +1,377 @@
/*
* 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", "ALBUMGROUPING" } },
{ 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.contains("DISCSUBTITLE"))
_propertyMap["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString() };
}
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) << "'");
}
for (const auto& value : _propertyMap.unsupportedData())
LMS_LOG(METADATA, DEBUG, "Unknown 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
@@ -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;
};
}
+24 -9
View File
@@ -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>>;
@@ -51,21 +54,33 @@ namespace MetaData
struct Release
{
std::optional<UUID> mbid;
std::optional<UUID> groupMBID;
std::string name;
std::string sortName;
std::string artistDisplayName;
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 +90,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 +123,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 +142,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
+1
View File
@@ -2,6 +2,7 @@ include(GoogleTest)
add_executable(test-metadata
Metadata.cpp
Parser.cpp
Utils.cpp
)
+269
View File
@@ -0,0 +1,269 @@
/*
* 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::AlbumSortOrder, { "MyAlbumSortName" } },
{ 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::MusicBrainzReleaseGroupID, { "5b1a5a44-8420-4426-9b86-d25dc8d04838" } },
{ 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->groupMBID.value(), UUID::fromString("5b1a5a44-8420-4426-9b86-d25dc8d04838"));
EXPECT_EQ(track->medium->release->mediumCount, 3);
EXPECT_EQ(track->medium->release->name, "MyAlbum");
EXPECT_EQ(track->medium->release->sortName, "MyAlbumSortName");
{
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");
}
}
+107
View File
@@ -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;
};
}
+45 -33
View File
@@ -19,6 +19,7 @@
#include "AuthServiceBase.hpp"
#include <cstdlib>
#include "database/Db.hpp"
#include "database/Session.hpp"
#include "database/User.hpp"
@@ -26,46 +27,57 @@
namespace Auth
{
using namespace Database;
using namespace Database;
AuthServiceBase::AuthServiceBase(Db& db)
: _db {db}
{}
AuthServiceBase::AuthServiceBase(Db& db)
: _db{ db }
{}
UserId
AuthServiceBase::getOrCreateUser(std::string_view loginName)
{
Session& session {getDbSession()};
auto transaction {session.createWriteTransaction()};
UserId AuthServiceBase::getOrCreateUser(std::string_view loginName)
{
Session& session{ getDbSession() };
auto transaction{ session.createWriteTransaction() };
User::pointer user {User::find(session, loginName)};
if (!user)
{
const UserType type {User::getCount(session) == 0 ? UserType::ADMIN : UserType::REGULAR};
User::pointer user{ User::find(session, loginName) };
if (!user)
{
const UserType type{ User::getCount(session) == 0 ? UserType::ADMIN : UserType::REGULAR };
LMS_LOG(AUTH, DEBUG, "Creating user '" << loginName << "', admin = " << (type == UserType::ADMIN));
LMS_LOG(AUTH, DEBUG, "Creating user '" << loginName << "', admin = " << (type == UserType::ADMIN));
user = session.create<User>(loginName);
user.modify()->setType(type);
}
user = session.create<User>(loginName);
user.modify()->setType(type);
}
return user->getId();
}
return user->getId();
}
void
AuthServiceBase::onUserAuthenticated(UserId userId)
{
Session& session {getDbSession()};
auto transaction {session.createWriteTransaction()};
void AuthServiceBase::onUserAuthenticated(UserId userId)
{
Session& session{ getDbSession() };
User::pointer user {User::find(session, userId)};
if (user)
user.modify()->setLastLogin(Wt::WDateTime::currentDateTime());
}
// Update last login only if relevant (avoid hammering write accesses to the database)
{
auto transaction{ session.createReadTransaction() };
Session&
AuthServiceBase::getDbSession()
{
return _db.getTLSSession();
}
const User::pointer user{ User::find(session, userId) };
if (!user)
return;
if (std::abs(Wt::WDateTime::currentDateTime().secsTo(user->getLastLogin())) < 60)
return;
}
{
auto transaction{ session.createWriteTransaction() };
if (User::pointer user{ User::find(session, userId) })
user.modify()->setLastLogin(Wt::WDateTime::currentDateTime());
}
}
Session& AuthServiceBase::getDbSession()
{
return _db.getTLSSession();
}
}
+12 -12
View File
@@ -24,23 +24,23 @@
namespace Database
{
class Db;
class Session;
class Db;
class Session;
}
namespace Auth
{
class AuthServiceBase
{
protected:
AuthServiceBase(Database::Db& db);
class AuthServiceBase
{
protected:
AuthServiceBase(Database::Db& db);
Database::UserId getOrCreateUser(std::string_view loginName);
void onUserAuthenticated(Database::UserId userId);
Database::UserId getOrCreateUser(std::string_view loginName);
void onUserAuthenticated(Database::UserId userId);
Database::Session& getDbSession();
Database::Session& getDbSession();
private:
Database::Db& _db;
};
private:
Database::Db& _db;
};
}
@@ -29,110 +29,105 @@
namespace Auth
{
InternalPasswordService::InternalPasswordService(Database::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService)
: PasswordServiceBase {db, maxThrottlerEntries, authTokenService}
{
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::OneCharClass, 4);
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::TwoCharClass, 4);
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::PassPhrase, 4);
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::ThreeCharClass, 4);
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::FourCharClass, 4);
_validator.setMinimumPassPhraseWords(1);
_validator.setMinimumMatchLength(3);
}
InternalPasswordService::InternalPasswordService(Database::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService)
: PasswordServiceBase{ db, maxThrottlerEntries, authTokenService }
{
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::OneCharClass, 4);
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::TwoCharClass, 4);
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::PassPhrase, 4);
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::ThreeCharClass, 4);
_validator.setMinimumLength(Wt::Auth::PasswordStrengthType::FourCharClass, 4);
_validator.setMinimumPassPhraseWords(1);
_validator.setMinimumMatchLength(3);
}
bool
InternalPasswordService::checkUserPassword(std::string_view loginName, std::string_view password)
{
LMS_LOG(AUTH, DEBUG, "Checking internal password for user '" << loginName << "'");
bool InternalPasswordService::checkUserPassword(std::string_view loginName, std::string_view password)
{
LMS_LOG(AUTH, DEBUG, "Checking internal password for user '" << loginName << "'");
Database::User::PasswordHash passwordHash;
{
Database::Session& session {getDbSession()};
auto transaction {session.createReadTransaction()};
Database::User::PasswordHash passwordHash;
{
Database::Session& session{ getDbSession() };
auto transaction{ session.createReadTransaction() };
const Database::User::pointer user {Database::User::find(session, loginName)};
if (!user)
{
LMS_LOG(AUTH, DEBUG, "hashing random stuff");
// hash random stuff here to waste some time
hashRandomPassword();
return false;
}
const Database::User::pointer user{ Database::User::find(session, loginName) };
if (!user)
{
LMS_LOG(AUTH, DEBUG, "hashing random stuff");
// hash random stuff here to waste some time
hashRandomPassword();
return false;
}
// Don't allow users being created or coming from other backends
passwordHash = user->getPasswordHash();
if (passwordHash.salt.empty() || passwordHash.hash.empty())
{
// hash random stuff here to waste some time
hashRandomPassword();
return false;
}
}
// Don't allow users being created or coming from other backends
passwordHash = user->getPasswordHash();
if (passwordHash.salt.empty() || passwordHash.hash.empty())
{
// hash random stuff here to waste some time
hashRandomPassword();
return false;
}
}
return _hashFunc.verify(std::string {password}, std::string {passwordHash.salt}, std::string {passwordHash.hash});
}
return _hashFunc.verify(std::string{ password }, std::string{ passwordHash.salt }, std::string{ passwordHash.hash });
}
bool
InternalPasswordService::canSetPasswords() const
{
return true;
}
bool InternalPasswordService::canSetPasswords() const
{
return true;
}
IPasswordService::PasswordAcceptabilityResult
InternalPasswordService::checkPasswordAcceptability(std::string_view password, const PasswordValidationContext& context) const
{
switch (context.userType)
{
case Database::UserType::ADMIN:
case Database::UserType::REGULAR:
return _validator.evaluateStrength(std::string {password}, context.loginName, "").isValid() ? PasswordAcceptabilityResult::OK : PasswordAcceptabilityResult::TooWeak;
case Database::UserType::DEMO:
return password == context.loginName ? PasswordAcceptabilityResult::OK : PasswordAcceptabilityResult::MustMatchLoginName;
}
IPasswordService::PasswordAcceptabilityResult InternalPasswordService::checkPasswordAcceptability(std::string_view password, const PasswordValidationContext& context) const
{
switch (context.userType)
{
case Database::UserType::ADMIN:
case Database::UserType::REGULAR:
return _validator.evaluateStrength(std::string{ password }, context.loginName, "").isValid() ? PasswordAcceptabilityResult::OK : PasswordAcceptabilityResult::TooWeak;
case Database::UserType::DEMO:
return password == context.loginName ? PasswordAcceptabilityResult::OK : PasswordAcceptabilityResult::MustMatchLoginName;
}
throw NotImplementedException {};
}
throw NotImplementedException{};
}
void
InternalPasswordService::setPassword(Database::UserId userId, std::string_view newPassword)
{
const Database::User::PasswordHash passwordHash {hashPassword(newPassword)};
void InternalPasswordService::setPassword(Database::UserId userId, std::string_view newPassword)
{
const Database::User::PasswordHash passwordHash{ hashPassword(newPassword) };
Database::Session& session {getDbSession()};
auto transaction {session.createWriteTransaction()};
Database::Session& session{ getDbSession() };
auto transaction{ session.createWriteTransaction() };
Database::User::pointer user {Database::User::find(session, userId)};
if (!user)
throw Exception {"User not found!"};
Database::User::pointer user{ Database::User::find(session, userId) };
if (!user)
throw Exception{ "User not found!" };
switch (checkPasswordAcceptability(newPassword, PasswordValidationContext {user->getLoginName(), user->getType()}))
{
case PasswordAcceptabilityResult::OK:
break;
case PasswordAcceptabilityResult::TooWeak:
throw PasswordTooWeakException {};
case PasswordAcceptabilityResult::MustMatchLoginName:
throw PasswordMustMatchLoginNameException {};
}
switch (checkPasswordAcceptability(newPassword, PasswordValidationContext{ user->getLoginName(), user->getType() }))
{
case PasswordAcceptabilityResult::OK:
break;
case PasswordAcceptabilityResult::TooWeak:
throw PasswordTooWeakException{};
case PasswordAcceptabilityResult::MustMatchLoginName:
throw PasswordMustMatchLoginNameException{};
}
user.modify()->setPasswordHash(passwordHash);
getAuthTokenService().clearAuthTokens(userId);
}
user.modify()->setPasswordHash(passwordHash);
getAuthTokenService().clearAuthTokens(userId);
}
Database::User::PasswordHash
InternalPasswordService::hashPassword(std::string_view password) const
{
const std::string salt {Wt::WRandom::generateId(32)};
Database::User::PasswordHash InternalPasswordService::hashPassword(std::string_view password) const
{
const std::string salt{ Wt::WRandom::generateId(32) };
return {salt, _hashFunc.compute(std::string {password}, salt)};
}
return { salt, _hashFunc.compute(std::string {password}, salt) };
}
void
InternalPasswordService::hashRandomPassword() const
{
hashPassword(Wt::WRandom::generateId(32));
}
void
InternalPasswordService::hashRandomPassword() const
{
hashPassword(Wt::WRandom::generateId(32));
}
} // namespace Auth
@@ -28,25 +28,25 @@
namespace Auth
{
class IAuthTokenService;
class IAuthTokenService;
class InternalPasswordService : public PasswordServiceBase
{
public:
InternalPasswordService(Database::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService);
class InternalPasswordService : public PasswordServiceBase
{
public:
InternalPasswordService(Database::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService);
private:
bool checkUserPassword(std::string_view loginName, std::string_view password) override;
private:
bool checkUserPassword(std::string_view loginName, std::string_view password) override;
bool canSetPasswords() const override;
PasswordAcceptabilityResult checkPasswordAcceptability(std::string_view loginName, const PasswordValidationContext& context) const override;
void setPassword(Database::UserId userId, std::string_view newPassword) override;
bool canSetPasswords() const override;
PasswordAcceptabilityResult checkPasswordAcceptability(std::string_view loginName, const PasswordValidationContext& context) const override;
void setPassword(Database::UserId userId, std::string_view newPassword) override;
Database::User::PasswordHash hashPassword(std::string_view password) const;
void hashRandomPassword() const;
Database::User::PasswordHash hashPassword(std::string_view password) const;
void hashRandomPassword() const;
const Wt::Auth::BCryptHashFunction _hashFunc {7}; // TODO parametrize this
Wt::Auth::PasswordStrengthValidator _validator;
};
const Wt::Auth::BCryptHashFunction _hashFunc{ 7 }; // TODO parametrize this
Wt::Auth::PasswordStrengthValidator _validator;
};
}
@@ -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)
@@ -128,6 +129,10 @@ namespace Scanner
{
if (release->getName() != releaseInfo.name)
release.modify()->setName(releaseInfo.name);
if (release->getSortName() != releaseInfo.sortName)
release.modify()->setSortName(releaseInfo.sortName);
if (release->getGroupMBID() != releaseInfo.groupMBID)
release.modify()->setGroupMBID(releaseInfo.groupMBID);
if (release->getTotalDisc() != releaseInfo.mediumCount)
release.modify()->setTotalDisc(releaseInfo.mediumCount);
if (release->getArtistDisplayName() != releaseInfo.artistDisplayName)
@@ -179,25 +184,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;
}
@@ -215,22 +229,105 @@ namespace Scanner
throw LmsException{ "Invalid value for 'scanner-parser-read-style'" };
}
std::size_t getScanMetaDataThreadCount()
{
std::size_t threadCount{ Service<IConfig>::get()->getULong("scanner-metadata-thread-count", 0) };
if (threadCount == 0)
threadCount = std::max<std::size_t>(std::thread::hardware_concurrency() / 2, 1);
return threadCount;
}
} // namespace
ScanStepScanFiles::MetadataScanQueue::MetadataScanQueue(MetaData::IParser& parser, std::size_t threadCount)
: _metadataParser{ parser }
, _scanContextRunner{ _scanContext, threadCount }
{}
void ScanStepScanFiles::MetadataScanQueue::pushScanRequest(const std::filesystem::path& path)
{
{
std::scoped_lock lock{ _mutex };
_ongoingScanCount += 1;
}
_scanContext.post([=, this]
{
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(MetaDataScanResult{ std::move(path), std::move(track) });
_ongoingScanCount -= 1;
}
_condVar.notify_all();
});
}
std::size_t ScanStepScanFiles::MetadataScanQueue::getResultsCount() const
{
std::scoped_lock lock{ _mutex };
return _scanResults.size();
}
size_t ScanStepScanFiles::MetadataScanQueue::popResults(std::vector<MetaDataScanResult>& results, std::size_t maxCount)
{
results.clear();
results.reserve(maxCount);
{
std::scoped_lock lock{ _mutex };
while (results.size() < maxCount && !_scanResults.empty())
{
results.push_back(std::move(_scanResults.front()));
_scanResults.pop_front();
}
}
return results.size();
}
void ScanStepScanFiles::MetadataScanQueue::wait(std::size_t maxScanRequestCount)
{
std::unique_lock lock{ _mutex };
_condVar.wait(lock, [=, this] {return _ongoingScanCount <= maxScanRequestCount;});
}
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");
}
void ScanStepScanFiles::process(ScanContext& context)
{
const std::size_t scanQueueMaxScanRequestCount{ 20 * _metadataScanQueue.getThreadCount() };
const std::size_t processMetaDataBatchSize{ 10 };
{
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<MetaDataScanResult> scanResults;
context.currentStepStats.totalElems = context.stats.filesScanned;
for (const ScannerSettings::MediaLibraryInfo& mediaLibrary : _settings.mediaLibraries)
@@ -247,22 +344,32 @@ namespace Scanner
}
else if (PathUtils::hasFileAnyExtension(path, _settings.supportedExtensions))
{
scanAudioFile(path, context, mediaLibrary);
if (checkFileNeedScan(context, path, mediaLibrary))
_metadataScanQueue.pushScanRequest(path);
context.currentStepStats.processedElems++;
_progressCallback(context.currentStepStats);
// 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.currentStepStats.processedElems % 1'000) == 0)
_db.getTLSSession().optimize();
}
while (_metadataScanQueue.getResultsCount() > processMetaDataBatchSize)
{
_metadataScanQueue.popResults(scanResults, processMetaDataBatchSize);
processMetaDataScanResults(context, scanResults, mediaLibrary);
}
_metadataScanQueue.wait(scanQueueMaxScanRequestCount);
return true;
}, &excludeDirFileName);
}
}
void ScanStepScanFiles::scanAudioFile(const std::filesystem::path& file, ScanContext& context, const ScannerSettings::MediaLibraryInfo& libraryInfo)
_metadataScanQueue.wait();
while (_metadataScanQueue.popResults(scanResults, processMetaDataBatchSize) > 0)
processMetaDataScanResults(context, scanResults, mediaLibrary);
}
}
bool ScanStepScanFiles::checkFileNeedScan(ScanContext& context, const std::filesystem::path& file, const ScannerSettings::MediaLibraryInfo& libraryInfo)
{
ScanStats& stats{ context.stats };
Wt::WDateTime lastWriteTime;
@@ -274,7 +381,7 @@ namespace Scanner
{
LMS_LOG(DBUPDATER, ERROR, e.what());
stats.skips++;
return;
return false;
}
bool needUpdateLibrary{};
@@ -296,7 +403,7 @@ namespace Scanner
if (trackMediaLibrary && trackMediaLibrary->getId() == libraryInfo.id)
{
stats.skips++;
return;
return false;
}
needUpdateLibrary = true;
@@ -312,26 +419,60 @@ namespace Scanner
assert(track);
track.modify()->setMediaLibrary(Database::MediaLibrary::find(dbSession, libraryInfo.id)); // may be null, will be handled in the next scan anyway
stats.updates++;
return;
return false;
}
std::optional<MetaData::Track> trackInfo{ _metadataParser->parse(file) };
if (!trackInfo)
{
context.stats.errors.emplace_back(file, ScanErrorType::CannotParseFile);
return;
}
stats.scans++;
return true; // need to scan
}
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 MetaDataScanResult& scanResult : scanResults)
{
if (_abortScan)
return;
if (scanResult.trackMetaData)
{
context.stats.scans++;
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)
_db.getTLSSession().optimize();
}
else
{
context.stats.errors.emplace_back(scanResult.path, ScanErrorType::CannotParseFile);
}
}
}
void ScanStepScanFiles::processFileMetaData(ScanContext& context, const std::filesystem::path& file, const MetaData::Track& trackMetadata, const ScannerSettings::MediaLibraryInfo& libraryInfo)
{
ScanStats& stats{ context.stats };
Wt::WDateTime lastWriteTime;
try
{
lastWriteTime = PathUtils::getLastWriteTime(file);
}
catch (LmsException& e)
{
LMS_LOG(DBUPDATER, ERROR, e.what());
stats.skips++;
return;
}
Database::Session& dbSession{ _db.getTLSSession() };
Track::pointer track{ Track::findByPath(dbSession, file) };
if (trackInfo->mbid && (!track || _settings.skipDuplicateMBID))
if (trackMetadata.mbid && (!track || _settings.skipDuplicateMBID))
{
std::vector<Track::pointer> duplicateTracks{ Track::findByMBID(dbSession, *trackInfo->mbid) };
std::vector<Track::pointer> duplicateTracks{ Track::findByMBID(dbSession, *trackMetadata.mbid) };
// find for existing MBIDs as the file may have just been moved
if (!track && duplicateTracks.size() == 1)
@@ -378,7 +519,7 @@ namespace Scanner
}
// We estimate this is an audio file if the duration is not null
if (trackInfo->duration == std::chrono::milliseconds::zero())
if (trackMetadata.duration == std::chrono::milliseconds::zero())
{
LMS_LOG(DBUPDATER, DEBUG, "Skipped '" << file.string() << "' (duration is 0)");
@@ -394,8 +535,8 @@ namespace Scanner
// ***** Title
std::string title;
if (!trackInfo->title.empty())
title = trackInfo->title;
if (!trackMetadata.title.empty())
title = trackMetadata.title;
else
{
// TODO parse file name guess track etc.
@@ -424,78 +565,78 @@ namespace Scanner
track.modify()->setMediaLibrary(MediaLibrary::find(dbSession, libraryInfo.id)); // may be null if settings are updated in // => next scan will correct this
track.modify()->clearArtistLinks();
// Do not fallback on artists with the same name but having a MBID for artist and releaseArtists, as it may be corrected by properly tagging files
for (const Artist::pointer& artist : getOrCreateArtists(dbSession, trackInfo->artists, false))
for (const Artist::pointer& artist : getOrCreateArtists(dbSession, trackMetadata.artists, false))
track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, artist, TrackArtistLinkType::Artist));
if (trackInfo->medium && trackInfo->medium->release)
if (trackMetadata.medium && trackMetadata.medium->release)
{
for (const Artist::pointer& releaseArtist : getOrCreateArtists(dbSession, trackInfo->medium->release->artists, false))
for (const Artist::pointer& releaseArtist : getOrCreateArtists(dbSession, trackMetadata.medium->release->artists, false))
track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, releaseArtist, TrackArtistLinkType::ReleaseArtist));
}
// Allow fallbacks on artists with the same name even if they have MBID, since there is no tag to indicate the MBID of these artists
// We could ask MusicBrainz to get all the information, but that would heavily slow down the import process
for (const Artist::pointer& conductor : getOrCreateArtists(dbSession, trackInfo->conductorArtists, true))
for (const Artist::pointer& conductor : getOrCreateArtists(dbSession, trackMetadata.conductorArtists, true))
track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, conductor, TrackArtistLinkType::Conductor));
for (const Artist::pointer& composer : getOrCreateArtists(dbSession, trackInfo->composerArtists, true))
for (const Artist::pointer& composer : getOrCreateArtists(dbSession, trackMetadata.composerArtists, true))
track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, composer, TrackArtistLinkType::Composer));
for (const Artist::pointer& lyricist : getOrCreateArtists(dbSession, trackInfo->lyricistArtists, true))
for (const Artist::pointer& lyricist : getOrCreateArtists(dbSession, trackMetadata.lyricistArtists, true))
track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, lyricist, TrackArtistLinkType::Lyricist));
for (const Artist::pointer& mixer : getOrCreateArtists(dbSession, trackInfo->mixerArtists, true))
for (const Artist::pointer& mixer : getOrCreateArtists(dbSession, trackMetadata.mixerArtists, true))
track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, mixer, TrackArtistLinkType::Mixer));
for (const auto& [role, performers] : trackInfo->performerArtists)
for (const auto& [role, performers] : trackMetadata.performerArtists)
{
for (const Artist::pointer& performer : getOrCreateArtists(dbSession, performers, true))
track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, performer, TrackArtistLinkType::Performer, role));
}
for (const Artist::pointer& producer : getOrCreateArtists(dbSession, trackInfo->producerArtists, true))
for (const Artist::pointer& producer : getOrCreateArtists(dbSession, trackMetadata.producerArtists, true))
track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, producer, TrackArtistLinkType::Producer));
for (const Artist::pointer& remixer : getOrCreateArtists(dbSession, trackInfo->remixerArtists, true))
for (const Artist::pointer& remixer : getOrCreateArtists(dbSession, trackMetadata.remixerArtists, true))
track.modify()->addArtistLink(TrackArtistLink::create(dbSession, track, remixer, TrackArtistLinkType::Remixer));
track.modify()->setScanVersion(_settings.scanVersion);
if (trackInfo->medium && trackInfo->medium->release)
track.modify()->setRelease(getOrCreateRelease(dbSession, *trackInfo->medium->release, file.parent_path()));
if (trackMetadata.medium && trackMetadata.medium->release)
track.modify()->setRelease(getOrCreateRelease(dbSession, *trackMetadata.medium->release, file.parent_path()));
else
track.modify()->setRelease({});
track.modify()->setTotalTrack(trackInfo->medium ? trackInfo->medium->trackCount : std::nullopt);
track.modify()->setReleaseReplayGain(trackInfo->medium ? trackInfo->medium->replayGain : std::nullopt);
track.modify()->setDiscSubtitle(trackInfo->medium ? trackInfo->medium->name : "");
track.modify()->setClusters(getOrCreateClusters(dbSession, trackInfo->userExtraTags));
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));
track.modify()->setLastWriteTime(lastWriteTime);
track.modify()->setName(title);
track.modify()->setDuration(trackInfo->duration);
track.modify()->setBitrate(trackInfo->bitrate);
track.modify()->setDuration(trackMetadata.duration);
track.modify()->setBitrate(trackMetadata.bitrate);
track.modify()->setAddedTime(Wt::WDateTime::currentDateTime());
track.modify()->setTrackNumber(trackInfo->position);
track.modify()->setDiscNumber(trackInfo->medium ? trackInfo->medium->position : std::nullopt);
track.modify()->setDate(trackInfo->date);
track.modify()->setYear(trackInfo->year);
track.modify()->setOriginalDate(trackInfo->originalDate);
track.modify()->setOriginalYear(trackInfo->originalYear);
track.modify()->setTrackNumber(trackMetadata.position);
track.modify()->setDiscNumber(trackMetadata.medium ? trackMetadata.medium->position : std::nullopt);
track.modify()->setDate(trackMetadata.date);
track.modify()->setYear(trackMetadata.year);
track.modify()->setOriginalDate(trackMetadata.originalDate);
track.modify()->setOriginalYear(trackMetadata.originalYear);
// If a file has an OriginalDate but no date, set it to ease filtering
if (!trackInfo->date.isValid() && trackInfo->originalDate.isValid())
track.modify()->setDate(trackInfo->originalDate);
if (!trackMetadata.date.isValid() && trackMetadata.originalDate.isValid())
track.modify()->setDate(trackMetadata.originalDate);
// If a file has an OriginalYear but no Year, set it to ease filtering
if (!trackInfo->year && trackInfo->originalYear)
track.modify()->setYear(trackInfo->originalYear);
if (!trackMetadata.year && trackMetadata.originalYear)
track.modify()->setYear(trackMetadata.originalYear);
track.modify()->setRecordingMBID(trackInfo->recordingMBID);
track.modify()->setTrackMBID(trackInfo->mbid);
track.modify()->setRecordingMBID(trackMetadata.recordingMBID);
track.modify()->setTrackMBID(trackMetadata.mbid);
if (auto trackFeatures{ TrackFeatures::find(dbSession, track->getId()) })
trackFeatures.remove(); // TODO: only if MBID changed?
track.modify()->setHasCover(trackInfo->hasCover);
track.modify()->setCopyright(trackInfo->copyright);
track.modify()->setCopyrightURL(trackInfo->copyrightURL);
track.modify()->setTrackReplayGain(trackInfo->replayGain);
track.modify()->setArtistDisplayName(trackInfo->artistDisplayName);
track.modify()->setHasCover(trackMetadata.hasCover);
track.modify()->setCopyright(trackMetadata.copyright);
track.modify()->setCopyrightURL(trackMetadata.copyrightURL);
track.modify()->setTrackReplayGain(trackMetadata.replayGain);
track.modify()->setArtistDisplayName(trackMetadata.artistDisplayName);
}
}
@@ -19,11 +19,16 @@
#pragma once
#include <condition_variable>
#include <deque>
#include <filesystem>
#include <mutex>
#include <span>
#include <string>
#include <vector>
#include "metadata/IParser.hpp"
#include "utils/IOContextRunner.hpp"
#include "ScanStepBase.hpp"
namespace Scanner
@@ -38,9 +43,44 @@ namespace Scanner
std::string_view getStepName() const override { return "Scanning files"; }
void process(ScanContext& context) override;
void scanAudioFile(const std::filesystem::path& file, ScanContext& context, const ScannerSettings::MediaLibraryInfo& libraryInfo);
bool checkFileNeedScan(ScanContext& context, const std::filesystem::path& file, const ScannerSettings::MediaLibraryInfo& libraryInfo);
struct MetaDataScanResult
{
std::filesystem::path path;
std::unique_ptr<MetaData::Track> trackMetaData;
};
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
{
public:
MetadataScanQueue(MetaData::IParser& parser, std::size_t threadCount);
std::size_t getThreadCount() const { return _scanContextRunner.getThreadCount(); }
void pushScanRequest(const std::filesystem::path& path);
std::size_t getResultsCount() const;
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
private:
MetaData::IParser& _metadataParser;
boost::asio::io_context _scanContext;
IOContextRunner _scanContextRunner;
mutable std::mutex _mutex ;
std::size_t _ongoingScanCount{};
std::deque<MetaDataScanResult> _scanResults;
std::condition_variable _condVar;
};
MetadataScanQueue _metadataScanQueue;
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;
};
}
@@ -65,10 +65,12 @@ namespace Scrobbling::ListenBrainz
Wt::Json::Object additionalInfo;
additionalInfo["listening_from"] = "LMS";
additionalInfo["duration_ms"] = std::chrono::duration_cast<std::chrono::milliseconds>(track->getDuration()).count();
if (track->getRelease())
if (const auto release {track->getRelease()})
{
if (auto MBID{ track->getRelease()->getMBID() })
if (auto MBID{ release->getMBID() })
additionalInfo["release_mbid"] = Wt::Json::Value{ std::string {MBID->getAsString()} };
if (auto groupMBID{ release->getGroupMBID() })
additionalInfo["release_group_mbid"] = Wt::Json::Value{ std::string {groupMBID->getAsString()} };
}
{
@@ -94,10 +96,10 @@ namespace Scrobbling::ListenBrainz
Wt::Json::Object trackMetadata;
trackMetadata["additional_info"] = std::move(additionalInfo);
trackMetadata["artist_name"] = Wt::Json::Value{ artists.front()->getName() };
trackMetadata["artist_name"] = Wt::Json::Value{ std::string{ track->getArtistDisplayName() } };
trackMetadata["track_name"] = Wt::Json::Value{ track->getName() };
if (track->getRelease())
trackMetadata["release_name"] = Wt::Json::Value{ track->getRelease()->getName() };
trackMetadata["release_name"] = Wt::Json::Value{ std::string {track->getRelease()->getName()} };
Wt::Json::Object payload;
payload["track_metadata"] = std::move(trackMetadata);
+1 -1
View File
@@ -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;
+4 -4
View File
@@ -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;
+1 -1
View File
@@ -33,7 +33,7 @@ namespace API::Subsonic::Utils
throw NotImplementedGenericError{};
}
std::string makeNameFilesystemCompatible(const std::string& name)
std::string makeNameFilesystemCompatible(std::string_view name)
{
return StringUtils::replaceInString(name, "/", "_");
}
+2 -1
View File
@@ -20,9 +20,10 @@
#pragma once
#include <string>
#include <string_view>
namespace API::Subsonic::Utils
{
void checkSetPasswordImplemented();
std::string makeNameFilesystemCompatible(const std::string& name);
std::string makeNameFilesystemCompatible(std::string_view name);
}
@@ -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) };
@@ -106,6 +106,8 @@ namespace API::Subsonic
return albumNode;
// OpenSubsonic specific fields (must always be set)
albumNode.setAttribute("sortName", release->getSortName());
if (!id3)
albumNode.setAttribute("mediaType", "album");
+30 -25
View File
@@ -24,40 +24,45 @@
#include "utils/ILogger.hpp"
IOContextRunner::IOContextRunner(boost::asio::io_service& ioService, std::size_t threadCount)
: _ioService {ioService}
, _work {ioService}
: _ioService{ ioService }
, _work{ ioService }
{
LMS_LOG(UTILS, INFO, "Starting IO context with " << threadCount << " threads...");
for (std::size_t i {}; i < threadCount; ++i)
{
_threads.emplace_back([&]
{
try
{
_ioService.run();
}
catch (const std::exception& e)
{
LMS_LOG(UTILS, FATAL, "Exception caught in IO context: " << e.what());
std::abort();
}
});
}
LMS_LOG(UTILS, INFO, "Starting IO context with " << threadCount << " threads...");
for (std::size_t i{}; i < threadCount; ++i)
{
_threads.emplace_back([&]
{
try
{
_ioService.run();
}
catch (const std::exception& e)
{
LMS_LOG(UTILS, FATAL, "Exception caught in IO context: " << e.what());
std::abort();
}
});
}
}
void
IOContextRunner::stop()
{
LMS_LOG(UTILS, DEBUG, "Stopping IO context...");
_work.reset();
_ioService.stop();
LMS_LOG(UTILS, DEBUG, "IO context stopped!");
LMS_LOG(UTILS, DEBUG, "Stopping IO context...");
_work.reset();
_ioService.stop();
LMS_LOG(UTILS, DEBUG, "IO context stopped!");
}
std::size_t IOContextRunner::getThreadCount() const
{
return _threads.size();
}
IOContextRunner::~IOContextRunner()
{
stop();
stop();
for (std::thread& t : _threads)
t.join();
for (std::thread& t : _threads)
t.join();
}
+1
View File
@@ -68,6 +68,7 @@ Log::Log(ILogger& logger, Module module, Severity severity)
Log::~Log()
{
assert(_logger.isSeverityActive(_severity));
_logger.processLog(*this);
}
+3 -2
View File
@@ -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
View File
@@ -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,19 +25,18 @@
class IOContextRunner
{
public:
IOContextRunner(boost::asio::io_service& ioService, std::size_t threadCount);
~IOContextRunner();
public:
IOContextRunner(boost::asio::io_service& ioService, std::size_t threadCount);
~IOContextRunner();
IOContextRunner(const IOContextRunner&) = delete;
IOContextRunner(IOContextRunner&&) = delete;
IOContextRunner& operator=(const IOContextRunner&) = delete;
IOContextRunner& operator=(IOContextRunner&&) = delete;
void stop();
std::size_t getThreadCount() const;
void stop();
private:
IOContextRunner(const IOContextRunner&) = delete;
IOContextRunner& operator=(const IOContextRunner&) = delete;
private:
boost::asio::io_service& _ioService;
std::optional<boost::asio::io_service::work> _work;
std::vector<std::thread> _threads;
boost::asio::io_service& _ioService;
std::optional<boost::asio::io_service::work> _work;
std::vector<std::thread> _threads;
};
@@ -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:
+14 -7
View File
@@ -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);
+2
View File
@@ -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
View File
@@ -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"));
}
+1 -1
View File
@@ -289,7 +289,7 @@ namespace UserInterface
if (track->getRelease())
{
_release->setTextFormat(Wt::TextFormat::Plain);
_release->setText(Wt::WString::fromUTF8(track->getRelease()->getName()));
_release->setText(Wt::WString::fromUTF8(std::string{ track->getRelease()->getName() }));
_release->setLink(Utils::createReleaseLink(track->getRelease()));
}
else
+3 -2
View File
@@ -255,9 +255,10 @@ namespace UserInterface::Utils
if (setText)
{
std::string releaseName{ release->getName() };
res->setTextFormat(Wt::TextFormat::Plain);
res->setText(Wt::WString::fromUTF8(release->getName()));
res->setToolTip(Wt::WString::fromUTF8(release->getName()), Wt::TextFormat::Plain);
res->setText(Wt::WString::fromUTF8(releaseName));
res->setToolTip(Wt::WString::fromUTF8(releaseName), Wt::TextFormat::Plain);
}
return res;
+57 -3
View File
@@ -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")));
+15 -15
View File
@@ -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
+1 -1
View File
@@ -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
@@ -88,6 +88,36 @@ namespace UserInterface
return res;
}
std::vector<Database::TrackId> getDiscTracks(Database::Session& session, const std::vector<PlayQueueController::Disc>& discs, const std::vector<Database::ClusterId>& clusters, std::size_t maxTrackCount)
{
using namespace Database;
assert(maxTrackCount);
std::vector<TrackId> res;
auto transaction{ session.createReadTransaction() };
for (const PlayQueueController::Disc& disc : discs)
{
Database::Track::FindParameters params;
params.setRelease(disc.releaseId);
params.setSortMethod(Database::TrackSortMethod::Release);
params.setDiscNumber(disc.discNumber);
params.setClusters(clusters);
params.setRange(Database::Range{ 0, maxTrackCount - res.size() });
const auto tracks{ Database::Track::findIds(session, params) };
res.reserve(res.size() + tracks.results.size());
res.insert(std::end(res), std::cbegin(tracks.results), std::cend(tracks.results));
if (res.size() == maxTrackCount)
break;
}
return res;
}
std::vector<Database::TrackId> getTrackListTracks(Database::Session& session, Database::TrackListId trackListId, const std::vector<Database::ClusterId>& clusters, std::size_t maxTrackCount)
{
using namespace Database;
@@ -149,6 +179,12 @@ namespace UserInterface
processCommand(command, tracks);
}
void PlayQueueController::processCommand(Command command, const std::vector<Disc>& discs)
{
const std::vector<Database::TrackId> tracks{ getDiscTracks(LmsApp->getDbSession(), discs, _filters.getClusterIds(), _maxTrackCountToEnqueue) };
processCommand(command, tracks);
}
void PlayQueueController::playTrackInRelease(Database::TrackId trackId)
{
Database::ReleaseId releaseId;
+12 -5
View File
@@ -45,11 +45,18 @@ namespace UserInterface
PlayShuffled,
};
void processCommand(Command command, const std::vector<Database::ArtistId>&);
void processCommand(Command command, const std::vector<Database::ReleaseId>&);
void processCommand(Command command, const std::vector<Database::TrackId>&);
void processCommand(Command command, Database::TrackListId);
void playTrackInRelease(Database::TrackId);
void processCommand(Command command, const std::vector<Database::ArtistId>& artists);
void processCommand(Command command, const std::vector<Database::ReleaseId>& releases);
void processCommand(Command command, const std::vector<Database::TrackId>& tracks);
struct Disc
{
Database::ReleaseId releaseId;
size_t discNumber;
};
void processCommand(Command command, const std::vector<Disc>& discs);
void processCommand(Command command, Database::TrackListId trackList);
void playTrackInRelease(Database::TrackId track);
void setMaxTrackCountToEnqueue(std::size_t maxTrackCount) { _maxTrackCountToEnqueue = maxTrackCount; }
+36 -9
View File
@@ -238,14 +238,14 @@ namespace UserInterface
if (!release)
throw ReleaseNotFoundException{};
LmsApp->setTitle(release->getName());
LmsApp->setTitle(std::string{ release->getName() });
_releaseId = *releaseId;
refreshCopyright(release);
refreshLinks(release);
refreshSimilarReleases(similarReleasesIds);
bindString("name", Wt::WString::fromUTF8(release->getName()), Wt::TextFormat::Plain);
bindString("name", Wt::WString::fromUTF8(std::string{ release->getName() }), Wt::TextFormat::Plain);
Wt::WString year{ ReleaseHelpers::buildReleaseYearString(release->getYear(), release->getOriginalYear()) };
if (!year.empty())
@@ -340,21 +340,47 @@ namespace UserInterface
// Expect to be called in asc order
std::map<std::size_t, Wt::WContainerWidget*> trackContainers;
auto getOrAddDiscContainer = [&](std::size_t discNumber, const std::string& discSubtitle) -> Wt::WContainerWidget*
auto getOrAddDiscContainer = [&, releaseId = _releaseId](std::size_t discNumber, const std::string& discSubtitle) -> Wt::WContainerWidget*
{
{
auto it = trackContainers.find(discNumber);
if (it != std::cend(trackContainers))
return it->second;
}
if (auto it{ trackContainers.find(discNumber) }; it != std::cend(trackContainers))
return it->second;
Wt::WTemplate* disc{ rootContainer->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Explore.Release.template.entry-disc")) };
Template* disc{ rootContainer->addNew<Template>(Wt::WString::tr("Lms.Explore.Release.template.entry-disc")) };
disc->addFunction("id", &Wt::WTemplate::Functions::id);
if (discSubtitle.empty())
disc->bindNew<Wt::WText>("disc-title", Wt::WString::tr("Lms.Explore.Release.disc").arg(discNumber));
else
disc->bindString("disc-title", Wt::WString::fromUTF8(discSubtitle), Wt::TextFormat::Plain);
Wt::WPushButton* playBtn{ disc->bindNew<Wt::WPushButton>("play-btn", Wt::WString::tr("Lms.template.play-btn"), Wt::TextFormat::XHTML) };
playBtn->clicked().connect([this, releaseId, discNumber]
{
_playQueueController.processCommand(PlayQueueController::Command::Play, { PlayQueueController::Disc{ releaseId, discNumber} });
});
disc->bindNew<Wt::WPushButton>("more-btn", Wt::WString::tr("Lms.template.more-btn"), Wt::TextFormat::XHTML);
disc->bindNew<Wt::WPushButton>("play", Wt::WString::tr("Lms.Explore.play"))
->clicked().connect([this, releaseId, discNumber]
{
_playQueueController.processCommand(PlayQueueController::Command::Play, { PlayQueueController::Disc{ releaseId, discNumber} });
});
disc->bindNew<Wt::WPushButton>("play-next", Wt::WString::tr("Lms.Explore.play-next"))
->clicked().connect([this, releaseId, discNumber]
{
_playQueueController.processCommand(PlayQueueController::Command::PlayNext, { PlayQueueController::Disc{ releaseId, discNumber } });
});
disc->bindNew<Wt::WPushButton>("play-shuffled", Wt::WString::tr("Lms.Explore.play-shuffled"), Wt::TextFormat::Plain)
->clicked().connect([this, releaseId, discNumber]
{
_playQueueController.processCommand(PlayQueueController::Command::PlayShuffled, { PlayQueueController::Disc{ releaseId, discNumber } });
});
disc->bindNew<Wt::WPushButton>("play-last", Wt::WString::tr("Lms.Explore.play-last"))
->clicked().connect([this, releaseId, discNumber]
{
_playQueueController.processCommand(PlayQueueController::Command::PlayOrAddLast, { PlayQueueController::Disc{ releaseId, discNumber } });
});
Wt::WContainerWidget* tracksContainer{ disc->bindNew<Wt::WContainerWidget>("tracks") };
trackContainers[discNumber] = tracksContainer;
@@ -390,6 +416,7 @@ namespace UserInterface
container = getOrAddNoDiscContainer();
Template* entry{ container->addNew<Template>(Wt::WString::tr("Lms.Explore.Release.template.entry")) };
entry->addFunction("id", &Wt::WTemplate::Functions::id);
entry->bindString("name", Wt::WString::fromUTF8(track->getName()), Wt::TextFormat::Plain);
+41 -17
View File
@@ -26,6 +26,7 @@
#include <Wt/WDate.h>
#include "metadata/Exception.hpp"
#include "metadata/IParser.hpp"
#include "utils/StreamLogger.hpp"
@@ -47,9 +48,15 @@ namespace
std::ostream& operator<<(std::ostream& os, const MetaData::Release& release)
{
os << release.name;
if (!release.sortName.empty())
os << " '" << release.sortName << "'";
os << std::endl;
if (release.mbid)
os << " (" << release.mbid->getAsString() << ")" << std::endl;
os << "\tRelease MBID = " << release.mbid->getAsString() << std::endl;
if (release.groupMBID)
os << "\tRelease Group MBID = " << release.groupMBID->getAsString() << std::endl;
if (release.mediumCount)
std::cout << "\tMediumCount: " << *release.mediumCount << std::endl;
@@ -60,9 +67,8 @@ namespace
for (const MetaData::Artist& artist : release.artists)
std::cout << "\tRelease artist: " << artist << std::endl;
std::cout << "Release types:" << std::endl;
for (std::string_view releaseType : release.releaseTypes)
std::cout << "\t" << releaseType << std::endl;
std::cout << "\tRelease type: " << releaseType << std::endl;
return os;
}
@@ -76,8 +82,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;
@@ -86,7 +92,7 @@ namespace
std::cout << "\tReplay gain: " << *medium.replayGain << std::endl;
if (medium.release)
std::cout << "Release: " << *medium.release << std::endl;
std::cout << "Release: " << *medium.release;
return os;
}
@@ -95,15 +101,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 +154,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 +223,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 +231,27 @@ 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)
@@ -57,7 +57,7 @@ static void dumpTracksRecommendation(Session session, Recommendation::IRecommend
res += track->getName();
if (track->getRelease())
res += " [" + track->getRelease()->getName() + "]";
res += " [" + std::string{ track->getRelease()->getName() } + "]";
for (auto artist : track->getArtists({ TrackArtistLinkType::Artist }))
res += " - " + artist->getName();
for (auto cluster : track->getClusters())
@@ -88,7 +88,7 @@ static void dumpReleasesRecommendation(Session session, Recommendation::IRecomme
auto transaction{ session.createReadTransaction() };
Release::pointer release{ Release::find(session, releaseId) };
return release->getName();
return std::string{ release->getName() };
};
std::cout << "Processing release '" << releaseToString(releaseId) << "'" << std::endl;