Merge branch 'develop' for release v3.50.0
This commit is contained in:
@@ -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:
|
||||
```
|
||||
|
||||
@@ -42,6 +42,24 @@
|
||||
${extra-tags-to-scan-info}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="${id:artist-tag-delimiter}">
|
||||
${tr:Lms.Admin.Database.artist-tag-delimiter}
|
||||
</label>
|
||||
${artist-tag-delimiter class="form-control"}
|
||||
<div class="invalid-feedback">
|
||||
${artist-tag-delimiter-info}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="${id:default-tag-delimiter}">
|
||||
${tr:Lms.Admin.Database.default-tag-delimiter}
|
||||
</label>
|
||||
${default-tag-delimiter class="form-control"}
|
||||
<div class="invalid-feedback">
|
||||
${default-tag-delimiter-info}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
${save-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"}
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
<message id="Lms.Admin.MediaLibrary.root-path">Root directory</message>
|
||||
|
||||
<!--Scan settings-->
|
||||
<message id="Lms.Admin.Database.artist-tag-delimiter">Delimiter to be used for splitting artist 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ if(BUILD_TESTING)
|
||||
endif()
|
||||
|
||||
add_library(lmsmetadata SHARED
|
||||
impl/AvFormatParser.cpp
|
||||
impl/Factory.cpp
|
||||
impl/TagLibParser.cpp
|
||||
impl/AvFormatTagReader.cpp
|
||||
impl/Parser.cpp
|
||||
impl/TagLibTagReader.cpp
|
||||
impl/Utils.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2013 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "AvFormatParser.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
|
||||
#include "av/IAudioFile.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
#include "utils/String.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
template <typename T>
|
||||
std::optional<T> findFirstValueOfAs(const Av::IAudioFile::MetadataMap& metadataMap, std::initializer_list<std::string> tags)
|
||||
{
|
||||
auto it = std::find_first_of(std::cbegin(metadataMap), std::cend(metadataMap), std::cbegin(tags), std::cend(tags), [](const auto& it, const auto& str) { return it.first == str; });
|
||||
if (it == std::cend(metadataMap))
|
||||
return std::nullopt;
|
||||
|
||||
return StringUtils::readAs<T>(StringUtils::stringTrim(it->second));
|
||||
}
|
||||
|
||||
template <>
|
||||
std::optional<std::vector<UUID>> findFirstValueOfAs(const Av::IAudioFile::MetadataMap& metadataMap, std::initializer_list<std::string> tags)
|
||||
{
|
||||
std::optional<std::string> str{ findFirstValueOfAs<std::string>(metadataMap, tags) };
|
||||
if (!str)
|
||||
return std::nullopt;
|
||||
|
||||
const std::vector<std::string_view> strUuids{ StringUtils::splitString(*str, "/") };
|
||||
std::vector<UUID> res;
|
||||
|
||||
for (std::string_view strUuid : strUuids)
|
||||
{
|
||||
std::optional<UUID> uuid{ UUID::fromString(strUuid) };
|
||||
if (!uuid)
|
||||
return std::nullopt;
|
||||
|
||||
res.push_back(std::move(*uuid));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::vector<Artist> getReleaseArtists(const Av::IAudioFile::MetadataMap& metadataMap)
|
||||
{
|
||||
std::vector<Artist> res;
|
||||
|
||||
auto name{ findFirstValueOfAs<std::string>(metadataMap, {"ALBUM_ARTIST"}) };
|
||||
if (!name)
|
||||
return res;
|
||||
|
||||
auto mbid{ findFirstValueOfAs<UUID>(metadataMap, {"MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID"}) };
|
||||
|
||||
return { Artist {mbid, *name, std::nullopt} };
|
||||
}
|
||||
|
||||
std::vector<Artist> getArtists(const Av::IAudioFile::MetadataMap& metadataMap)
|
||||
{
|
||||
std::vector<Artist> artists;
|
||||
|
||||
std::vector<std::string_view> artistNames;
|
||||
if (metadataMap.find("ARTISTS") != metadataMap.end())
|
||||
{
|
||||
artistNames = StringUtils::splitString(metadataMap.find("ARTISTS")->second, "/;");
|
||||
}
|
||||
else if (metadataMap.find("ARTIST") != metadataMap.end())
|
||||
{
|
||||
artistNames = { metadataMap.find("ARTIST")->second };
|
||||
}
|
||||
|
||||
auto artistMBIDs{ findFirstValueOfAs<std::vector<UUID>>(metadataMap, {"MUSICBRAINZ ARTIST ID", "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ/ARTIST ID"}) };
|
||||
|
||||
for (std::size_t i{}; i < artistNames.size(); ++i)
|
||||
{
|
||||
if (artistMBIDs && artistNames.size() == artistMBIDs->size())
|
||||
artists.emplace_back(Artist{ (*artistMBIDs)[i], artistNames[i], std::nullopt });
|
||||
else
|
||||
artists.emplace_back(Artist{ std::nullopt, artistNames[i], std::nullopt });
|
||||
}
|
||||
|
||||
return artists;
|
||||
}
|
||||
|
||||
std::optional<Release> getRelease(const Av::IAudioFile::MetadataMap& metadataMap)
|
||||
{
|
||||
std::optional<Release> res;
|
||||
|
||||
std::optional<std::string> releaseName{ findFirstValueOfAs<std::string>(metadataMap, {"ALBUM", "TALB", "WM/ALBUMTITLE"}) };
|
||||
if (!releaseName)
|
||||
return res;
|
||||
|
||||
res.emplace();
|
||||
res->name = std::move(*releaseName);
|
||||
res->mbid = findFirstValueOfAs<UUID>(metadataMap, { "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ/ALBUM ID" });
|
||||
res->artists = getReleaseArtists(metadataMap);
|
||||
res->mediumCount = findFirstValueOfAs<std::size_t>(metadataMap, { "TOTALDISCS", "DISCTOTAL" });
|
||||
if (!res->mediumCount)
|
||||
{
|
||||
// mediumCount may be encoded as position/count
|
||||
if (const auto value{ findFirstValueOfAs<std::string>(metadataMap, {"TPOS", "DISC", "DISK", "DISCNUMBER", "WM/PARTOFSET"}) })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, "/") };
|
||||
if (strings.size() == 2)
|
||||
res->mediumCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::optional<Medium> getMedium(const Av::IAudioFile::MetadataMap& metadataMap)
|
||||
{
|
||||
std::optional<Medium> res;
|
||||
res.emplace();
|
||||
|
||||
res->type = findFirstValueOfAs<std::string>(metadataMap, { "TMED", "MEDIA", "WM/MEDIA" }).value_or("");
|
||||
res->name = findFirstValueOfAs<std::string>(metadataMap, { "TSST", "DISCSUBTITLE", "SETSUBTITLE" }).value_or("");
|
||||
res->trackCount = findFirstValueOfAs<std::size_t>(metadataMap, { "TOTALTRACKS", "TRACKTOTAL" });
|
||||
if (!res->trackCount)
|
||||
{
|
||||
// totalTracks may be encoded as "position/count"
|
||||
if (const auto value{ findFirstValueOfAs<std::string>(metadataMap, {"TRCK", "TRACK", "TRACKNUMBER", "TRKN", "WM/TRACKNUMBER"}) })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, "/") };
|
||||
if (strings.size() == 2)
|
||||
res->trackCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// position may be encoded in TPOS/DISC/DISK as "position/count". Expecting 'Number[/Total]'
|
||||
res->position = findFirstValueOfAs<std::size_t>(metadataMap, { "TPOS", "DISC", "DISK", "DISCNUMBER", "WM/PARTOFSET" });
|
||||
res->release = getRelease(metadataMap);
|
||||
|
||||
if (res->type.empty()
|
||||
&& res->name.empty()
|
||||
&& !res->trackCount
|
||||
&& !res->position
|
||||
&& !res->release
|
||||
&& !res->replayGain)
|
||||
{
|
||||
res.reset();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Track> AvFormatParser::parse(const std::filesystem::path& p, bool debug)
|
||||
{
|
||||
Track track;
|
||||
|
||||
try
|
||||
{
|
||||
const auto mediaFile{ Av::parseAudioFile(p) };
|
||||
|
||||
Av::ContainerInfo info{ mediaFile->getContainerInfo() };
|
||||
track.duration = info.duration;
|
||||
track.bitrate = info.bitrate;
|
||||
track.hasCover = mediaFile->hasAttachedPictures();
|
||||
|
||||
MetaData::Tags tags;
|
||||
|
||||
const Av::IAudioFile::MetadataMap metadataMap{ mediaFile->getMetaData() };
|
||||
|
||||
track.artists = getArtists(metadataMap);
|
||||
track.medium = getMedium(metadataMap);
|
||||
|
||||
for (const auto& [tag, value] : metadataMap)
|
||||
{
|
||||
if (debug)
|
||||
std::cout << "TAG = " << tag << ", VAL = " << value << std::endl;
|
||||
|
||||
if (tag == "TITLE")
|
||||
track.title = value;
|
||||
else if (tag == "TRACK")
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
track.position = StringUtils::readAs<std::size_t>(value);
|
||||
}
|
||||
else if (tag == "DATE"
|
||||
|| tag == "YEAR"
|
||||
|| tag == "WM/YEAR")
|
||||
{
|
||||
track.date = Utils::parseDate(value);
|
||||
}
|
||||
else if (tag == "TDOR" // Original release time (ID3v2 2.4)
|
||||
|| tag == "TORY") // Original release year
|
||||
{
|
||||
track.originalDate = Utils::parseDate(value);
|
||||
}
|
||||
else if (tag == "ACOUSTID ID")
|
||||
{
|
||||
track.acoustID = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "MUSICBRAINZ RELEASE TRACK ID"
|
||||
|| tag == "MUSICBRAINZ_RELEASETRACKID")
|
||||
{
|
||||
track.mbid = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "MUSICBRAINZ_TRACKID"
|
||||
|| tag == "MUSICBRAINZ/TRACK ID")
|
||||
{
|
||||
track.recordingMBID = UUID::fromString(value);
|
||||
}
|
||||
else if (std::find(std::cbegin(_userExtraTags), std::cend(_userExtraTags), tag) != std::cend(_userExtraTags))
|
||||
{
|
||||
const std::vector<std::string_view> tagValues{ StringUtils::splitString(value, "/,;") };
|
||||
|
||||
if (!tagValues.empty())
|
||||
{
|
||||
std::vector<std::string> values;
|
||||
std::transform(std::cbegin(tagValues), std::cend(tagValues), std::inserter(values, std::begin(values)), [](std::string_view v) { return std::string{ v }; });
|
||||
track.userExtraTags[tag] = std::move(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Av::Exception& e)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
} // namespace MetaData
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright (C) 2013 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "AvFormatTagReader.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
|
||||
#include "av/IAudioFile.hpp"
|
||||
#include "metadata/Exception.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
#include "utils/String.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
|
||||
// Mapping to internal 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
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "metadata/IParser.hpp"
|
||||
|
||||
#include "utils/Exception.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
|
||||
#include "AvFormatParser.hpp"
|
||||
#include "TagLibParser.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
std::unique_ptr<IParser>
|
||||
createParser(ParserType parserType, ParserReadStyle parserReadStyle)
|
||||
{
|
||||
switch (parserType)
|
||||
{
|
||||
case ParserType::TagLib:
|
||||
LMS_LOG(METADATA, INFO, "Creating TagLib parser with read style = " << Utils::readStyleToString(parserReadStyle));
|
||||
return std::make_unique<TagLibParser>(parserReadStyle);
|
||||
case ParserType::AvFormat:
|
||||
LMS_LOG(METADATA, INFO, "Creating AvFormat parser");
|
||||
return std::make_unique<AvFormatParser>();
|
||||
}
|
||||
|
||||
throw LmsException {"Unhandled parser type"};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
// using picard internal names
|
||||
// see https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html
|
||||
enum class TagType
|
||||
{
|
||||
AcoustID,
|
||||
AcoustIDFingerprint,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AlbumArtists, // non standard
|
||||
AlbumArtistSortOrder,
|
||||
AlbumArtistsSortOrder, // non standard
|
||||
AlbumSortOrder,
|
||||
Arranger,
|
||||
Artist,
|
||||
ArtistSortOrder,
|
||||
Artists,
|
||||
ASIN,
|
||||
Barcode,
|
||||
BPM,
|
||||
CatalogNumber,
|
||||
Comment,
|
||||
Compilation,
|
||||
Composer,
|
||||
ComposerSortOrder,
|
||||
Composers, // non standard
|
||||
ComposersSortOrder, // non standard
|
||||
Conductor,
|
||||
ConductorSortOrder, // non standard
|
||||
Conductors, // non standard
|
||||
ConductorsSortOrder, // non standard
|
||||
Copyright,
|
||||
CopyrightURL, // non standard
|
||||
Date,
|
||||
Director,
|
||||
DiscNumber,
|
||||
DiscSubtitle,
|
||||
EncodedBy,
|
||||
EncoderSettings,
|
||||
Engineer,
|
||||
GaplessPlayback,
|
||||
Genre,
|
||||
Grouping,
|
||||
InitialKey,
|
||||
ISRC,
|
||||
Language,
|
||||
License,
|
||||
Lyricist,
|
||||
LyricistSortOrder, // non standard
|
||||
Lyricists, // non standard
|
||||
LyricistsSortOrder, // non standard
|
||||
Lyrics,
|
||||
Media,
|
||||
MixDJ,
|
||||
Mixer,
|
||||
MixerSortOrder, // non standard
|
||||
Mixers, // non standard
|
||||
MixersSortOrder, // non standard
|
||||
Mood,
|
||||
Movement,
|
||||
MovementCount,
|
||||
MovementNumber,
|
||||
MusicBrainzArtistID,
|
||||
MusicBrainzDiscID,
|
||||
MusicBrainzOriginalArtistID,
|
||||
MusicBrainzOriginalReleaseID,
|
||||
MusicBrainzRecordingID,
|
||||
MusicBrainzReleaseArtistID,
|
||||
MusicBrainzReleaseGroupID,
|
||||
MusicBrainzReleaseID,
|
||||
MusicBrainzTrackID,
|
||||
MusicBrainzWorkID,
|
||||
MusicIPFingerprint,
|
||||
MusicIPPUID,
|
||||
OriginalAlbum,
|
||||
OriginalArtist,
|
||||
OriginalFilename,
|
||||
OriginalReleaseDate,
|
||||
OriginalReleaseYear,
|
||||
Podcast,
|
||||
PodcastURL,
|
||||
Producer,
|
||||
ProducerSortOrder, // non standard
|
||||
Producers, // non standard
|
||||
ProducersSortOrder, // non standard
|
||||
Rating,
|
||||
RecordLabel,
|
||||
ReleaseCountry,
|
||||
ReleaseDate,
|
||||
ReleaseStatus,
|
||||
ReleaseType,
|
||||
Remixer,
|
||||
RemixerSortOrder,
|
||||
Remixers,
|
||||
RemixersSortOrder,
|
||||
ReplayGainAlbumGain,
|
||||
ReplayGainAlbumPeak,
|
||||
ReplayGainAlbumRange,
|
||||
ReplayGainReferenceLoudness,
|
||||
ReplayGainTrackGain,
|
||||
ReplayGainTrackPeak,
|
||||
ReplayGainTrackRange,
|
||||
Script,
|
||||
ShowName,
|
||||
ShowNameSortOrder,
|
||||
ShowWorkAndMovement,
|
||||
Subtitle,
|
||||
TotalDiscs,
|
||||
TotalTracks,
|
||||
TrackNumber,
|
||||
TrackTitle,
|
||||
TrackTitleSortOrder,
|
||||
Website,
|
||||
WorkTitle,
|
||||
Writer,
|
||||
};
|
||||
|
||||
class ITagReader
|
||||
{
|
||||
public:
|
||||
virtual ~ITagReader() = default;
|
||||
|
||||
virtual bool hasMultiValuedTags() const = 0;
|
||||
|
||||
using TagValueVisitor = std::function<void(std::string_view value)>;
|
||||
virtual void visitTagValues(TagType tag, TagValueVisitor visitor) const = 0;
|
||||
virtual void visitTagValues(std::string_view tag, TagValueVisitor visitor) const = 0;
|
||||
|
||||
using PerformerVisitor = std::function<void(std::string_view role, std::string_view artist)>;
|
||||
virtual void visitPerformerTags(PerformerVisitor visitor) const = 0;
|
||||
|
||||
virtual bool hasEmbeddedCover() const = 0;
|
||||
|
||||
virtual std::chrono::milliseconds getDuration() const = 0;
|
||||
virtual std::size_t getBitrate() const = 0;
|
||||
virtual std::size_t getBitsPerSample() const = 0;
|
||||
virtual std::size_t getSampleRate() const = 0;
|
||||
};
|
||||
} // namespace MetaData
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "metadata/IParser.hpp"
|
||||
#include "ITagReader.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
class Parser : public IParser
|
||||
{
|
||||
public:
|
||||
Parser(ParserBackend parserBackend = ParserBackend::TagLib, ParserReadStyle readStyle = ParserReadStyle::Average);
|
||||
|
||||
std::unique_ptr<Track> parse(const std::filesystem::path& p, bool debug = false) override;
|
||||
std::unique_ptr<Track> parse(const ITagReader& reader);
|
||||
|
||||
private:
|
||||
void setUserExtraTags(std::span<const std::string> extraTags) override { _userExtraTags.assign(std::cbegin(extraTags), std::cend(extraTags)); }
|
||||
void setArtistTagDelimiters(std::span<const std::string> delimiters) override { _artistTagDelimiters.assign(std::cbegin(delimiters), std::cend(delimiters)); }
|
||||
void setDefaultTagDelimiters(std::span<const std::string> delimiters) override { _defaultTagDelimiters.assign(std::cbegin(delimiters), std::cend(delimiters)); }
|
||||
|
||||
void processAudioProperties(const ITagReader& reader, Track& track);
|
||||
void processTags(const ITagReader& reader, Track& track);
|
||||
|
||||
std::optional<Medium> getMedium(const ITagReader& tagReader);
|
||||
std::optional<Release> getRelease(const ITagReader& tagReader);
|
||||
|
||||
const ParserBackend _parserBackend;
|
||||
const ParserReadStyle _readStyle;
|
||||
|
||||
std::vector<std::string> _userExtraTags;
|
||||
std::vector<std::string> _artistTagDelimiters;
|
||||
std::vector<std::string> _defaultTagDelimiters;
|
||||
};
|
||||
} // namespace MetaData
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "TagLibParser.hpp"
|
||||
|
||||
#include <map>
|
||||
|
||||
#include <taglib/apetag.h>
|
||||
#include <taglib/asffile.h>
|
||||
#include <taglib/id3v2tag.h>
|
||||
#include <taglib/fileref.h>
|
||||
#include <taglib/flacfile.h>
|
||||
#include <taglib/mp4file.h>
|
||||
#include <taglib/mpcfile.h>
|
||||
#include <taglib/mpegfile.h>
|
||||
#include <taglib/opusfile.h>
|
||||
#include <taglib/tag.h>
|
||||
#include <taglib/tpropertymap.h>
|
||||
#include <taglib/vorbisfile.h>
|
||||
#include <taglib/wavpackfile.h>
|
||||
|
||||
#include "utils/IConfig.hpp"
|
||||
#include "utils/Exception.hpp"
|
||||
#include "utils/ILogger.hpp"
|
||||
#include "utils/Service.hpp"
|
||||
#include "utils/String.hpp"
|
||||
#include "Utils.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
// TODO use string_views here for values
|
||||
using TagMap = std::map<std::string, std::vector<std::string>>;
|
||||
|
||||
template<typename T>
|
||||
std::vector<T> getPropertyValuesFirstMatchAs(const TagMap& tags, std::initializer_list<std::string_view> keys)
|
||||
{
|
||||
std::vector<T> res;
|
||||
|
||||
for (std::string_view key : keys)
|
||||
{
|
||||
const auto itValues{ tags.find(std::string {key}) };
|
||||
if (itValues == std::cend(tags))
|
||||
continue;
|
||||
|
||||
const std::vector<std::string>& values{ itValues->second };
|
||||
if (values.empty())
|
||||
continue;
|
||||
|
||||
res.reserve(values.size());
|
||||
|
||||
for (const auto& value : values)
|
||||
{
|
||||
std::optional<T> val{ StringUtils::readAs<T>(value) };
|
||||
if (!val)
|
||||
continue;
|
||||
|
||||
res.emplace_back(std::move(*val));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::optional<T> getPropertyValueFirstMatchAs(const TagMap& tags, std::initializer_list<std::string_view> keys)
|
||||
{
|
||||
std::optional<T> res;
|
||||
std::vector<T> values{ getPropertyValuesFirstMatchAs<T>(tags, keys) };
|
||||
if (!values.empty())
|
||||
res = std::move(values.front());
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::vector<T> getPropertyValuesAs(const TagMap& tags, std::string_view key)
|
||||
{
|
||||
return getPropertyValuesFirstMatchAs<T>(tags, { key });
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::optional<T> getPropertyValueAs(const TagMap& tags, std::string_view key)
|
||||
{
|
||||
return getPropertyValueFirstMatchAs<T>(tags, { key });
|
||||
}
|
||||
|
||||
std::vector<std::string_view> splitAndTrimString(std::string_view str, std::string_view delimiters)
|
||||
{
|
||||
std::vector<std::string_view> strings{ StringUtils::splitString(str, delimiters) };
|
||||
for (std::string_view& s : strings)
|
||||
s = StringUtils::stringTrim(s);
|
||||
|
||||
return strings;
|
||||
}
|
||||
|
||||
std::vector<Artist> getArtists(const TagMap& tags,
|
||||
std::initializer_list<std::string_view> artistTagNames,
|
||||
std::initializer_list<std::string_view> artistSortTagNames,
|
||||
std::initializer_list<std::string_view> artistMBIDTagNames
|
||||
)
|
||||
{
|
||||
const std::vector<std::string_view> artistNames{ getPropertyValuesFirstMatchAs<std::string_view>(tags, artistTagNames) };
|
||||
if (artistNames.empty())
|
||||
return {};
|
||||
|
||||
std::vector<Artist> artists;
|
||||
artists.reserve(artistNames.size());
|
||||
std::transform(std::cbegin(artistNames), std::cend(artistNames), std::back_inserter(artists),
|
||||
[&](std::string_view name) { return Artist{ name }; });
|
||||
|
||||
{
|
||||
const std::vector<std::string_view> artistSortNames{ getPropertyValuesFirstMatchAs<std::string_view>(tags, artistSortTagNames) };
|
||||
if (artistSortNames.size() == artists.size())
|
||||
{
|
||||
for (std::size_t i{}; i < artistSortNames.size(); ++i)
|
||||
artists[i].sortName = artistSortNames[i];
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const std::vector<UUID> artistsMBID{ getPropertyValuesFirstMatchAs<UUID>(tags, artistMBIDTagNames) };
|
||||
|
||||
if (artistNames.size() == artistsMBID.size())
|
||||
{
|
||||
for (std::size_t i{}; i < artistsMBID.size(); ++i)
|
||||
artists[i].mbid = artistsMBID[i];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return artists;
|
||||
}
|
||||
|
||||
PerformerContainer getPerformerArtists(const TagMap& tags, std::initializer_list<std::string_view> artistTagNames)
|
||||
{
|
||||
PerformerContainer performers;
|
||||
|
||||
// picard stores like this: (see https://picard-docs.musicbrainz.org/en/appendices/tag_mapping.html#performer)
|
||||
// We may hit both styles for the same track
|
||||
// PERFORMER: artist (role)
|
||||
if (const std::vector<std::string_view> artistNames{ getPropertyValuesFirstMatchAs<std::string_view>(tags, artistTagNames) }; !artistNames.empty())
|
||||
{
|
||||
for (std::string_view entry : artistNames)
|
||||
{
|
||||
Utils::PerformerArtist performer{ Utils::extractPerformerAndRole(entry) };
|
||||
StringUtils::capitalize(performer.role);
|
||||
performers[performer.role].push_back(std::move(performer.artist));
|
||||
}
|
||||
}
|
||||
// PERFORMER:role (MP3)
|
||||
for (const auto& [key, values] : tags)
|
||||
{
|
||||
if (key.find("PERFORMER:") == 0)
|
||||
{
|
||||
std::string performerStr{ key };
|
||||
std::string role;
|
||||
if (const std::size_t rolePos{ performerStr.find(':') }; rolePos != std::string::npos)
|
||||
{
|
||||
role = StringUtils::stringToLower(performerStr.substr(rolePos + 1, performerStr.size() - rolePos + 1));
|
||||
StringUtils::capitalize(role);
|
||||
}
|
||||
|
||||
for (const auto& value : values)
|
||||
performers[role].push_back(Artist{ value });
|
||||
}
|
||||
}
|
||||
|
||||
return performers;
|
||||
}
|
||||
|
||||
std::optional<Release> getRelease(const TagMap& tags)
|
||||
{
|
||||
std::optional<Release> release;
|
||||
|
||||
auto releaseName{ getPropertyValueAs<std::string>(tags, "ALBUM") };
|
||||
if (!releaseName)
|
||||
return release;
|
||||
|
||||
release.emplace();
|
||||
release->name = std::move(*releaseName);
|
||||
release->artistDisplayName = getPropertyValueAs<std::string_view>(tags, "ALBUMARTIST").value_or("");
|
||||
release->mbid = getPropertyValueFirstMatchAs<UUID>(tags, { "MUSICBRAINZ_ALBUMID", "MUSICBRAINZ ALBUM ID", "MUSICBRAINZ/ALBUM ID" });
|
||||
release->artists = getArtists(tags, { "ALBUMARTISTS", "ALBUMARTIST" }, { "ALBUMARTISTSSORT", "ALBUMARTISTSORT" }, { "MUSICBRAINZ_ALBUMARTISTID", "MUSICBRAINZ ALBUM ARTIST ID", "MUSICBRAINZ/ALBUM ARTIST ID" });
|
||||
release->mediumCount = getPropertyValueAs<std::size_t>(tags, "DISCTOTAL");
|
||||
if (!release->mediumCount)
|
||||
{
|
||||
// mediumCount may be encoded as "position/count"
|
||||
if (const auto value{ getPropertyValueAs<std::string_view>(tags, "DISCNUMBER") })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, "/") };
|
||||
if (strings.size() == 2)
|
||||
release->mediumCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
|
||||
release->releaseTypes = getPropertyValuesFirstMatchAs<std::string>(tags, { "MUSICBRAINZ_ALBUMTYPE", "RELEASETYPE", "MUSICBRAINZ ALBUM TYPE", "MUSICBRAINZ/ALBUM TYPE" });
|
||||
|
||||
return release;
|
||||
}
|
||||
|
||||
std::optional<Medium> getMedium(const TagMap& tags)
|
||||
{
|
||||
std::optional<Medium> medium;
|
||||
medium.emplace();
|
||||
|
||||
medium->type = getPropertyValueAs<std::string>(tags, "MEDIA").value_or("");
|
||||
medium->name = getPropertyValueFirstMatchAs<std::string>(tags, { "DISCSUBTITLE", "SETSUBTITLE" }).value_or("");
|
||||
medium->trackCount = getPropertyValueAs<std::size_t>(tags, "TRACKTOTAL");
|
||||
if (!medium->trackCount)
|
||||
{
|
||||
// totalTracks may be encoded as "position/count"
|
||||
if (const auto value{ getPropertyValueAs<std::string_view>(tags, "TRACKNUMBER") })
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(*value, "/") };
|
||||
if (strings.size() == 2)
|
||||
medium->trackCount = StringUtils::readAs<std::size_t>(strings[1]);
|
||||
}
|
||||
}
|
||||
// Expecting 'Number[/Total]'
|
||||
medium->position = getPropertyValueAs<std::size_t>(tags, "DISCNUMBER");
|
||||
medium->release = getRelease(tags);
|
||||
medium->replayGain = getPropertyValueAs<float>(tags, "REPLAYGAIN_ALBUM_GAIN");
|
||||
|
||||
if (medium->type.empty()
|
||||
&& medium->name.empty()
|
||||
&& !medium->trackCount
|
||||
&& !medium->position
|
||||
&& !medium->release
|
||||
&& !medium->replayGain)
|
||||
{
|
||||
medium.reset();
|
||||
}
|
||||
|
||||
return medium;
|
||||
}
|
||||
|
||||
TagLib::AudioProperties::ReadStyle readStyleToTagLibReadStyle(ParserReadStyle readStyle)
|
||||
{
|
||||
switch (readStyle)
|
||||
{
|
||||
case ParserReadStyle::Fast: return TagLib::AudioProperties::ReadStyle::Fast;
|
||||
case ParserReadStyle::Average: return TagLib::AudioProperties::ReadStyle::Average;
|
||||
case ParserReadStyle::Accurate: return TagLib::AudioProperties::ReadStyle::Accurate;
|
||||
}
|
||||
|
||||
throw LmsException{ "Cannot convert read style" };
|
||||
}
|
||||
|
||||
TagMap constructTagMap(const TagLib::PropertyMap& properties)
|
||||
{
|
||||
TagMap tagMap;
|
||||
|
||||
for (const auto& [propertyName, propertyValues] : properties)
|
||||
{
|
||||
std::vector<std::string>& values{ tagMap[propertyName.upper().to8Bit(true)] };
|
||||
for (const TagLib::String& propertyValue : propertyValues)
|
||||
{
|
||||
std::string trimedValue{ StringUtils::stringTrim(propertyValue.to8Bit(true)) };
|
||||
if (!trimedValue.empty())
|
||||
values.emplace_back(std::move(trimedValue));
|
||||
}
|
||||
}
|
||||
|
||||
return tagMap;
|
||||
}
|
||||
|
||||
void mergeTagMaps(TagMap& dst, TagMap&& src)
|
||||
{
|
||||
for (auto&& [tag, values] : src)
|
||||
{
|
||||
if (dst.find(tag) == std::cend(dst))
|
||||
dst[tag] = std::move(values);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TagLibParser::TagLibParser(ParserReadStyle readStyle)
|
||||
: _readStyle{ readStyleToTagLibReadStyle(readStyle) }
|
||||
{
|
||||
}
|
||||
|
||||
void TagLibParser::processTag(Track& track, const std::string& tag, const std::vector<std::string>& values, bool debug)
|
||||
{
|
||||
if (debug)
|
||||
std::cout << "[" << tag << "] = " << StringUtils::joinStrings(values, "*SEP*") << std::endl;
|
||||
|
||||
if (tag.empty() || values.empty())
|
||||
return;
|
||||
|
||||
std::string_view value{ values.front() };
|
||||
|
||||
if (tag == "TITLE")
|
||||
track.title = value;
|
||||
else if (tag == "MUSICBRAINZ_RELEASETRACKID"
|
||||
|| tag == "MUSICBRAINZ RELEASE TRACK ID"
|
||||
|| tag == "MUSICBRAINZ/RELEASE TRACK ID")
|
||||
{
|
||||
track.mbid = UUID::fromString(value);
|
||||
}
|
||||
else if (tag == "MUSICBRAINZ_TRACKID"
|
||||
|| tag == "MUSICBRAINZ TRACK ID"
|
||||
|| tag == "MUSICBRAINZ/TRACK ID")
|
||||
track.recordingMBID = UUID::fromString(value);
|
||||
else if (tag == "ACOUSTID_ID")
|
||||
track.acoustID = UUID::fromString(value);
|
||||
else if (tag == "TRACKNUMBER")
|
||||
{
|
||||
// Expecting 'Number/Total'
|
||||
track.position = StringUtils::readAs<std::size_t>(value);
|
||||
}
|
||||
else if (tag == "DATE")
|
||||
{
|
||||
if (const Wt::WDate date{ Utils::parseDate(value) }; date.isValid())
|
||||
track.date = date;
|
||||
else if (!track.year)
|
||||
track.year = Utils::parseYear(value);
|
||||
}
|
||||
else if (tag == "YEAR")
|
||||
track.year = Utils::parseYear(value);
|
||||
else if (tag == "ORIGINALDATE")
|
||||
{
|
||||
if (const Wt::WDate date{ Utils::parseDate(value) }; date.isValid())
|
||||
track.originalDate = date;
|
||||
else if (!track.originalYear)
|
||||
track.originalYear = Utils::parseYear(value);
|
||||
}
|
||||
else if (tag == "ORIGINALYEAR")
|
||||
track.originalYear = Utils::parseYear(value);
|
||||
else if (tag == "METADATA_BLOCK_PICTURE")
|
||||
track.hasCover = true;
|
||||
else if (tag == "COPYRIGHT")
|
||||
track.copyright = value;
|
||||
else if (tag == "COPYRIGHTURL")
|
||||
track.copyrightURL = value;
|
||||
else if (tag == "REPLAYGAIN_TRACK_GAIN")
|
||||
track.replayGain = StringUtils::readAs<float>(value);
|
||||
else if (tag == "ARTIST")
|
||||
track.artistDisplayName = value;
|
||||
else if (std::find(std::cbegin(_userExtraTags), std::cend(_userExtraTags), tag) != std::cend(_userExtraTags))
|
||||
{
|
||||
std::vector<std::string> tagValues;
|
||||
for (std::string_view valueList : values)
|
||||
{
|
||||
const std::vector<std::string_view> splittedValues{ splitAndTrimString(valueList, "/,;") }; // handle possibily bad split tags
|
||||
for (std::string_view value : splittedValues)
|
||||
tagValues.push_back(std::string{ value });
|
||||
}
|
||||
|
||||
if (!tagValues.empty())
|
||||
track.userExtraTags[tag] = std::move(tagValues);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Track> TagLibParser::parse(const std::filesystem::path& p, bool debug)
|
||||
{
|
||||
TagLib::FileRef f{ p.string().c_str(),
|
||||
true, // read audio properties
|
||||
_readStyle };
|
||||
|
||||
if (f.isNull())
|
||||
{
|
||||
LMS_LOG(METADATA, ERROR, "File '" << p.string() << "': parsing failed");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Track track;
|
||||
|
||||
if (const TagLib::AudioProperties* properties{ f.audioProperties() })
|
||||
{
|
||||
track.duration = std::chrono::milliseconds{ properties->lengthInMilliseconds() };
|
||||
track.bitrate = static_cast<std::size_t>(properties->bitrate() * 1000);
|
||||
}
|
||||
else
|
||||
{
|
||||
LMS_LOG(METADATA, INFO, "File '" << p.string() << "': no audio properties");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
TagMap tags{ constructTagMap(f.file()->properties()) };
|
||||
|
||||
auto getAPETags = [&](const TagLib::APE::Tag* apeTag)
|
||||
{
|
||||
if (!apeTag)
|
||||
return;
|
||||
|
||||
mergeTagMaps(tags, constructTagMap(apeTag->properties()));
|
||||
};
|
||||
|
||||
// Not that good embedded pictures handling
|
||||
|
||||
// WMA
|
||||
if (TagLib::ASF::File * asfFile{ dynamic_cast<TagLib::ASF::File*>(f.file()) })
|
||||
{
|
||||
const TagLib::ASF::Tag* tag{ asfFile->tag() };
|
||||
if (tag)
|
||||
{
|
||||
if (tag->attributeListMap().contains("WM/Picture"))
|
||||
track.hasCover = true;
|
||||
|
||||
for (const auto& [name, attributeList] : tag->attributeListMap())
|
||||
{
|
||||
std::string strName{ StringUtils::stringToUpper(name.to8Bit(true)) };
|
||||
if (strName.find("WM/") == 0 || tags.find(strName) != std::cend(tags))
|
||||
continue;
|
||||
|
||||
std::vector<std::string> attributes;
|
||||
for (const auto& attribute : attributeList)
|
||||
{
|
||||
if (attribute.type() == TagLib::ASF::Attribute::AttributeTypes::UnicodeType)
|
||||
attributes.emplace_back(attribute.toString().to8Bit(true));
|
||||
}
|
||||
|
||||
if (!attributes.empty())
|
||||
{
|
||||
if (debug)
|
||||
std::cout << "ASF property: '" << name << "'" << std::endl;
|
||||
|
||||
tags.emplace(strName, std::move(attributes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// MP3
|
||||
else if (TagLib::MPEG::File * mp3File{ dynamic_cast<TagLib::MPEG::File*>(f.file()) })
|
||||
{
|
||||
if (mp3File->ID3v2Tag())
|
||||
{
|
||||
const auto& frameListMap{ mp3File->ID3v2Tag()->frameListMap() };
|
||||
|
||||
if (!frameListMap["APIC"].isEmpty())
|
||||
track.hasCover = true;
|
||||
if (!frameListMap["TSST"].isEmpty())
|
||||
tags["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString().to8Bit(true) };
|
||||
}
|
||||
|
||||
getAPETags(mp3File->APETag());
|
||||
}
|
||||
//MP4
|
||||
else if (TagLib::MP4::File * mp4File{ dynamic_cast<TagLib::MP4::File*>(f.file()) })
|
||||
{
|
||||
TagLib::MP4::Item coverItem{ mp4File->tag()->item("covr") };
|
||||
TagLib::MP4::CoverArtList coverArtList{ coverItem.toCoverArtList() };
|
||||
if (!coverArtList.isEmpty())
|
||||
track.hasCover = true;
|
||||
}
|
||||
// MPC
|
||||
else if (TagLib::MPC::File * mpcFile{ dynamic_cast<TagLib::MPC::File*>(f.file()) })
|
||||
{
|
||||
getAPETags(mpcFile->APETag());
|
||||
}
|
||||
// WavPack
|
||||
else if (TagLib::WavPack::File * wavPackFile{ dynamic_cast<TagLib::WavPack::File*>(f.file()) })
|
||||
{
|
||||
getAPETags(wavPackFile->APETag());
|
||||
}
|
||||
// FLAC
|
||||
else if (TagLib::FLAC::File * flacFile{ dynamic_cast<TagLib::FLAC::File*>(f.file()) })
|
||||
{
|
||||
if (!flacFile->pictureList().isEmpty())
|
||||
track.hasCover = true;
|
||||
}
|
||||
else if (TagLib::Ogg::Vorbis::File * vorbisFile{ dynamic_cast<TagLib::Ogg::Vorbis::File*>(f.file()) })
|
||||
{
|
||||
if (!vorbisFile->tag()->pictureList().isEmpty())
|
||||
track.hasCover = true;
|
||||
}
|
||||
else if (TagLib::Ogg::Opus::File * opusFile{ dynamic_cast<TagLib::Ogg::Opus::File*>(f.file()) })
|
||||
{
|
||||
if (!opusFile->tag()->pictureList().isEmpty())
|
||||
track.hasCover = true;
|
||||
}
|
||||
|
||||
track.medium = getMedium(tags);
|
||||
track.artists = getArtists(tags, { "ARTISTS", "ARTIST" }, { "ARTISTSORT" }, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" });
|
||||
track.conductorArtists = getArtists(tags, { "CONDUCTORS", "CONDUCTOR" }, { "CONDUCTORSSORT", "CONDUCTORSORT" }, {});
|
||||
track.composerArtists = getArtists(tags, { "COMPOSERS", "COMPOSER" }, { "COMPOSERSSORT", "COMPOSERSORT" }, {});
|
||||
track.lyricistArtists = getArtists(tags, { "LYRICISTS", "LYRICIST" }, { "LYRICISTSSORT", "LYRICISTSORT" }, {});
|
||||
track.mixerArtists = getArtists(tags, { "MIXERS", "MIXER" }, { "MIXERSSORT", "MIXERSORT" }, {});
|
||||
track.producerArtists = getArtists(tags, { "PRODUCERS", "PRODUCER" }, { "PRODUCERSSORT", "PRODUCERSORT" }, {});
|
||||
track.remixerArtists = getArtists(tags, { "REMIXERS", "REMIXER", "ModifiedBy" }, { "REMIXERSSORT", "REMIXERSORT" }, {});
|
||||
track.performerArtists = getPerformerArtists(tags, { "PERFORMERS", "PERFORMER" });
|
||||
|
||||
for (const auto& [tag, values] : tags)
|
||||
processTag(track, tag, values, debug);
|
||||
|
||||
// If a file has date but no year, set it
|
||||
if (!track.year && track.date.isValid())
|
||||
track.year = track.date.year();
|
||||
|
||||
// If a file has originalDate but no originalYear, set it
|
||||
if (!track.originalYear && track.originalDate.isValid())
|
||||
track.originalYear = track.originalDate.year();
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
} // namespace MetaData
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <taglib/audioproperties.h>
|
||||
#include "metadata/IParser.hpp"
|
||||
|
||||
namespace TagLib
|
||||
{
|
||||
class StringList;
|
||||
}
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
|
||||
// Parse that makes use of AvFormat
|
||||
class TagLibParser : public IParser
|
||||
{
|
||||
public:
|
||||
TagLibParser(ParserReadStyle readStyle);
|
||||
|
||||
private:
|
||||
std::optional<Track> parse(const std::filesystem::path& p, bool debug = false) override;
|
||||
void processTag(Track& track, const std::string& tag, const std::vector<std::string>& values, bool debug);
|
||||
|
||||
const TagLib::AudioProperties::ReadStyle _readStyle;
|
||||
};
|
||||
|
||||
} // namespace MetaData
|
||||
|
||||
@@ -0,0 +1,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
|
||||
+13
-11
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2018 Emeric Poupon
|
||||
* Copyright (C) 2024 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
@@ -19,17 +19,19 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "metadata/IParser.hpp"
|
||||
#include "utils/Exception.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
class Exception : public LmsException
|
||||
{
|
||||
public:
|
||||
using LmsException::LmsException;
|
||||
};
|
||||
|
||||
// Parse that makes use of AvFormat
|
||||
class AvFormatParser : public IParser
|
||||
{
|
||||
public:
|
||||
std::optional<Track> parse(const std::filesystem::path& p, bool debug = false) override;
|
||||
};
|
||||
|
||||
} // namespace MetaData
|
||||
|
||||
class ParseException : public Exception
|
||||
{
|
||||
public:
|
||||
using Exception::Exception;
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
@@ -44,6 +45,8 @@ namespace MetaData
|
||||
|
||||
Artist(std::string_view _name) : name{ _name } {}
|
||||
Artist(std::optional<UUID> _mbid, std::string_view _name, std::optional<std::string> _sortName) : mbid{ std::move(_mbid) }, name{ _name }, sortName{ std::move(_sortName) } {}
|
||||
|
||||
bool operator<=>(const Artist&) const = default;
|
||||
};
|
||||
|
||||
using PerformerContainer = std::map<std::string /*role*/, std::vector<Artist>>;
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ include(GoogleTest)
|
||||
|
||||
add_executable(test-metadata
|
||||
Metadata.cpp
|
||||
Parser.cpp
|
||||
Utils.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (C) 2024 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "Parser.hpp"
|
||||
|
||||
namespace MetaData
|
||||
{
|
||||
namespace
|
||||
{
|
||||
template <typename TagMapType>
|
||||
bool tagMapHasMultiValuedTags(const TagMapType& m)
|
||||
{
|
||||
return std::any_of(std::cbegin(m), std::cend(m), [](const auto& tagPair)
|
||||
{
|
||||
return tagPair.second.size() > 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TestTagReader : public ITagReader
|
||||
{
|
||||
public:
|
||||
static constexpr std::chrono::milliseconds trackDuration{ 180 };
|
||||
static constexpr std::size_t trackBitrate{ 128000 };
|
||||
static constexpr std::size_t trackBitsPerSample{ 16 };
|
||||
static constexpr std::size_t trackSampleRate{ 44000 };
|
||||
|
||||
using Tags = std::unordered_map<TagType, std::vector<std::string_view>>;
|
||||
using Performers = std::unordered_map<std::string_view, std::vector<std::string_view>>;
|
||||
using ExtraUserTags = std::unordered_map<std::string_view, std::vector<std::string_view>>;
|
||||
TestTagReader(Tags&& tags, Performers&& performers = {}, ExtraUserTags&& extraUserTags = {})
|
||||
: _tags{ std::move(tags) }
|
||||
, _performers{ std::move(performers) }
|
||||
, _extraUserTags{ std::move(extraUserTags) }
|
||||
{
|
||||
_hasMultiValuedTags = tagMapHasMultiValuedTags(_tags)
|
||||
|| tagMapHasMultiValuedTags(_performers)
|
||||
|| tagMapHasMultiValuedTags(_extraUserTags);
|
||||
}
|
||||
|
||||
bool hasMultiValuedTags() const override
|
||||
{
|
||||
return _hasMultiValuedTags;
|
||||
}
|
||||
|
||||
void visitTagValues(TagType tag, TagValueVisitor visitor) const override
|
||||
{
|
||||
auto itValues{ _tags.find(tag) };
|
||||
if (itValues != std::cend(_tags))
|
||||
{
|
||||
for (std::string_view value : itValues->second)
|
||||
visitor(value);
|
||||
}
|
||||
}
|
||||
void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override
|
||||
{
|
||||
auto itValues{ _extraUserTags.find(tag) };
|
||||
if (itValues == std::cend(_extraUserTags))
|
||||
return;
|
||||
|
||||
for (std::string_view value : itValues->second)
|
||||
visitor(value);
|
||||
}
|
||||
|
||||
void visitPerformerTags(PerformerVisitor visitor) const override
|
||||
{
|
||||
for (const auto& [role, names] : _performers)
|
||||
{
|
||||
for (const auto& name : names)
|
||||
visitor(role, name);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasEmbeddedCover() const override { return false; };
|
||||
|
||||
std::chrono::milliseconds getDuration() const override { return trackDuration; }
|
||||
std::size_t getBitrate() const override { return trackBitrate; }
|
||||
std::size_t getBitsPerSample() const override { return trackBitsPerSample; }
|
||||
std::size_t getSampleRate() const override { return trackSampleRate; }
|
||||
|
||||
private:
|
||||
const Tags _tags;
|
||||
const Performers _performers;
|
||||
const ExtraUserTags _extraUserTags;
|
||||
bool _hasMultiValuedTags;
|
||||
};
|
||||
}
|
||||
@@ -19,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace StringUtils
|
||||
std::optional<API::Subsonic::ProtocolVersion> readAs(std::string_view str)
|
||||
{
|
||||
// Expects "X.Y.Z"
|
||||
const auto numbers{ StringUtils::splitString(str, ".") };
|
||||
const auto numbers{ StringUtils::splitString(str, '.') };
|
||||
if (numbers.size() < 2 || numbers.size() > 3)
|
||||
return std::nullopt;
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ namespace StringUtils
|
||||
template<>
|
||||
std::optional<Database::ArtistId> readAs(std::string_view str)
|
||||
{
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, "-") };
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, '-') };
|
||||
if (values.size() != 2)
|
||||
return std::nullopt;
|
||||
|
||||
@@ -88,7 +88,7 @@ namespace StringUtils
|
||||
template<>
|
||||
std::optional<Database::ReleaseId> readAs(std::string_view str)
|
||||
{
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, "-") };
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, '-') };
|
||||
if (values.size() != 2)
|
||||
return std::nullopt;
|
||||
|
||||
@@ -113,7 +113,7 @@ namespace StringUtils
|
||||
template<>
|
||||
std::optional<Database::TrackId> readAs(std::string_view str)
|
||||
{
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, "-") };
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, '-') };
|
||||
if (values.size() != 2)
|
||||
return std::nullopt;
|
||||
|
||||
@@ -129,7 +129,7 @@ namespace StringUtils
|
||||
template<>
|
||||
std::optional<Database::TrackListId> readAs(std::string_view str)
|
||||
{
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, "-") };
|
||||
std::vector<std::string_view> values{ StringUtils::splitString(str, '-') };
|
||||
if (values.size() != 2)
|
||||
return std::nullopt;
|
||||
|
||||
|
||||
@@ -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, "/", "_");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ Log::Log(ILogger& logger, Module module, Severity severity)
|
||||
|
||||
Log::~Log()
|
||||
{
|
||||
assert(_logger.isSeverityActive(_severity));
|
||||
_logger.processLog(*this);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <cassert>
|
||||
#include <thread>
|
||||
|
||||
#include "utils/StreamLogger.hpp"
|
||||
@@ -29,7 +30,7 @@ StreamLogger::StreamLogger(std::ostream& os, EnumSet<Severity> severities)
|
||||
|
||||
void StreamLogger::processLog(const Log& log)
|
||||
{
|
||||
if (_severities.contains(log.getSeverity()))
|
||||
_os << std::this_thread::get_id() << " [" << getSeverityName(log.getSeverity()) << "] [" << getModuleName(log.getModule()) << "] " << log.getMessage() << std::endl;
|
||||
assert(isSeverityActive(log.getSeverity()));
|
||||
_os << std::this_thread::get_id() << " [" << getSeverityName(log.getSeverity()) << "] [" << getModuleName(log.getModule()) << "] " << log.getMessage() << std::endl;
|
||||
}
|
||||
|
||||
|
||||
+131
-67
@@ -23,17 +23,13 @@
|
||||
#include <iomanip>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/algorithm/string/split.hpp>
|
||||
#include <boost/algorithm/string/join.hpp>
|
||||
#include <boost/algorithm/string.hpp>
|
||||
|
||||
#include <Wt/WDateTime.h>
|
||||
#include <Wt/WDate.h>
|
||||
|
||||
namespace StringUtils
|
||||
{
|
||||
|
||||
namespace
|
||||
namespace details
|
||||
{
|
||||
constexpr std::pair<char, std::string_view> jsEscapeChars[]
|
||||
{
|
||||
@@ -87,32 +83,23 @@ namespace StringUtils
|
||||
os << c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool readList(const std::string& str, const std::string& separators, std::list<std::string>& results)
|
||||
{
|
||||
std::string curStr;
|
||||
|
||||
for (char c : str)
|
||||
template <typename StringType>
|
||||
std::string joinStrings(std::span<const StringType> strings, std::string_view delimiter)
|
||||
{
|
||||
if (separators.find(c) != std::string::npos) {
|
||||
if (!curStr.empty()) {
|
||||
results.push_back(curStr);
|
||||
curStr.clear();
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (curStr.empty() && std::isspace(c))
|
||||
continue;
|
||||
std::string res;
|
||||
bool first{ true };
|
||||
|
||||
curStr.push_back(c);
|
||||
for (const StringType& str : strings)
|
||||
{
|
||||
if (!first)
|
||||
res += delimiter;
|
||||
res += str;
|
||||
first = false;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
if (!curStr.empty())
|
||||
results.push_back(curStr);
|
||||
|
||||
return !str.empty();
|
||||
}
|
||||
|
||||
template<>
|
||||
@@ -138,57 +125,102 @@ namespace StringUtils
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<std::string> splitStringCopy(std::string_view string, std::string_view separators)
|
||||
std::vector<std::string_view> splitString(std::string_view str, char separator)
|
||||
{
|
||||
std::string str{ stringTrim(string, separators) };
|
||||
|
||||
std::vector<std::string> res;
|
||||
boost::algorithm::split(res, str, boost::is_any_of(separators), boost::token_compress_on);
|
||||
|
||||
return res;
|
||||
return splitString(str, std::string_view{ &separator, 1 });
|
||||
}
|
||||
|
||||
std::vector<std::string_view> splitString(std::string_view str, std::string_view separators)
|
||||
std::vector<std::string_view> splitString(std::string_view str, std::string_view separator)
|
||||
{
|
||||
std::vector<std::string_view> res;
|
||||
|
||||
std::string_view::size_type strBegin{};
|
||||
if (separator.empty())
|
||||
return { str };
|
||||
|
||||
while ((strBegin = str.find_first_not_of(separators, strBegin)) != std::string_view::npos)
|
||||
size_t pos{};
|
||||
size_t found{ str.find(separator) };
|
||||
|
||||
while (found != std::string_view::npos)
|
||||
{
|
||||
auto strEnd{ str.find_first_of(separators, strBegin + 1) };
|
||||
if (strEnd == std::string_view::npos)
|
||||
res.push_back(str.substr(pos, found - pos));
|
||||
pos = found + separator.size();
|
||||
found = str.find(separator, pos);
|
||||
}
|
||||
|
||||
res.push_back(str.substr(pos));
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
std::string joinStrings(std::span<const std::string_view> strings, std::string_view delimiter)
|
||||
{
|
||||
return details::joinStrings(strings, delimiter);
|
||||
}
|
||||
|
||||
std::string joinStrings(std::span<const std::string> strings, std::string_view delimiter)
|
||||
{
|
||||
return details::joinStrings(strings, delimiter);
|
||||
}
|
||||
|
||||
std::string joinStrings(std::span<const std::string> strings, char delimiter)
|
||||
{
|
||||
return details::joinStrings(strings, std::string_view{ &delimiter, 1 });
|
||||
}
|
||||
|
||||
std::string joinStrings(std::span<const std::string_view> strings, char delimiter)
|
||||
{
|
||||
return details::joinStrings(strings, std::string_view{ &delimiter, 1 });
|
||||
}
|
||||
|
||||
std::string escapeAndJoinStrings(std::span<const std::string_view> strings, char delimiter, char escapeChar)
|
||||
{
|
||||
std::string result;
|
||||
for (const std::string_view str : strings)
|
||||
{
|
||||
if (!result.empty())
|
||||
result.push_back(delimiter);
|
||||
|
||||
for (char c : str)
|
||||
{
|
||||
res.push_back(str.substr(strBegin, str.size() - strBegin));
|
||||
break;
|
||||
if (c == delimiter || c == escapeChar)
|
||||
result.push_back(escapeChar);
|
||||
|
||||
result.push_back(c);
|
||||
}
|
||||
|
||||
res.push_back(str.substr(strBegin, strEnd - strBegin));
|
||||
strBegin = strEnd + 1;
|
||||
}
|
||||
|
||||
return res;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string joinStrings(const std::vector<std::string_view>& strings, std::string_view delimiter)
|
||||
std::vector<std::string> splitEscapedStrings(std::string_view str, char delimiter, char escapeChar)
|
||||
{
|
||||
std::string res;
|
||||
bool first{ true };
|
||||
std::vector<std::string> result;
|
||||
std::string current;
|
||||
bool escaped{};
|
||||
|
||||
for (std::string_view str : strings)
|
||||
for (char c : str)
|
||||
{
|
||||
if (!first)
|
||||
res += delimiter;
|
||||
res += str;
|
||||
first = false;
|
||||
if (escaped) {
|
||||
current.push_back(c);
|
||||
escaped = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == delimiter)
|
||||
{
|
||||
result.push_back(std::move(current));
|
||||
current.clear();
|
||||
}
|
||||
else if (c == escapeChar)
|
||||
escaped = true;
|
||||
else
|
||||
current.push_back(c);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
if (!current.empty())
|
||||
result.push_back(std::move(current));
|
||||
|
||||
std::string joinStrings(const std::vector<std::string>& strings, const std::string& delimiter)
|
||||
{
|
||||
return boost::algorithm::join(strings, delimiter);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string_view stringTrim(std::string_view str, std::string_view whitespaces)
|
||||
@@ -237,7 +269,7 @@ namespace StringUtils
|
||||
return res;
|
||||
}
|
||||
|
||||
std::string bufferToString(const std::vector<unsigned char>& data)
|
||||
std::string bufferToString(std::span<const unsigned char> data)
|
||||
{
|
||||
std::ostringstream oss;
|
||||
|
||||
@@ -253,7 +285,7 @@ namespace StringUtils
|
||||
{
|
||||
if (strA.size() != strB.size())
|
||||
return false;
|
||||
|
||||
|
||||
for (std::size_t i{}; i < strA.size(); ++i)
|
||||
{
|
||||
if (std::tolower(strA[i]) != std::tolower(strB[i]))
|
||||
@@ -293,22 +325,22 @@ namespace StringUtils
|
||||
|
||||
std::string jsEscape(std::string_view str)
|
||||
{
|
||||
return escape(str, jsEscapeChars);
|
||||
return details::escape(str, details::jsEscapeChars);
|
||||
}
|
||||
|
||||
void writeJSEscapedString(std::ostream& os, std::string_view str)
|
||||
{
|
||||
writeEscapedString(os, str, jsEscapeChars);
|
||||
details::writeEscapedString(os, str, details::jsEscapeChars);
|
||||
}
|
||||
|
||||
|
||||
std::string jsonEscape(std::string_view str)
|
||||
{
|
||||
return escape(str, jsonEscapeChars);
|
||||
return details::escape(str, details::jsonEscapeChars);
|
||||
}
|
||||
|
||||
void writeJsonEscapedString(std::ostream& os, std::string_view str)
|
||||
{
|
||||
writeEscapedString(os, str, jsonEscapeChars);
|
||||
details::writeEscapedString(os, str, details::jsonEscapeChars);
|
||||
}
|
||||
|
||||
std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar)
|
||||
@@ -327,9 +359,41 @@ namespace StringUtils
|
||||
return res;
|
||||
}
|
||||
|
||||
bool stringEndsWith(const std::string& str, const std::string& ending)
|
||||
std::string unescapeString(std::string_view str, char escapeChar)
|
||||
{
|
||||
return boost::algorithm::ends_with(str, ending);
|
||||
std::string res;
|
||||
res.reserve(str.size());
|
||||
|
||||
bool escaped{};
|
||||
|
||||
for (char c : str)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
res += c;
|
||||
escaped = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == escapeChar)
|
||||
escaped = true;
|
||||
else
|
||||
res += c;
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped)
|
||||
res += escapeChar;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
bool stringEndsWith(std::string_view str, std::string_view ending)
|
||||
{
|
||||
if (str.length() < ending.length())
|
||||
return false;
|
||||
|
||||
return str.substr(str.length() - ending.length()) == ending;
|
||||
}
|
||||
|
||||
std::optional<std::string> stringFromHex(const std::string& str)
|
||||
|
||||
@@ -25,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:
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include <initializer_list>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <sstream>
|
||||
@@ -35,13 +36,18 @@ namespace Wt
|
||||
class WDateTime;
|
||||
}
|
||||
|
||||
namespace StringUtils {
|
||||
namespace StringUtils
|
||||
{
|
||||
[[nodiscard]] std::vector<std::string_view> splitString(std::string_view string, char separator);
|
||||
[[nodiscard]] std::vector<std::string_view> splitString(std::string_view string, std::string_view separator);
|
||||
|
||||
[[nodiscard]] std::vector<std::string> splitStringCopy(std::string_view string, std::string_view separators);
|
||||
[[nodiscard]] std::vector<std::string_view> splitString(std::string_view string, std::string_view separators);
|
||||
[[nodiscard]] std::string joinStrings(std::span<const std::string> strings, std::string_view delimiter);
|
||||
[[nodiscard]] std::string joinStrings(std::span<const std::string_view> strings, std::string_view delimiter);
|
||||
[[nodiscard]] std::string joinStrings(std::span<const std::string> strings, char delimiter);
|
||||
[[nodiscard]] std::string joinStrings(std::span<const std::string_view> strings, char delimiter);
|
||||
|
||||
[[nodiscard]] std::string joinStrings(const std::vector<std::string_view>& strings, std::string_view delimiter);
|
||||
[[nodiscard]] std::string joinStrings(const std::vector<std::string>& strings, const std::string& delimiter);
|
||||
[[nodiscard]] std::string escapeAndJoinStrings(std::span<const std::string_view> strings, char delimiter, char escapeChar);
|
||||
[[nodiscard]] std::vector<std::string> splitEscapedStrings(std::string_view string, char delimiter, char escapeChar);
|
||||
|
||||
[[nodiscard]] std::string_view stringTrim(std::string_view str, std::string_view whitespaces = " \t");
|
||||
[[nodiscard]] std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t");
|
||||
@@ -50,7 +56,7 @@ namespace StringUtils {
|
||||
void stringToLower(std::string& str);
|
||||
[[nodiscard]] std::string stringToUpper(const std::string& str);
|
||||
|
||||
[[nodiscard]] std::string bufferToString(const std::vector<unsigned char>& data);
|
||||
[[nodiscard]] std::string bufferToString(std::span<const unsigned char> data);
|
||||
|
||||
[[nodiscard]] bool stringCaseInsensitiveEqual(std::string_view strA, std::string_view strB);
|
||||
|
||||
@@ -86,8 +92,9 @@ namespace StringUtils {
|
||||
void writeJsonEscapedString(std::ostream& os, std::string_view str);
|
||||
|
||||
[[nodiscard]] std::string escapeString(std::string_view str, std::string_view charsToEscape, char escapeChar);
|
||||
[[nodiscard]] std::string unescapeString(std::string_view str, char escapeChar);
|
||||
|
||||
[[nodiscard]] bool stringEndsWith(const std::string& str, const std::string& ending);
|
||||
[[nodiscard]] bool stringEndsWith(std::string_view str, std::string_view ending);
|
||||
|
||||
[[nodiscard]] std::optional<std::string> stringFromHex(const std::string& str);
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ public:
|
||||
|
||||
std::string_view getAsString() const { return _value; }
|
||||
|
||||
bool operator<=>(const UUID&) const = default;
|
||||
|
||||
private:
|
||||
UUID(std::string_view value);
|
||||
std::string _value;
|
||||
|
||||
+132
-60
@@ -24,80 +24,77 @@
|
||||
#include <Wt/WTime.h>
|
||||
#include "utils/String.hpp"
|
||||
|
||||
TEST(StringUtils, splitString)
|
||||
|
||||
TEST(StringUtils, splitString_charDelim)
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
const std::string test{ "a" };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, "") };
|
||||
ASSERT_EQ(strings.size(), 1);
|
||||
EXPECT_EQ(strings.front(), "a");
|
||||
}
|
||||
std::string_view input;
|
||||
char delimiter;
|
||||
std::vector<std::string_view> expectedOutput;
|
||||
};
|
||||
|
||||
TestCase tests[]
|
||||
{
|
||||
const std::string test{ "a b" };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, "|") };
|
||||
ASSERT_EQ(strings.size(), 1);
|
||||
EXPECT_EQ(strings.front(), "a b");
|
||||
}
|
||||
{"abc", '-', {"abc"}},
|
||||
{"a", '-', {"a"}},
|
||||
{"", '-', {""}},
|
||||
{"a-b-c", '-', {"a", "b", "c"}},
|
||||
{"a|b|c", '|', {"a", "b", "c"}},
|
||||
{"a;b;c", ';', {"a", "b", "c"}},
|
||||
{";b;c", ';', {"", "b", "c"}},
|
||||
{" ;b;c", ';', {" ", "b", "c"}},
|
||||
{" ;;c", ';', {" ", "", "c"}},
|
||||
{" ; ;c", ';', {" ", " ", "c"}},
|
||||
{"a;b; ", ';', {"a", "b", " "}},
|
||||
{"a;b", ';', {"a", "b"}},
|
||||
{";b", ';', {"", "b"}},
|
||||
{";", ';', {"", ""}},
|
||||
{";;", ';', {"", "", ""}},
|
||||
{";;;", ';', {"", "", "", ""}},
|
||||
{";;a;;b;;", ';', {"", "", "a", "", "b", "", ""}},
|
||||
{"a b", ' ', {"a", "b"}},
|
||||
{"", ' ', {""}},
|
||||
{"a-b|c", '-', {"a","b|c"}},
|
||||
{"a|b-c", '-', {"a|b", "c"}},
|
||||
{"test=foo bar", '=', {"test", "foo bar"}},
|
||||
};
|
||||
|
||||
for (const TestCase& test : tests)
|
||||
{
|
||||
const std::string test{ " a" };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, " ") };
|
||||
ASSERT_EQ(strings.size(), 1);
|
||||
EXPECT_EQ(strings.front(), "a");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string test{ "a " };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, " ") };
|
||||
ASSERT_EQ(strings.size(), 1);
|
||||
EXPECT_EQ(strings.front(), "a");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string test{ "a b" };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, " ") };
|
||||
ASSERT_EQ(strings.size(), 2);
|
||||
EXPECT_EQ(strings.front(), "a");
|
||||
EXPECT_EQ(strings.back(), "b");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string test{ "a b,c|defgh " };
|
||||
|
||||
const std::vector<std::string_view> strings{ StringUtils::splitString(test, " ,|") };
|
||||
ASSERT_EQ(strings.size(), 4);
|
||||
EXPECT_EQ(strings[0], "a");
|
||||
EXPECT_EQ(strings[1], "b");
|
||||
EXPECT_EQ(strings[2], "c");
|
||||
EXPECT_EQ(strings[3], "defgh");
|
||||
const std::vector<std::string_view> res{ StringUtils::splitString(test.input, test.delimiter) };
|
||||
EXPECT_EQ(res, test.expectedOutput) << "Input = '" << test.input << "', delims = '" << test.delimiter << "'";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST(StringUtils, splitStringCopy)
|
||||
TEST(StringUtils, splitString_stringDelim)
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
const std::string test{ "test=foo" };
|
||||
|
||||
const std::vector<std::string> strings{ StringUtils::splitStringCopy(test, "=") };
|
||||
ASSERT_EQ(strings.size(), 2);
|
||||
EXPECT_EQ(strings[0], "test");
|
||||
EXPECT_EQ(strings[1], "foo");
|
||||
}
|
||||
std::string_view input;
|
||||
std::string_view delimiter;
|
||||
std::vector<std::string_view> expectedOutput;
|
||||
};
|
||||
|
||||
TestCase tests[]
|
||||
{
|
||||
const std::string test{ "test=foo bar" };
|
||||
{"abc", "", {"abc"}},
|
||||
{"abc", "-", {"abc"}},
|
||||
{"abc", "b", {"a", "c"}},
|
||||
{"ab/cd", "/", {"ab", "cd"}},
|
||||
{"ab/cd", "/ ", {"ab/cd"}},
|
||||
{"ab/cd", " /", {"ab/cd"}},
|
||||
{"ab /cd", " /", {"ab", "cd"}},
|
||||
{"ab/ cd", "/ ", {"ab", "cd"}},
|
||||
{"ab / cd", " / ", {"ab", "cd"}},
|
||||
{"ab/cd", " / ", {"ab/cd"}},
|
||||
{"ab/cd / ", " / ", {"ab/cd", ""}},
|
||||
};
|
||||
|
||||
const std::vector<std::string> strings{ StringUtils::splitStringCopy(test, "=") };
|
||||
ASSERT_EQ(strings.size(), 2);
|
||||
EXPECT_EQ(strings[0], "test");
|
||||
EXPECT_EQ(strings[1], "foo bar");
|
||||
for (const TestCase& test : tests)
|
||||
{
|
||||
const std::vector<std::string_view> res{ StringUtils::splitString(test.input, test.delimiter) };
|
||||
EXPECT_EQ(res, test.expectedOutput) << "Input = '" << test.input << "', delims = '" << test.delimiter << "'";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +125,58 @@ TEST(StringUtils, joinStrings)
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StringUtils, escapeAndJoinStrings)
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
std::vector<std::string_view> input;
|
||||
char delimiter;
|
||||
char escapeChar;
|
||||
std::string expectedOutput;
|
||||
};
|
||||
|
||||
TestCase tests[]
|
||||
{
|
||||
{{""}, ';', '\\', ""},
|
||||
{{";"}, ';', '\\', "\\;"},
|
||||
{{";;"}, ';', '\\', "\\;\\;"},
|
||||
{{"a;", "b"}, ';', '\\', "a\\;;b"},
|
||||
{{"a;", "b;"}, ';', '\\', "a\\;;b\\;"},
|
||||
};
|
||||
|
||||
for (const TestCase& test : tests)
|
||||
{
|
||||
const std::string str{ StringUtils::escapeAndJoinStrings(test.input, test.delimiter, test.escapeChar) };
|
||||
EXPECT_EQ(str, test.expectedOutput);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StringUtils, splitEscapedStrings)
|
||||
{
|
||||
struct TestCase
|
||||
{
|
||||
std::string input;
|
||||
char delimiter;
|
||||
char escapeChar;
|
||||
std::vector<std::string> expectedOutput;
|
||||
};
|
||||
|
||||
TestCase tests[]
|
||||
{
|
||||
{"", ';', '\\', {}},
|
||||
{"\\;", ';', '\\', {";"}},
|
||||
{"\\;\\;", ';', '\\', {";;"}},
|
||||
{"a\\;;b", ';', '\\', {"a;", "b"}},
|
||||
{"a\\;;b\\;", ';', '\\', {"a;", "b;"}},
|
||||
};
|
||||
|
||||
for (const TestCase& test : tests)
|
||||
{
|
||||
const std::vector<std::string> str{ StringUtils::splitEscapedStrings(test.input, test.delimiter, test.escapeChar) };
|
||||
EXPECT_EQ(str, test.expectedOutput);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(StringUtils, escapeJSString)
|
||||
{
|
||||
EXPECT_EQ(StringUtils::jsEscape(""), "");
|
||||
@@ -153,6 +202,17 @@ TEST(StringUtils, escapeString)
|
||||
EXPECT_EQ(StringUtils::escapeString("*a*", "*", '_'), "_*a_*");
|
||||
EXPECT_EQ(StringUtils::escapeString("*a|", "*|", '_'), "_*a_|");
|
||||
EXPECT_EQ(StringUtils::escapeString("**||", "*|", '_'), "_*_*_|_|");
|
||||
EXPECT_EQ(StringUtils::escapeString("one;two", ";", '\\'), "one\\;two");
|
||||
EXPECT_EQ(StringUtils::escapeString("one\\;two", ";", '\\'), "one\\\\;two");
|
||||
EXPECT_EQ(StringUtils::escapeString("one;", ";", '\\'), "one\\;");
|
||||
}
|
||||
|
||||
TEST(StringUtils, unescapeString)
|
||||
{
|
||||
EXPECT_EQ(StringUtils::unescapeString("one\\", '\\'), "one\\");
|
||||
EXPECT_EQ(StringUtils::unescapeString("\\\\one", '\\'), "\\one");
|
||||
EXPECT_EQ(StringUtils::unescapeString("one\\;two", '\\'), "one;two");
|
||||
EXPECT_EQ(StringUtils::unescapeString("one\\\\;two", '\\'), "one\\;two");
|
||||
}
|
||||
|
||||
TEST(StringUtils, readAs_bool)
|
||||
@@ -220,4 +280,16 @@ TEST(Stringutils, dateTime)
|
||||
{
|
||||
const Wt::WDateTime dateTime{ Wt::WDate {2020, 01, 03 }, Wt::WTime{9, 8, 11, 75} };
|
||||
EXPECT_EQ(StringUtils::toISO8601String(dateTime), "2020-01-03T09:08:11.075");
|
||||
}
|
||||
|
||||
TEST(StringUtils, stringEndsWith)
|
||||
{
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "Bar"));
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", ""));
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("", ""));
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "ar"));
|
||||
EXPECT_TRUE(StringUtils::stringEndsWith("FooBar", "FooBar"));
|
||||
EXPECT_FALSE(StringUtils::stringEndsWith("FooBar", "1FooBar"));
|
||||
EXPECT_FALSE(StringUtils::stringEndsWith("FooBar", "1FooBar"));
|
||||
EXPECT_FALSE(StringUtils::stringEndsWith("FooBar", "R"));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -45,6 +45,24 @@ namespace UserInterface
|
||||
|
||||
namespace
|
||||
{
|
||||
class TagDelimitersValidator : public Wt::WValidator
|
||||
{
|
||||
private:
|
||||
Wt::WValidator::Result validate(const Wt::WString& input) const override
|
||||
{
|
||||
if (input.empty())
|
||||
return Wt::WValidator::Result{ Wt::ValidationState::Valid };
|
||||
|
||||
std::string inputStr{ input.toUTF8() };
|
||||
if (std::all_of(std::cbegin(inputStr), std::cend(inputStr), [](char c) { return std::isspace(c); }))
|
||||
return Wt::WValidator::Result{ Wt::ValidationState::Invalid, Wt::WString::tr("Lms.Admin.Database.tag-delimiter-must-not-contain-only-spaces") };
|
||||
|
||||
return Wt::WValidator::Result{ Wt::ValidationState::Valid };
|
||||
}
|
||||
|
||||
std::string javaScriptValidate() const override { return {}; }
|
||||
};
|
||||
|
||||
class DatabaseSettingsModel : public Wt::WFormModel
|
||||
{
|
||||
public:
|
||||
@@ -52,10 +70,12 @@ namespace UserInterface
|
||||
static inline constexpr Field UpdateStartTimeField{ "update-start-time" };
|
||||
static inline constexpr Field SimilarityEngineTypeField{ "similarity-engine-type" };
|
||||
static inline constexpr Field ExtraTagsField{ "extra-tags-to-scan" };
|
||||
static inline constexpr Field ArtistTagDelimiterField{ "artist-tag-delimiter" };
|
||||
static inline constexpr Field DefaultTagDelimiterField{ "default-tag-delimiter" };
|
||||
|
||||
using UpdatePeriodModel = ValueStringModel<ScanSettings::UpdatePeriod>;
|
||||
|
||||
static inline constexpr std::string_view extraTagsDelimiter{ ";" };
|
||||
static inline constexpr char extraTagsDelimiter{ ';' };
|
||||
|
||||
DatabaseSettingsModel()
|
||||
{
|
||||
@@ -65,11 +85,15 @@ namespace UserInterface
|
||||
addField(UpdateStartTimeField);
|
||||
addField(SimilarityEngineTypeField);
|
||||
addField(ExtraTagsField);
|
||||
addField(ArtistTagDelimiterField);
|
||||
addField(DefaultTagDelimiterField);
|
||||
|
||||
setValidator(UpdatePeriodField, createMandatoryValidator());
|
||||
setValidator(UpdateStartTimeField, createMandatoryValidator());
|
||||
setValidator(SimilarityEngineTypeField, createMandatoryValidator());
|
||||
setValidator(ExtraTagsField, createUppercaseValidator());
|
||||
setValidator(ArtistTagDelimiterField, std::make_unique<TagDelimitersValidator>());
|
||||
setValidator(DefaultTagDelimiterField, std::make_unique<TagDelimitersValidator>());
|
||||
|
||||
// populate the model with initial data
|
||||
loadData();
|
||||
@@ -103,8 +127,18 @@ namespace UserInterface
|
||||
if (similarityEngineTypeRow)
|
||||
setValue(SimilarityEngineTypeField, _similarityEngineTypeModel->getString(*similarityEngineTypeRow));
|
||||
|
||||
auto extraTags{ scanSettings->getExtraTagsToScan() };
|
||||
setValue(ExtraTagsField, StringUtils::joinStrings(scanSettings->getExtraTagsToScan(), extraTagsDelimiter));
|
||||
const auto extraTags{ scanSettings->getExtraTagsToScan() };
|
||||
setValue(ExtraTagsField, StringUtils::joinStrings(extraTags, extraTagsDelimiter));
|
||||
|
||||
{
|
||||
std::vector<std::string> delimiters{ scanSettings->getArtistTagDelimiters() };
|
||||
setValue(ArtistTagDelimiterField, delimiters.empty() ? "" : delimiters.front());
|
||||
}
|
||||
|
||||
{
|
||||
std::vector<std::string> delimiters{ scanSettings->getDefaultTagDelimiters() };
|
||||
setValue(DefaultTagDelimiterField, delimiters.empty() ? "" : delimiters.front());
|
||||
}
|
||||
}
|
||||
|
||||
void saveData()
|
||||
@@ -126,6 +160,20 @@ namespace UserInterface
|
||||
scanSettings.modify()->setSimilarityEngineType(_similarityEngineTypeModel->getValue(*similarityEngineTypeRow));
|
||||
|
||||
scanSettings.modify()->setExtraTagsToScan(StringUtils::splitString(valueText(ExtraTagsField).toUTF8(), extraTagsDelimiter));
|
||||
|
||||
{
|
||||
std::vector<std::string_view> artistDelimiters;
|
||||
if (std::string artistDelimiter{ valueText(ArtistTagDelimiterField).toUTF8() }; !artistDelimiter.empty())
|
||||
artistDelimiters.push_back(std::move(artistDelimiter));
|
||||
scanSettings.modify()->setArtistTagDelimiters(artistDelimiters);
|
||||
}
|
||||
|
||||
{
|
||||
std::vector<std::string_view> defaultDelimiters;
|
||||
if (std::string defaultDelimiter{ valueText(DefaultTagDelimiterField).toUTF8() }; !defaultDelimiter.empty())
|
||||
defaultDelimiters.push_back(std::move(defaultDelimiter));
|
||||
scanSettings.modify()->setDefaultTagDelimiters(defaultDelimiters);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
@@ -201,6 +249,12 @@ namespace UserInterface
|
||||
// Extra tags
|
||||
t->setFormWidget(DatabaseSettingsModel::ExtraTagsField, std::make_unique<Wt::WLineEdit>());
|
||||
|
||||
// Artist tag delimiter
|
||||
t->setFormWidget(DatabaseSettingsModel::ArtistTagDelimiterField, std::make_unique<Wt::WLineEdit>());
|
||||
|
||||
// Default tag delimiter
|
||||
t->setFormWidget(DatabaseSettingsModel::DefaultTagDelimiterField, std::make_unique<Wt::WLineEdit>());
|
||||
|
||||
// Buttons
|
||||
Wt::WPushButton* saveBtn = t->bindWidget("save-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.save")));
|
||||
Wt::WPushButton* discardBtn = t->bindWidget("discard-btn", std::make_unique<Wt::WPushButton>(Wt::WString::tr("Lms.discard")));
|
||||
|
||||
@@ -22,20 +22,20 @@
|
||||
|
||||
namespace UserInterface
|
||||
{
|
||||
void
|
||||
Template::applyArguments(WWidget* widget, const std::vector<Wt::WString>& args)
|
||||
{
|
||||
for (const Wt::WString& arg : args)
|
||||
{
|
||||
const std::vector<std::string> operands {StringUtils::splitStringCopy(arg.toUTF8(), "=")};
|
||||
void Template::applyArguments(WWidget* widget, const std::vector<Wt::WString>& args)
|
||||
{
|
||||
for (const Wt::WString& argStr : args)
|
||||
{
|
||||
std::string arg{ argStr.toUTF8() };
|
||||
const std::vector<std::string_view> operands{ StringUtils::splitString(arg, '=') };
|
||||
|
||||
if (operands.size() == 2)
|
||||
{
|
||||
if (operands[0] == "class")
|
||||
widget->addStyleClass(operands[1]);
|
||||
else
|
||||
widget->setAttributeValue(operands[0], operands[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (operands.size() == 2)
|
||||
{
|
||||
if (operands[0] == "class")
|
||||
widget->addStyleClass(std::string{ operands[1] });
|
||||
else
|
||||
widget->setAttributeValue(std::string{ operands[0] }, std::string{ operands[1] });
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace UserInterface
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace UserInterface
|
||||
void DatabaseCollectorBase::setSearch(std::string_view searchText)
|
||||
{
|
||||
_searchText = searchText;
|
||||
_searchKeywords = StringUtils::splitString(_searchText, " ");
|
||||
_searchKeywords = StringUtils::splitString(_searchText, ' ');
|
||||
}
|
||||
|
||||
} // ns UserInterface
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user