diff --git a/INSTALL.md b/INSTALL.md index cecb7aae..115ddc3c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -8,10 +8,11 @@ + [Upgrade](#upgrade) - [Deployment](#deployment) * [Configuration](#configuration) - * [Authentication backend](#authentication-backend) + * [Authentication backends](#authentication-backends) * [Deploy on non root path](#deploy-on-non-root-path) * [Reverse proxy settings](#reverse-proxy-settings) - [Run](#run) +- [First launch](#first-launch) # Installation ## Docker _Docker_ images are available, please see detailed instructions on https://hub.docker.com/r/epoupon/lms. @@ -100,7 +101,7 @@ __Note__: don't forget to give the _lms_ user read access to the music directory _LMS_ uses a configuration file, installed by default in `/etc/lms.conf`. It is recommended to edit this file and change relevant settings (listen address, listen port, working directory, Subsonic API activation, deployment path, ...). All other settings are set using the web interface (user management, scan settings, transcode settings, ...). If a setting is not present in the configuration file, a hardcoded default value is used (the same as in the [default configuration file](conf/lms.conf)) -## Authentication backend +## Authentication backends You can define which authentication backend to be used thanks to the `authentication-backend` option: * `internal` (default): _LMS_ uses an internal database to store users and their associated passwords (salted and hashed using [Bcrypt](https://en.wikipedia.org/wiki/Bcrypt)). Only the admin user can create, edit or remove other users. * `PAM`: the user/password authentication request is forwarded to PAM (see the default [PAM configuration file](conf/pam/lms) provided). @@ -170,3 +171,10 @@ Log traces can be accessed using journactl: journalctl -u lms.service ``` To connect to _LMS_, just open your favorite browser and go to `http://localhost:5082` +# First launch +At the first launch, a setup assistant will guide you through creating the administrator account (username and password). Once this information is saved, you can refresh the page to access the _LMS_ login screen. +__Note__: If you are using `PAM` or `http-header` authentication, this setup assistant will not appear (see [Authentication backends](#authentication-backends)). + +Once logged in as an administrator, you will need to define your libraries—that is, select the music folders you want to scan. Then, check that the scan settings meet your needs (scan frequency, tag delimiters, etc.). + +You can now initiate the initial scan to import your music into _LMS_. \ No newline at end of file diff --git a/SUBSONIC.md b/SUBSONIC.md index cc3a6d6f..a4bcefb6 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -51,3 +51,4 @@ The following extra fields are implemented: ## Supported extensions * [Transcode offset](https://opensubsonic.netlify.app/docs/extensions/transcodeoffset/) +* [Song Lyrics](https://opensubsonic.netlify.app/docs/extensions/songlyrics/) diff --git a/approot/messages.xml b/approot/messages.xml index 1a7e401c..e34e36cf 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -34,6 +34,13 @@ User + +. +, +MM/dd/yyyy +HH:mm:ss +MM/dd/yyyy HH:mm:ss + Artist not found Error occured! @@ -89,6 +96,7 @@ Cannot get track duration Cannot parse audio file Cannot parse image file +Cannot parse lyrics file Cannot read file Compact the database. Caution: this may take a while and will block the whole application during the compact step! {1} duplicate files: @@ -98,7 +106,7 @@ Get report Last scan Not available -Scanned {1} files in {2} on {3} ({4} errors, {5} duplicates) +{1} files were scanned in {2} at {4} on {3} (UTC) - {5} errors, {6} duplicates No audio track Duplicated file hash Duplicated track MBID @@ -107,9 +115,10 @@ Scanner Status Not scheduled -Scheduled on {1} +Scheduled at {2} on {1} (UTC) Scanning: step {1}/{2} Associating artist images: {1}%... +Associating external lyrics: {1}%... Associating release images: {1}%... Checking for duplicate files... {1} files Checking for removed files... {1}% @@ -182,6 +191,7 @@ Star Starred Track information +Lyrics Playlists Tracks Type diff --git a/approot/messages_en-US.xml b/approot/messages_en-US.xml new file mode 100644 index 00000000..77611a53 --- /dev/null +++ b/approot/messages_en-US.xml @@ -0,0 +1,12 @@ + + + + +. +, +MM/dd/yyyy +hh:mm:ss a +MM/dd/yyyy hh:mm:ss a + + \ No newline at end of file diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index 5e3cdef5..99291866 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -34,6 +34,13 @@ Utilisateur + +, + +dd/MM/yyyy +HH:mm:ss +dd/MM/yyyy HH:mm:ss + Cet artiste n'existe pas Une erreur est survenue! @@ -89,6 +96,7 @@ Impossible de récupérer la durée de la piste Impossible d'analyser le fichier audio Impossible d'analyser le fichier image +Impossible d'analyser le fichier de paroles Impossible de lire le fichier Compacter la base de données. Attention : cette opération peut prendre du temps et va vérouiller l'application pendant toute l'étape de compactage! {1} fichiers dupliqués : @@ -98,7 +106,7 @@ Rapport Dernier scan Non disponible -{1} fichiers scannés en {2} le {3} ({4} erreurs, {5} duplicatas) +{1} fichiers analysés en {2} à {4} le {3} (UTC) - {5} erreurs, {6} duplicatas Pas de piste audio Hash dupliqué Track MBID dupliqué @@ -107,9 +115,10 @@ Scanner Statut Non planifié -Planifié le {1} +Planifié à {2} le {1} (UTC) En cours de scan : étape {1}/{2} Association des images des artistes: {1}%... +Association des paroles externes: {1}%... Association des images des albums: {1}%... Vérification des fichiers dupliqués... {1} fichiers Vérification des fichiers supprimés... {1}% @@ -182,6 +191,7 @@ Ajouter aux favoris Favoris Informations sur la piste +Paroles Playlists Pistes Type @@ -328,6 +338,36 @@ Paramètres sauvegardés ! +moins d'une seconde + + une second + {1} secondes + + + une minute + {1} minutes + + + une houre + {1} houres + + + un jour + {1} jours + + + une semaine + {1} semaines + + + un mois + {1} mois + + + une année + {1} années + + Oui Non Ce champ ne peut pas être vide diff --git a/approot/messages_it.xml b/approot/messages_it.xml index e5755c71..749ca015 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -34,6 +34,13 @@ Utente + +, +. +dd/MM/yyyy +HH:mm:ss +dd/MM/yyyy HH:mm:ss + Artista non trovato C'è stato un errore! @@ -89,6 +96,7 @@ Non sono stato in grado di determinare la durata della traccia Impossibile analizzare il file audio Impossibile analizzare il file immagine +Impossibile analizzare il file dei testi Non in grado di leggere il file Compatta il database. Attenzione: ciò potrebbe richiedere del tempo e bloccherà l'intera applicazione durante il passaggio di compattazione! {1} file duplicati: @@ -98,7 +106,7 @@ Scarica un resoconto Ultima scansione Non disponibile -Scansionati {1} files in {2} su {3} ({4} errori, {5} duplicati) +{1} file scansionati in {2} alle {3} il {4} (UTC) - {5} errori, {6} duplicati Nessuna traccia audio Hash doppio Track MBID doppio @@ -107,9 +115,10 @@ Scanner Stato Non pianificato -Pianificato il {1} +Programmato alle {2} del {1} (UTC) Scansione: passo {1}/{2} Associando immagini degli artisti: {1}%... +Associazione dei testi esterni: {1}%... Associando immagini degli album: {1}%... Controllo duplicati... {1} files Controllo file... {1}% @@ -182,6 +191,7 @@ Aggiungi ai preferiti Preferiti Informazioni sulla traccia +Testo Playlist Tracce Tipo @@ -328,6 +338,36 @@ Nuove impostazioni salvate! +meno di un secondo + + un secondo + {1} secondi + + + un minuto + {1} minuti + + + un'ora + {1} ore + + + un giorno + {1} giorni + + + una settimana + {1} settimane + + + un mese + {1} mesi + + + un anno + {1} anni + + Si No Questo campo non può essere vuoto diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index 01343f09..ae9bf9c4 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -35,6 +35,13 @@ Użytkownik + +, + +dd.MM.yyyy +HH:mm:ss +dd.MM.yyyy HH:mm:ss + Artysta nie odnaleziony Wystąpił błąd! @@ -90,6 +97,7 @@ Nie udało się ustalić długości ścieżki Nie można przeanalizować pliku audio Nie można przeanalizować pliku obrazu +Nie można przetworzyć pliku z tekstem Nie udało się odczytać pliku Sprasuj bazę danych. Uwaga: może to trochę zająć, a cała aplikacja będzie w tym czasie zablokowana! @@ -107,7 +115,7 @@ Pokaż raport Ostatnie skanowanie Niedostępne -Przeskanowano {1} plików w {2} o {3} ({4} błędów, {5} duplikatów) +Przeskanowano {1} plików w {2} o {4} dnia {3} (UTC) - {5} błędów, {6} duplikatów Brak ścieżki dźwiękowej Zduplikowny hash pliku Zduplikowany MBID pliku @@ -116,9 +124,10 @@ Skaner Status Nie zaplanowano -Zaplanowano na {1} +Zaplanowano o {2} dnia {1} (UTC) Skanowanie: krok {1}/{2} Kojarzenie obrazów artystów: {1}%... +Kojarzenie zewnętrznych tekstów: {1}%... Kojarzenie obrazów albumów: {1}%... Sprawdzanie duplikatów... {1} plik @@ -199,6 +208,7 @@ Wyróżnij Wyróżnione Informacje o ścieżce +Tekst Listy odtwarzania Ścieżki Rodzaj @@ -353,4 +363,42 @@ Nigdy Ustawienia zapisane! + +mniej niż sekunda + + jedna sekunda + {1} sekundy + {1} sekund + + + jedna minuta + {1} minuty + {1} minut + + + jedna godzina + {1} godziny + {1} godzin + + + jeden dzień + {1} dni + {1} dni + + + jeden tydzień + {1} tygodnie + {1} tygodni + + + jeden miesiąc + {1} miesiące + {1} miesięcy + + + jeden rok + {1} lata + {1} lat + + diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index efb8dd4f..7b44aa1e 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -34,6 +34,13 @@ 用户 + +. +, +yyyy/MM/dd +HH:mm:ss +yyyy/MM/dd HH:mm:ss + 歌手未找到 发生错误! @@ -85,13 +92,13 @@ 更新开始时间 每周 - 无法获得音轨时间 无法解析文件 无法读取文件 + {1} 个重复文件: {1} 个错误: @@ -99,7 +106,7 @@ 生成报告 最后扫描 不可用 -在 {2} 内扫描了 {3} 中的 {1} 个文件({4} 个错误, {5} 个副本) +已扫描 {1} 个文件,用时 {2},在 {3} 的 {4} (UTC) - {5} 个错误,{6} 个重复项 没有音轨 相同文件哈希值 相同 MBID @@ -108,11 +115,12 @@ 扫描器 状态 无计划 -计划于 {1} +计划于 {2} 在 {1} (UTC) 扫描中: 阶段 {1}/{2} + 检查文件中... {1}% @@ -183,6 +191,7 @@ 收藏 已收藏 + 播放列表 音轨 类型 diff --git a/approot/release.xml b/approot/release.xml index 4428ed2a..448868d9 100644 --- a/approot/release.xml +++ b/approot/release.xml @@ -102,6 +102,7 @@
  • ${star class="dropdown-item"}
  • ${download class="dropdown-item"}
  • ${track-info class="dropdown-item"}
  • + ${}
  • ${track-lyrics class="dropdown-item"}
  • ${
    } diff --git a/approot/tracks.xml b/approot/tracks.xml index 25a5984c..f8caea0e 100644 --- a/approot/tracks.xml +++ b/approot/tracks.xml @@ -60,6 +60,7 @@
  • ${star class="dropdown-item"}
  • ${download class="dropdown-item"}
  • ${track-info class="dropdown-item"}
  • + ${}
  • ${track-lyrics class="dropdown-item"}
  • ${
    } @@ -139,4 +140,24 @@
    + + + + + diff --git a/src/libs/core/impl/Path.cpp b/src/libs/core/impl/Path.cpp index 75b30c5a..ff44a485 100644 --- a/src/libs/core/impl/Path.cpp +++ b/src/libs/core/impl/Path.cpp @@ -133,7 +133,7 @@ namespace lms::core::pathUtils return true; } - bool hasFileAnyExtension(const std::filesystem::path& file, const std::vector& supportedExtensions) + bool hasFileAnyExtension(const std::filesystem::path& file, std::span supportedExtensions) { const std::filesystem::path extension{ stringUtils::stringToLower(file.extension().string()) }; diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp index 226caa1d..9a736474 100644 --- a/src/libs/core/impl/String.cpp +++ b/src/libs/core/impl/String.cpp @@ -149,9 +149,9 @@ namespace lms::core::stringUtils template<> std::optional readAs(std::string_view str) { - if (str == "1" || str == "true") + if (str == "1" || stringCaseInsensitiveEqual(str, "true")) return true; - else if (str == "0" || str == "false") + else if (str == "0" || stringCaseInsensitiveEqual(str, "false")) return false; return std::nullopt; @@ -463,4 +463,17 @@ namespace lms::core::stringUtils // assume UTC return date.toString("yyyy-MM-dd").toUTF8(); } + + std::string formatTimestamp(std::chrono::milliseconds timestamp) + { + using namespace std::chrono; + + const auto mins{ duration_cast(timestamp).count() }; + timestamp -= duration_cast(minutes{ mins }); + const auto secs{ duration_cast(timestamp).count() }; + timestamp -= duration_cast(seconds{ secs }); + const auto millis{ timestamp.count() }; + + return "[" + std::to_string(mins) + ":" + (secs < 10 ? "0" : "") + std::to_string(secs) + "." + (millis < 100 ? (millis < 10 ? "00" : "0") : "") + std::to_string(millis) + "]"; + } } // namespace lms::core::stringUtils diff --git a/src/libs/core/include/core/Path.hpp b/src/libs/core/include/core/Path.hpp index f49edaf4..500c9af3 100644 --- a/src/libs/core/include/core/Path.hpp +++ b/src/libs/core/include/core/Path.hpp @@ -21,8 +21,8 @@ #include #include +#include #include -#include #include @@ -41,7 +41,7 @@ namespace lms::core::pathUtils bool exploreFilesRecursive(const std::filesystem::path& directory, std::function cb, const std::filesystem::path* excludeDirFileName = {}); // Check if file's extension is one of provided extensions - bool hasFileAnyExtension(const std::filesystem::path& file, const std::vector& extensions); + bool hasFileAnyExtension(const std::filesystem::path& file, std::span extensions); // Check if a path is within a directory (excludeDirFileName is a relative can be used to exclude a whole directory and its subdirectory, must not have parent_path) // Caller responsibility to call with normalized paths diff --git a/src/libs/core/include/core/String.hpp b/src/libs/core/include/core/String.hpp index 2e1a488b..867e73e6 100644 --- a/src/libs/core/include/core/String.hpp +++ b/src/libs/core/include/core/String.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include #include @@ -51,8 +52,8 @@ namespace lms::core::stringUtils [[nodiscard]] std::string escapeAndJoinStrings(std::span strings, char delimiter, char escapeChar); [[nodiscard]] std::vector 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"); + [[nodiscard]] std::string_view stringTrim(std::string_view str, std::string_view whitespaces = " \t\r"); + [[nodiscard]] std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t\r"); [[nodiscard]] std::string stringToLower(std::string_view str); void stringToLower(std::string& str); @@ -113,4 +114,7 @@ namespace lms::core::stringUtils [[nodiscard]] std::string toISO8601String(const Wt::WDateTime& dateTime); [[nodiscard]] std::string toISO8601String(const Wt::WDate& date); + + // to "[minutes:seconds.milliseconds]" + std::string formatTimestamp(std::chrono::milliseconds timestamp); } // namespace lms::core::stringUtils \ No newline at end of file diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index 1b840fa0..3b2bf1d2 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -23,6 +23,7 @@ add_library(lmsdatabase SHARED impl/SqlQuery.cpp impl/Track.cpp impl/TrackBookmark.cpp + impl/TrackLyrics.cpp impl/Types.cpp impl/UIState.cpp impl/User.cpp diff --git a/src/libs/database/impl/AuthToken.cpp b/src/libs/database/impl/AuthToken.cpp index 79bc2abb..5dea83c0 100644 --- a/src/libs/database/impl/AuthToken.cpp +++ b/src/libs/database/impl/AuthToken.cpp @@ -46,7 +46,7 @@ namespace lms::db { session.checkWriteTransaction(); - session.getDboSession()->execute("DELETE FROM auth_token WHERE expiry < ?").bind(now); + utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token WHERE expiry < ?", now); } AuthToken::pointer AuthToken::find(Session& session, std::string_view value) diff --git a/src/libs/database/impl/Directory.cpp b/src/libs/database/impl/Directory.cpp index 726b15e6..7bed3d4a 100644 --- a/src/libs/database/impl/Directory.cpp +++ b/src/libs/database/impl/Directory.cpp @@ -169,9 +169,11 @@ namespace lms::db query.leftJoin("directory d_child ON d.id = d_child.parent_directory_id"); query.leftJoin("track t ON d.id = t.directory_id"); query.leftJoin("image i ON d.id = i.directory_id"); + query.leftJoin("track_lyrics l_lrc ON d.id = l_lrc.directory_id"); query.where("d_child.id IS NULL"); query.where("t.directory_id IS NULL"); query.where("i.directory_id IS NULL"); + query.where("l_lrc.directory_id IS NULL"); return utils::execRangeQuery(query, range); } diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index 7622706c..d1a0a1ce 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -35,7 +35,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 70 }; + static constexpr Version LMS_DATABASE_VERSION{ 71 }; } VersionInfo::VersionInfo() @@ -92,7 +92,7 @@ namespace lms::db::Migration { // remove name from track_artist_link // Drop Auth mode - session.getDboSession()->execute(R"( + utils::executeCommand(*session.getDboSession(), R"( CREATE TABLE IF NOT EXISTS "track_artist_link_backup" ( "id" integer primary key autoincrement, "version" integer not null, @@ -103,49 +103,49 @@ CREATE TABLE IF NOT EXISTS "track_artist_link_backup" ( constraint "fk_track_artist_link_artist" foreign key ("artist_id") references "artist" ("id") on delete cascade deferrable initially deferred ); ))"); - session.getDboSession()->execute("INSERT INTO track_artist_link_backup SELECT id, version, type, track_id, artist_id FROM track_artist_link"); - session.getDboSession()->execute("DROP TABLE track_artist_link"); - session.getDboSession()->execute("ALTER TABLE track_artist_link_backup RENAME TO track_artist_link"); + utils::executeCommand(*session.getDboSession(), "INSERT INTO track_artist_link_backup SELECT id, version, type, track_id, artist_id FROM track_artist_link"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE track_artist_link"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_artist_link_backup RENAME TO track_artist_link"); } void migrateFromV34(Session& session) { // Add scrobbling state // By default, everything needs to be sent - session.getDboSession()->execute("ALTER TABLE starred_artist ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(/*ScrobblingState::PendingAdd*/ 0)) + ")"); - session.getDboSession()->execute("ALTER TABLE starred_release ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(/*ScrobblingState::PendingAdd*/ 0)) + ")"); - session.getDboSession()->execute("ALTER TABLE starred_track ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(/*ScrobblingState::PendingAdd*/ 0)) + ")"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_artist ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(/*ScrobblingState::PendingAdd*/ 0)) + ")"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_release ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(/*ScrobblingState::PendingAdd*/ 0)) + ")"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_track ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(/*ScrobblingState::PendingAdd*/ 0)) + ")"); } void migrateFromV35(Session& session) { // Add creattion/last modif date time for tracklists - session.getDboSession()->execute("ALTER TABLE tracklist ADD creation_date_time TEXT"); - session.getDboSession()->execute("ALTER TABLE tracklist ADD last_modified_date_time TEXT"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE tracklist ADD creation_date_time TEXT"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE tracklist ADD last_modified_date_time TEXT"); } void migrateFromV36(Session& session) { // Increased precision for track durations (now in milliseconds instead of secodns) // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV37(Session& session) { // Support Performer tags (via subtypes) - session.getDboSession()->execute("ALTER TABLE track_artist_link ADD subtype TEXT"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_artist_link ADD subtype TEXT"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV38(Session& session) { // migrate release-specific tags from Track to Release - session.getDboSession()->execute("ALTER TABLE release ADD total_disc INTEGER"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD total_disc INTEGER"); - session.getDboSession()->execute(R"( + utils::executeCommand(*session.getDboSession(), R"( CREATE TABLE IF NOT EXISTS "track_backup" ( "id" integer primary key autoincrement, "version" integer not null, @@ -172,158 +172,158 @@ CREATE TABLE IF NOT EXISTS "track_backup" ( constraint "fk_track_release" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred ); ))"); - session.getDboSession()->execute("INSERT INTO track_backup SELECT id, version, scan_version, track_number, disc_number, total_track, disc_subtitle, name, duration, date, original_date, file_path, file_last_write, file_added, has_cover, mbid, recording_mbid, copyright, copyright_url, track_replay_gain, release_replay_gain, release_id FROM track"); - session.getDboSession()->execute("DROP TABLE track"); - session.getDboSession()->execute("ALTER TABLE track_backup RENAME TO track"); + utils::executeCommand(*session.getDboSession(), "INSERT INTO track_backup SELECT id, version, scan_version, track_number, disc_number, total_track, disc_subtitle, name, duration, date, original_date, file_path, file_last_write, file_added, has_cover, mbid, recording_mbid, copyright, copyright_url, track_replay_gain, release_replay_gain, release_id FROM track"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE track"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_backup RENAME TO track"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV39(Session& session) { // add release type - session.getDboSession()->execute("ALTER TABLE release ADD primary_type INTEGER"); - session.getDboSession()->execute("ALTER TABLE release ADD secondary_types INTEGER"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD primary_type INTEGER"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD secondary_types INTEGER"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV40(Session& session) { // add artist_display_name in Release and Track - session.getDboSession()->execute("ALTER TABLE release ADD artist_display_name TEXT NOT NULL DEFAULT ''"); - session.getDboSession()->execute("ALTER TABLE track ADD artist_display_name TEXT NOT NULL DEFAULT ''"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD artist_display_name TEXT NOT NULL DEFAULT ''"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD artist_display_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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV41(Session& session) { // add artist_display_name in Release and Track - session.getDboSession()->execute("ALTER TABLE user RENAME COLUMN subsonic_transcode_format TO subsonic_default_transcode_format"); - session.getDboSession()->execute("ALTER TABLE user RENAME COLUMN subsonic_transcode_bitrate TO subsonic_default_transcode_bitrate"); - session.getDboSession()->execute("ALTER TABLE user DROP COLUMN subsonic_transcode_enable"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user RENAME COLUMN subsonic_transcode_format TO subsonic_default_transcode_format"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user RENAME COLUMN subsonic_transcode_bitrate TO subsonic_default_transcode_bitrate"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user DROP COLUMN subsonic_transcode_enable"); } void migrateFromV42(Session& session) { - session.getDboSession()->execute("DROP INDEX IF EXISTS listen_scrobbler_idx"); - session.getDboSession()->execute("DROP INDEX IF EXISTS listen_user_scrobbler_idx"); - session.getDboSession()->execute("DROP INDEX IF EXISTS listen_user_track_scrobbler_date_time_idx"); - session.getDboSession()->execute("DROP INDEX IF EXISTS starred_artist_user_scrobbler_idx"); - session.getDboSession()->execute("DROP INDEX IF EXISTS starred_artist_artist_user_scrobbler_idx"); - session.getDboSession()->execute("DROP INDEX IF EXISTS starred_release_user_scrobbler_idx"); - session.getDboSession()->execute("DROP INDEX IF EXISTS starred_release_release_user_scrobbler_idx"); - session.getDboSession()->execute("DROP INDEX IF EXISTS starred_track_user_scrobbler_idx"); - session.getDboSession()->execute("DROP INDEX IF EXISTS starred_track_track_user_scrobbler_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS listen_scrobbler_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS listen_user_scrobbler_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS listen_user_track_scrobbler_date_time_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_artist_user_scrobbler_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_artist_artist_user_scrobbler_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_release_user_scrobbler_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_release_release_user_scrobbler_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_track_user_scrobbler_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS starred_track_track_user_scrobbler_idx"); // New feedback service that now handles the star/unstar stuff (that was previously handled by the scrobbling service) - session.getDboSession()->execute("ALTER TABLE user RENAME COLUMN scrobbler TO scrobbling_backend"); - session.getDboSession()->execute("ALTER TABLE user ADD feedback_backend INTEGER"); - session.getDboSession()->execute("ALTER TABLE listen RENAME COLUMN scrobbler TO backend"); - session.getDboSession()->execute("ALTER TABLE listen RENAME COLUMN scrobbling_state TO sync_state"); - session.getDboSession()->execute("ALTER TABLE starred_artist RENAME COLUMN scrobbler TO backend"); - session.getDboSession()->execute("ALTER TABLE starred_artist RENAME COLUMN scrobbling_state TO sync_state"); - session.getDboSession()->execute("ALTER TABLE starred_release RENAME COLUMN scrobbler TO backend"); - 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"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user RENAME COLUMN scrobbler TO scrobbling_backend"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user ADD feedback_backend INTEGER"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE listen RENAME COLUMN scrobbler TO backend"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE listen RENAME COLUMN scrobbling_state TO sync_state"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_artist RENAME COLUMN scrobbler TO backend"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_artist RENAME COLUMN scrobbling_state TO sync_state"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_release RENAME COLUMN scrobbler TO backend"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_release RENAME COLUMN scrobbling_state TO sync_state"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_track RENAME COLUMN scrobbler TO backend"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_track RENAME COLUMN scrobbling_state TO sync_state"); - session.getDboSession()->execute("UPDATE user SET feedback_backend = scrobbling_backend"); + utils::executeCommand(*session.getDboSession(), "UPDATE user SET feedback_backend = scrobbling_backend"); } void migrateFromV43(Session& session) { // add counts in genre table - session.getDboSession()->execute("ALTER TABLE cluster ADD track_count INTEGER"); - session.getDboSession()->execute("ALTER TABLE cluster ADD release_count INTEGER"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE cluster ADD track_count INTEGER"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE cluster ADD release_count INTEGER"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV44(Session& session) { // add bitrate - session.getDboSession()->execute("ALTER TABLE track ADD bitrate INTEGER NOT NULL DEFAULT 0"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD bitrate INTEGER NOT NULL DEFAULT 0"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV45(Session& session) { // add subsonic_enable_transcoding_by_default, default is disabled - session.getDboSession()->execute("ALTER TABLE user ADD subsonic_enable_transcoding_by_default INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(/*User::defaultSubsonicEnableTranscodingByDefault*/ 0)) + ")"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user ADD subsonic_enable_transcoding_by_default INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast(/*User::defaultSubsonicEnableTranscodingByDefault*/ 0)) + ")"); } void migrateFromV46(Session& session) { // add extra tags to parse - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "cluster_type_backup" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "cluster_type_backup" ( "id" integer primary key autoincrement, "version" integer not null, "name" text not null );)"); - session.getDboSession()->execute("INSERT INTO cluster_type_backup SELECT id, version, name FROM cluster_type"); - session.getDboSession()->execute("DROP TABLE cluster_type"); - session.getDboSession()->execute("ALTER TABLE cluster_type_backup RENAME TO cluster_type"); + utils::executeCommand(*session.getDboSession(), "INSERT INTO cluster_type_backup SELECT id, version, name FROM cluster_type"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE cluster_type"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE cluster_type_backup RENAME TO cluster_type"); - session.getDboSession()->execute("ALTER TABLE scan_settings ADD COLUMN extra_tags_to_scan TEXT"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN extra_tags_to_scan TEXT"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV47(Session& session) { // release type, new way - session.getDboSession()->execute("ALTER TABLE release DROP primary_type"); - session.getDboSession()->execute("ALTER TABLE release DROP secondary_types"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE release DROP primary_type"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE release DROP secondary_types"); - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "release_type" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "release_type" ( "id" integer primary key autoincrement, "version" integer not null, "name" text not null))"); - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "release_release_type" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "release_release_type" ( "release_type_id" bigint, "release_id" bigint, primary key ("release_type_id", "release_id"), constraint "fk_release_release_type_key1" foreign key ("release_type_id") references "release_type" ("id") on delete cascade deferrable initially deferred, constraint "fk_release_release_type_key2" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred ))"); - session.getDboSession()->execute(R"(CREATE INDEX "release_release_type_release_type" on "release_release_type" ("release_type_id"))"); - session.getDboSession()->execute(R"(CREATE INDEX "release_release_type_release" on "release_release_type" ("release_id"))"); + utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_release_type_release_type" on "release_release_type" ("release_type_id"))"); + utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_release_type_release" on "release_release_type" ("release_id"))"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV48(Session& session) { // Regression for the extra tags not being parsed // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV49(Session& session) { // Add year / originalYear fields, as date / originalDate are not enough (we don't want a wrong date but year or nothing) - session.getDboSession()->execute("ALTER TABLE track ADD year INTEGER"); - session.getDboSession()->execute("ALTER TABLE track ADD original_year INTEGER"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD year INTEGER"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD original_year INTEGER"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV50(Session& session) { // MediaLibrary support - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "media_library" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "media_library" ( "id" integer primary key autoincrement, "version" integer not null, "path" text not null, @@ -333,17 +333,17 @@ CREATE TABLE IF NOT EXISTS "track_backup" ( const int scanSettingsId{ session.getDboSession()->query("SELECT id FROM scan_settings") }; // Convert the existing media_directory in the scan_settings table to a media_library with id '1' - session.getDboSession()->execute(R"(INSERT INTO "media_library" ("id", "version", "path", "name") + utils::executeCommand(*session.getDboSession(), R"(INSERT INTO "media_library" ("id", "version", "path", "name") SELECT 1, 0, s_s.media_directory, "Main" FROM scan_settings s_s -WHERE id = ?)") - .bind(scanSettingsId); +WHERE id = ?)", + scanSettingsId); // Remove the outdated column in scan_settings - session.getDboSession()->execute("ALTER TABLE scan_settings DROP media_directory"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings DROP media_directory"); // Add the media_library column in tracks, with id '1' - session.getDboSession()->execute(R"( + utils::executeCommand(*session.getDboSession(), R"( CREATE TABLE IF NOT EXISTS "track_backup" ( "id" integer primary key autoincrement, "version" integer not null, @@ -377,7 +377,7 @@ CREATE TABLE IF NOT EXISTS "track_backup" ( ))"); // Migrate data, with the new media_library_id field set to 1 - session.getDboSession()->execute(R"(INSERT INTO track_backup + utils::executeCommand(*session.getDboSession(), R"(INSERT INTO track_backup SELECT id, version, @@ -407,55 +407,55 @@ SELECT release_id, 1 FROM track)"); - session.getDboSession()->execute("DROP TABLE track"); - session.getDboSession()->execute("ALTER TABLE track_backup RENAME TO track"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE track"); + utils::executeCommand(*session.getDboSession(), "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 ''"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD artist_tag_delimiters TEXT NOT NULL DEFAULT ''"); + utils::executeCommand(*session.getDboSession(), "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 ''"); + utils::executeCommand(*session.getDboSession(), "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"); + utils::executeCommand(*session.getDboSession(), "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 ''"); + utils::executeCommand(*session.getDboSession(), "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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV54(Session& session) { // Add file size + relative file path - session.getDboSession()->execute("ALTER TABLE track RENAME COLUMN file_path TO absolute_file_path"); - session.getDboSession()->execute("ALTER TABLE track ADD file_size BIGINT NOT NULL DEFAULT(0)"); - session.getDboSession()->execute("ALTER TABLE track ADD relative_file_path TEXT NOT NULL DEFAULT ''"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track RENAME COLUMN file_path TO absolute_file_path"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD file_size BIGINT NOT NULL DEFAULT(0)"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD relative_file_path 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV55(Session& session) { // Add bitsPerSample, channelCount and sampleRate - session.getDboSession()->execute("ALTER TABLE track ADD bits_per_sample INTEGER NOT NULL DEFAULT(0)"); - session.getDboSession()->execute("ALTER TABLE track ADD channel_count INTEGER NOT NULL DEFAULT(0)"); - session.getDboSession()->execute("ALTER TABLE track ADD sample_rate INTEGER NOT NULL DEFAULT(0)"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD bits_per_sample INTEGER NOT NULL DEFAULT(0)"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD channel_count INTEGER NOT NULL DEFAULT(0)"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD sample_rate INTEGER NOT NULL DEFAULT(0)"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV56(Session& session) @@ -463,25 +463,25 @@ SELECT // Make sure we remove all the previoulsy created index, the createIndexesIfNeeded will recreate them all std::vector indexeNames{ utils::fetchQueryResults(session.getDboSession()->query(R"(SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE '%_idx')")) }; for (const auto& indexName : indexeNames) - session.getDboSession()->execute("DROP INDEX " + indexName); + utils::executeCommand(*session.getDboSession(), "DROP INDEX " + indexName); } void migrateFromV57(Session& session) { // useless index, may have been already removed in the previous step - session.getDboSession()->execute("DROP INDEX IF EXISTS cluster_name_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS cluster_name_idx"); } void migrateFromV58(Session& session) { // DSF support - session.getDboSession()->execute("UPDATE scan_settings SET audio_file_extensions = audio_file_extensions || ' .dsf'"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_file_extensions = audio_file_extensions || ' .dsf'"); } void migrateFromV59(Session& session) { // Dedicated image table - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "image" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "image" ( "id" integer primary key autoincrement, "version" integer not null, "path" text not null, @@ -495,13 +495,13 @@ SELECT ))"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV60(Session& session) { // Dedicated directory table - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "directory" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "directory" ( "id" integer primary key autoincrement, "version" integer not null, "absolute_path" text not null, @@ -511,7 +511,7 @@ SELECT ))"); // Add a ref in track, need to recreate a new table - session.getDboSession()->execute(R"( + utils::executeCommand(*session.getDboSession(), R"( CREATE TABLE IF NOT EXISTS "track_backup" ( "id" integer primary key autoincrement, "version" integer not null, @@ -551,7 +551,7 @@ CREATE TABLE IF NOT EXISTS "track_backup" ( constraint "fk_track_directory" foreign key ("directory_id") references "directory" ("id") on delete cascade deferrable initially deferred ))"); // Migrate data, with the new directory_id field set to null - session.getDboSession()->execute(R"(INSERT INTO track_backup + utils::executeCommand(*session.getDboSession(), R"(INSERT INTO track_backup SELECT id, version, @@ -587,11 +587,11 @@ SELECT media_library_id, NULL FROM track)"); - session.getDboSession()->execute("DROP TABLE track"); - session.getDboSession()->execute("ALTER TABLE track_backup RENAME TO track"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE track"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track_backup RENAME TO track"); // Add a ref in image + rename path to absolute_file_path, need to recreate a new table - session.getDboSession()->execute(R"( + utils::executeCommand(*session.getDboSession(), R"( CREATE TABLE IF NOT EXISTS "image_backup" ( "id" integer primary key autoincrement, "version" integer not null, @@ -608,7 +608,7 @@ SELECT ))"); // Migrate data, with the new directory_id field set to null - session.getDboSession()->execute(R"(INSERT INTO image_backup + utils::executeCommand(*session.getDboSession(), R"(INSERT INTO image_backup SELECT id, version, @@ -622,11 +622,11 @@ SELECT NULL FROM image )"); - session.getDboSession()->execute("DROP TABLE image"); - session.getDboSession()->execute("ALTER TABLE image_backup RENAME TO image"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE image"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE image_backup RENAME TO image"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } } // namespace @@ -634,7 +634,7 @@ SELECT void migrateFromV61(Session& session) { // Added a media_library_id in Directory - session.getDboSession()->execute(R"( + utils::executeCommand(*session.getDboSession(), R"( CREATE TABLE IF NOT EXISTS "directory_backup" ( "id" integer primary key autoincrement, "version" integer not null, @@ -647,7 +647,7 @@ CREATE TABLE IF NOT EXISTS "directory_backup" ( ))"); // Migrate data, with the new directory_id field set to null - session.getDboSession()->execute(R"(INSERT INTO directory_backup + utils::executeCommand(*session.getDboSession(), R"(INSERT INTO directory_backup SELECT id, version, @@ -657,27 +657,27 @@ SELECT NULL FROM directory)"); - session.getDboSession()->execute("DROP TABLE directory"); - session.getDboSession()->execute("ALTER TABLE directory_backup RENAME TO directory"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE directory"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE directory_backup RENAME TO directory"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV62(Session& session) { // Add a new column comment - session.getDboSession()->execute("ALTER TABLE track ADD comment TEXT NOT NULL DEFAULT ''"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD comment 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV63(Session& session) { // Add a rated entities - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "rated_artist" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "rated_artist" ( "id" integer primary key autoincrement, "version" integer not null, "rating" integer not null, @@ -688,7 +688,7 @@ SELECT constraint "fk_rated_artist_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred ))"); - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "rated_release" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "rated_release" ( "id" integer primary key autoincrement, "version" integer not null, "rating" integer not null, @@ -699,7 +699,7 @@ SELECT constraint "fk_rated_release_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred ))"); - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "rated_track" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "rated_track" ( "id" integer primary key autoincrement, "version" integer not null, "rating" bigint not null, @@ -711,43 +711,43 @@ SELECT ))"); // Drop badly named index, will be recreated - session.getDboSession()->execute("DROP INDEX IF EXISTS listen_user_backend_date_time"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS listen_user_backend_date_time"); } void migrateFromV64(Session& session) { - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "label" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "label" ( "id" integer primary key autoincrement, "version" integer not null, "name" text not null ))"); - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "release_label" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "release_label" ( "label_id" bigint, "release_id" bigint, primary key ("label_id", "release_id"), constraint "fk_release_label_key1" foreign key ("label_id") references "label" ("id") on delete cascade deferrable initially deferred, constraint "fk_release_label_key2" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred ))"); - session.getDboSession()->execute(R"(CREATE INDEX "release_label_label" on "release_label" ("label_id"))"); - session.getDboSession()->execute(R"(CREATE INDEX "release_label_release" on "release_label" ("release_id"))"); + utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_label_label" on "release_label" ("label_id"))"); + utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "release_label_release" on "release_label" ("release_id"))"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV65(Session& session) { - session.getDboSession()->execute("ALTER TABLE release ADD is_compilation BOOLEAN NOT NULL DEFAULT(false)"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE release ADD is_compilation BOOLEAN NOT NULL DEFAULT(false)"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV66(Session& session) { // New way of handling UI settings - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "ui_state" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "ui_state" ( "id" integer primary key autoincrement, "version" integer not null, "item" text not null, @@ -756,15 +756,15 @@ SELECT constraint "fk_ui_state_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred ))"); - session.getDboSession()->execute("ALTER TABLE user DROP COLUMN repeat_all"); - session.getDboSession()->execute("ALTER TABLE user DROP COLUMN radio"); - session.getDboSession()->execute("ALTER TABLE user DROP COLUMN cur_playing_track_pos"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user DROP COLUMN repeat_all"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user DROP COLUMN radio"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user DROP COLUMN cur_playing_track_pos"); } void migrateFromV67(Session& session) { // Add a ref to release in image - session.getDboSession()->execute(R"(CREATE TABLE "image_backup" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE "image_backup" ( "id" integer primary key autoincrement, "version" integer not null, "absolute_file_path" text not null, @@ -782,7 +782,7 @@ SELECT ))"); // Migrate data, with the new release_id field set to null - session.getDboSession()->execute(R"(INSERT INTO image_backup + utils::executeCommand(*session.getDboSession(), R"(INSERT INTO image_backup SELECT id, version, @@ -797,22 +797,22 @@ SELECT directory_id FROM image )"); - session.getDboSession()->execute("DROP TABLE image"); - session.getDboSession()->execute("ALTER TABLE image_backup RENAME TO image"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE image"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE image_backup RENAME TO image"); // Changed some indexes for the image table -> remove all the previoulsy created index, the createIndexesIfNeeded will recreate them all std::vector indexeNames{ utils::fetchQueryResults(session.getDboSession()->query(R"(SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE '%_idx')")) }; for (const auto& indexName : indexeNames) - session.getDboSession()->execute("DROP INDEX " + indexName); + utils::executeCommand(*session.getDboSession(), "DROP INDEX " + indexName); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV68(Session& session) { // Changed the way we ref images from release and artists (several releases and artist can now share the same image) - session.getDboSession()->execute(R"(CREATE TABLE "release_backup" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE "release_backup" ( "id" integer primary key autoincrement, "version" integer not null, "name" text not null, @@ -826,7 +826,7 @@ SELECT constraint "fk_release_image" foreign key ("image_id") references "image" ("id") on delete set null deferrable initially deferred))"); // Migrate data, with the new image_id field set to null - session.getDboSession()->execute(R"(INSERT INTO release_backup + utils::executeCommand(*session.getDboSession(), R"(INSERT INTO release_backup SELECT id, version, @@ -840,10 +840,10 @@ SELECT NULL FROM release )"); - session.getDboSession()->execute("DROP TABLE release"); - session.getDboSession()->execute("ALTER TABLE release_backup RENAME TO release"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE release"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE release_backup RENAME TO release"); - session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "artist_backup" ( + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "artist_backup" ( "id" integer primary key autoincrement, "version" integer not null, "name" text not null, @@ -854,7 +854,7 @@ SELECT ))"); // Migrate data, with the new image_id field set to null - session.getDboSession()->execute(R"(INSERT INTO artist_backup + utils::executeCommand(*session.getDboSession(), R"(INSERT INTO artist_backup SELECT id, version, @@ -865,17 +865,46 @@ SELECT FROM artist )"); - session.getDboSession()->execute("DROP TABLE artist"); - session.getDboSession()->execute("ALTER TABLE artist_backup RENAME TO artist"); + utils::executeCommand(*session.getDboSession(), "DROP TABLE artist"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE artist_backup RENAME TO artist"); // 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"); + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } void migrateFromV69(Session& session) { // Add a field in UI settings - session.getDboSession()->execute("ALTER TABLE user ADD COLUMN ui_artist_release_sort_method NOT NULL DEFAULT 7"); // 7 = ReleaseSortMethod::OriginalDateDesc + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user ADD COLUMN ui_artist_release_sort_method NOT NULL DEFAULT 7"); // 7 = ReleaseSortMethod::OriginalDateDesc + } + + void migrateFromV70(Session& session) + { + // Add a file name/stem in tracks + utils::executeCommand(*session.getDboSession(), "ALTER TABLE track ADD COLUMN file_stem TEXT NOT NULL DEFAULT ''"); + + // New table TrackLyrics + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "track_lyrics" ( + "id" integer primary key autoincrement, + "version" integer not null, + "absolute_file_path" text not null, + "stem" text not null, + "file_last_write" text, + "file_size" integer not null, + "lines" text not null, + "language" text not null, + "offset" integer, + "display_artist" text not null, + "display_title" text not null, + "synchronized" boolean not null, + "track_id" bigint, + "directory_id" bigint, + constraint "fk_track_lyrics_track" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred, + constraint "fk_track_lyrics_directory" foreign key ("directory_id") references "directory" ("id") on delete cascade deferrable initially deferred +))"); + + // Just increment the scan version of the settings to make the next scheduled scan rescan everything + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET scan_version = scan_version + 1"); } bool doDbMigration(Session& session) @@ -924,6 +953,7 @@ SELECT { 67, migrateFromV67 }, { 68, migrateFromV68 }, { 69, migrateFromV69 }, + { 70, migrateFromV70 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/impl/Release.cpp b/src/libs/database/impl/Release.cpp index 9e4acb47..4ab4437a 100644 --- a/src/libs/database/impl/Release.cpp +++ b/src/libs/database/impl/Release.cpp @@ -229,6 +229,23 @@ namespace lms::db return query; } + + template + Wt::Dbo::Query createArtistQuery(Wt::Dbo::Session& session, std::string_view itemToSelect, ReleaseId releaseId, TrackArtistLinkType linkType) + { + auto query{ session.query("SELECT " + std::string{ itemToSelect } + " from artist a") + .join("track_artist_link t_a_l ON t_a_l.artist_id = a.id") + .join("track t ON t.id = t_a_l.track_id") + .where("t.release_id = ?") + .bind(releaseId) + .where("+t_a_l.type = ?") + .bind(linkType) // adding + since the query planner does not a good job when analyze is not performed + .groupBy("a.id") + .orderBy("t_a_l.id") }; + + return query; + }; + } // namespace Label::Label(std::string_view name) @@ -530,19 +547,18 @@ namespace lms::db { assert(session()); - const auto query{ session()->query>( - "SELECT a FROM artist a" - " INNER JOIN track_artist_link t_a_l ON t_a_l.artist_id = a.id" - " INNER JOIN track t ON t.id = t_a_l.track_id") - .where("t.release_id = ?") - .bind(getId()) - .where("+t_a_l.type = ?") - .bind(linkType) // adding + since the query planner does not a good job when analyze is not performed - .groupBy("a.id") }; - + const auto query{ createArtistQuery>(*session(), "a", getId(), linkType) }; return utils::fetchQueryResults(query); } + std::vector Release::getArtistIds(TrackArtistLinkType linkType) const + { + assert(session()); + + const auto query{ createArtistQuery(*session(), "a.id", getId(), linkType) }; + return utils::fetchQueryResults(query); + } + std::vector Release::getSimilarReleases(std::optional offset, std::optional count) const { assert(session()); diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index 42f3642f..23f6d637 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -43,6 +43,7 @@ #include "database/TrackBookmark.hpp" #include "database/TrackFeatures.hpp" #include "database/TrackList.hpp" +#include "database/TrackLyrics.hpp" #include "database/TransactionChecker.hpp" #include "database/UIState.hpp" #include "database/User.hpp" @@ -118,6 +119,7 @@ namespace lms::db _session.mapClass("track_features"); _session.mapClass("tracklist"); _session.mapClass("tracklist_entry"); + _session.mapClass("track_lyrics"); _session.mapClass("ui_state"); _session.mapClass("user"); } @@ -134,7 +136,7 @@ namespace lms::db void Session::execute(std::string_view statement) { - _session.execute(std::string{ statement }); + utils::executeCommand(_session, std::string{ statement }); } void Session::prepareTablesIfNeeded() @@ -178,92 +180,97 @@ namespace lms::db LMS_LOG(DB, INFO, "Creating indexes... This may take a while..."); auto transaction{ createWriteTransaction() }; - _session.execute("CREATE INDEX IF NOT EXISTS artist_id_idx ON artist(id)"); - _session.execute("CREATE INDEX IF NOT EXISTS artist_image_idx ON artist(image_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS artist_name_idx ON artist(name)"); - _session.execute("CREATE INDEX IF NOT EXISTS artist_sort_name_nocase_idx ON artist(sort_name COLLATE NOCASE)"); - _session.execute("CREATE INDEX IF NOT EXISTS artist_mbid_idx ON artist(mbid)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_id_idx ON artist(id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_image_idx ON artist(image_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_name_idx ON artist(name)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_sort_name_nocase_idx ON artist(sort_name COLLATE NOCASE)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_mbid_idx ON artist(mbid)"); - _session.execute("CREATE INDEX IF NOT EXISTS auth_token_user_idx ON auth_token(user_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS auth_token_expiry_idx ON auth_token(expiry)"); - _session.execute("CREATE INDEX IF NOT EXISTS auth_token_value_idx ON auth_token(value)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_user_idx ON auth_token(user_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_expiry_idx ON auth_token(expiry)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_value_idx ON auth_token(value)"); - _session.execute("CREATE INDEX IF NOT EXISTS cluster_cluster_type_idx ON cluster(cluster_type_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS cluster_type_name_idx ON cluster_type(name)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_cluster_type_idx ON cluster(cluster_type_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_type_name_idx ON cluster_type(name)"); - _session.execute("CREATE INDEX IF NOT EXISTS directory_id_idx ON directory(id)"); - _session.execute("CREATE INDEX IF NOT EXISTS directory_parent_directory_idx ON directory(parent_directory_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS directory_path_idx ON directory(absolute_path)"); - _session.execute("CREATE INDEX IF NOT EXISTS directory_media_library_idx ON directory(media_library_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS directory_id_idx ON directory(id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS directory_parent_directory_idx ON directory(parent_directory_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS directory_path_idx ON directory(absolute_path)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS directory_media_library_idx ON directory(media_library_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS image_directory_stem_idx ON image(directory_id, stem COLLATE NOCASE)"); - _session.execute("CREATE INDEX IF NOT EXISTS image_id_idx ON image(id)"); - _session.execute("CREATE INDEX IF NOT EXISTS image_path_idx ON image(absolute_file_path)"); - _session.execute("CREATE INDEX IF NOT EXISTS image_stem_idx ON image(stem COLLATE NOCASE)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_directory_stem_idx ON image(directory_id, stem COLLATE NOCASE)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_id_idx ON image(id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_path_idx ON image(absolute_file_path)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS image_stem_idx ON image(stem COLLATE NOCASE)"); - _session.execute("CREATE INDEX IF NOT EXISTS label_name_idx ON label(name)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS label_name_idx ON label(name)"); - _session.execute("CREATE INDEX IF NOT EXISTS listen_backend_idx ON listen(backend)"); - _session.execute("CREATE INDEX IF NOT EXISTS listen_id_idx ON listen(id)"); - _session.execute("CREATE INDEX IF NOT EXISTS listen_user_backend_idx ON listen(user_id,backend)"); - _session.execute("CREATE INDEX IF NOT EXISTS listen_user_backend_date_time_idx ON listen(user_id, backend, date_time DESC)"); - _session.execute("CREATE INDEX IF NOT EXISTS listen_track_user_backend_idx ON listen(track_id,user_id,backend)"); - _session.execute("CREATE INDEX IF NOT EXISTS listen_user_track_backend_date_time_idx ON listen(user_id,track_id,backend,date_time)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_backend_idx ON listen(backend)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_id_idx ON listen(id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_user_backend_idx ON listen(user_id,backend)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_user_backend_date_time_idx ON listen(user_id, backend, date_time DESC)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_track_user_backend_idx ON listen(track_id,user_id,backend)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS listen_user_track_backend_date_time_idx ON listen(user_id,track_id,backend,date_time)"); - _session.execute("CREATE INDEX IF NOT EXISTS media_library_id_idx ON media_library(id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS media_library_id_idx ON media_library(id)"); - _session.execute("CREATE INDEX IF NOT EXISTS rated_artist_user_artist_idx ON rated_artist(user_id,artist_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS rated_release_user_release_idx ON rated_release(user_id,release_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS rated_track_user_track_idx ON rated_track(user_id,track_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS rated_artist_user_artist_idx ON rated_artist(user_id,artist_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS rated_release_user_release_idx ON rated_release(user_id,release_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS rated_track_user_track_idx ON rated_track(user_id,track_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS release_id_idx ON release(id)"); - _session.execute("CREATE INDEX IF NOT EXISTS release_image_idx ON release(image_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS release_mbid_idx ON release(mbid)"); - _session.execute("CREATE INDEX IF NOT EXISTS release_name_idx ON release(name)"); - _session.execute("CREATE INDEX IF NOT EXISTS release_name_nocase_idx ON release(name COLLATE NOCASE)"); - _session.execute("CREATE INDEX IF NOT EXISTS release_type_name_idx ON release_type(name)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_id_idx ON release(id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_image_idx ON release(image_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_mbid_idx ON release(mbid)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_name_idx ON release(name)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_name_nocase_idx ON release(name COLLATE NOCASE)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS release_type_name_idx ON release_type(name)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_id_idx ON track(id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_absolute_path_idx ON track(absolute_file_path)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_date_idx ON track(date)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_directory_release_idx ON track(directory_id, release_id);"); - _session.execute("CREATE INDEX IF NOT EXISTS track_file_last_write_idx ON track(file_last_write)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_media_library_idx ON track(media_library_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_media_library_release_idx ON track(media_library_id, release_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_mbid_idx ON track(mbid)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_name_idx ON track(name)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_name_nocase_idx ON track(name COLLATE NOCASE)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_original_date_idx ON track(original_date)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_original_year_idx ON track(original_year)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_recording_mbid_idx ON track(recording_mbid)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_release_idx ON track(release_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_release_file_last_write_idx ON track(release_id, file_last_write)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_release_year_idx ON track(release_id, year)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_year_idx ON track(year)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_id_idx ON track(id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_absolute_path_idx ON track(absolute_file_path)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_date_idx ON track(date)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_directory_release_idx ON track(directory_id, release_id);"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_directory_file_stem_idx ON track(directory_id, file_stem);"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_file_last_write_idx ON track(file_last_write)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_media_library_idx ON track(media_library_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_media_library_release_idx ON track(media_library_id, release_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_mbid_idx ON track(mbid)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_name_idx ON track(name)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_name_nocase_idx ON track(name COLLATE NOCASE)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_original_date_idx ON track(original_date)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_original_year_idx ON track(original_year)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_recording_mbid_idx ON track(recording_mbid)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_release_idx ON track(release_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_release_file_last_write_idx ON track(release_id, file_last_write)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_release_year_idx ON track(release_id, year)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_year_idx ON track(year)"); - _session.execute("CREATE INDEX IF NOT EXISTS tracklist_name_idx ON tracklist(name)"); - _session.execute("CREATE INDEX IF NOT EXISTS tracklist_user_idx ON tracklist(user_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_name_idx ON tracklist(name)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS tracklist_user_idx ON tracklist(user_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_features_track_idx ON track_features(track_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_artist_idx ON track_artist_link(artist_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_artist_track_idx ON track_artist_link(artist_id, track_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_artist_type_idx ON track_artist_link(artist_id,type)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_artist_idx ON track_artist_link(track_id, artist_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_type_idx ON track_artist_link(track_id,type)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_type_track_artist_idx ON track_artist_link(type, track_id, artist_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_artist_idx ON track_artist_link(artist_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_artist_track_idx ON track_artist_link(artist_id, track_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_artist_type_idx ON track_artist_link(artist_id,type)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_track_artist_idx ON track_artist_link(track_id, artist_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_track_type_idx ON track_artist_link(track_id,type)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_artist_link_type_track_artist_idx ON track_artist_link(type, track_id, artist_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_features_track_idx ON track_features(track_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_bookmark_user_idx ON track_bookmark(user_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS track_bookmark_user_track_idx ON track_bookmark(user_id,track_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_id_idx ON track_lyrics(id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_absolute_file_path_idx ON track_lyrics(absolute_file_path)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_track_idx ON track_lyrics(track_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS starred_artist_user_backend_idx ON starred_artist(user_id,backend)"); - _session.execute("CREATE INDEX IF NOT EXISTS starred_artist_artist_user_backend_idx ON starred_artist(artist_id,user_id,backend)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_bookmark_user_idx ON track_bookmark(user_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_bookmark_user_track_idx ON track_bookmark(user_id,track_id)"); - _session.execute("CREATE INDEX IF NOT EXISTS starred_release_user_backend_idx ON starred_release(user_id,backend)"); - _session.execute("CREATE INDEX IF NOT EXISTS starred_release_release_user_backend_idx ON starred_release(release_id,user_id,backend)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_artist_user_backend_idx ON starred_artist(user_id,backend)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_artist_artist_user_backend_idx ON starred_artist(artist_id,user_id,backend)"); - _session.execute("CREATE INDEX IF NOT EXISTS starred_track_user_backend_idx ON starred_track(user_id,backend)"); - _session.execute("CREATE INDEX IF NOT EXISTS starred_track_track_user_backend_idx ON starred_track(track_id,user_id,backend)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_release_user_backend_idx ON starred_release(user_id,backend)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_release_release_user_backend_idx ON starred_release(release_id,user_id,backend)"); + + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_track_user_backend_idx ON starred_track(user_id,backend)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS starred_track_track_user_backend_idx ON starred_track(track_id,user_id,backend)"); LMS_LOG(DB, INFO, "Indexes created!"); } @@ -341,11 +348,10 @@ namespace lms::db void Session::analyzeEntry(const std::string& entry) { - LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "AnalyzeEntry", "Entry", entry); LMS_LOG(DB, DEBUG, "Analyzing " << entry); { auto transaction{ createWriteTransaction() }; - _session.execute("ANALYZE " + entry); + utils::executeCommand(_session, "ANALYZE " + entry); } LMS_LOG(DB, DEBUG, "Analyzing " << entry << ": done!"); } diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index c60e946d..6abcb4d4 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -30,6 +30,7 @@ #include "database/Session.hpp" #include "database/TrackArtistLink.hpp" #include "database/TrackFeatures.hpp" +#include "database/TrackLyrics.hpp" #include "database/User.hpp" #include "IdTypeTraits.hpp" @@ -53,6 +54,9 @@ namespace lms::db for (std::string_view keyword : params.keywords) query.where("t.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeLikeKeyword(keyword) + "%"); + if (!params.stem.empty()) + query.where("t.file_stem = ?").bind(params.stem); + if (!params.name.empty()) query.where("t.name = ?").bind(params.name); @@ -372,11 +376,15 @@ namespace lms::db { assert(filePath.is_absolute()); _absoluteFilePath = filePath; + _fileStem = filePath.stem(); } void Track::setRelativeFilePath(const std::filesystem::path& filePath) { assert(filePath.is_relative()); + + assert(_absoluteFilePath.filename() == filePath.filename()); // must be compatible with previous setAbsoluteFilePath call + _fileStem = filePath.stem(); // lazy migration (_fileStem added later, could be set only with setAbsoluteFilePath) _relativeFilePath = filePath; } @@ -418,6 +426,26 @@ namespace lms::db _clusters.insert(getDboPtr(cluster)); } + void Track::clearLyrics() + { + _trackLyrics.clear(); + } + + void Track::clearEmbeddedLyrics() + { + utils::executeCommand(*session(), "DELETE FROM track_lyrics WHERE absolute_file_path = '' AND track_id = ?", getId()); + } + + void Track::addLyrics(const ObjectPtr& lyrics) + { + _trackLyrics.insert(getDboPtr(lyrics)); + } + + bool Track::hasLyrics() const + { + return !_trackLyrics.empty(); + } + std::optional Track::getCopyright() const { return _copyright != "" ? std::make_optional(_copyright) : std::nullopt; diff --git a/src/libs/database/impl/TrackLyrics.cpp b/src/libs/database/impl/TrackLyrics.cpp new file mode 100644 index 00000000..5bc1b495 --- /dev/null +++ b/src/libs/database/impl/TrackLyrics.cpp @@ -0,0 +1,212 @@ +/* + * 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 . + */ + +#include "database/TrackLyrics.hpp" + +#include +#include + +#include "database/Artist.hpp" +#include "database/Directory.hpp" +#include "database/Release.hpp" +#include "database/Session.hpp" +#include "database/Track.hpp" + +#include "IdTypeTraits.hpp" +#include "PathTraits.hpp" +#include "Utils.hpp" + +namespace lms::db +{ + namespace + { + Wt::Dbo::Query> createQuery(Session& session, const TrackLyrics::FindParameters& params) + { + auto query{ session.getDboSession()->query>("SELECT t_lrc from track_lyrics t_lrc") }; + + if (params.track.isValid()) + query.where("t_lrc.track_id = ?").bind(params.track); + + if (params.external.has_value()) + query.where("t_lrc.absolute_file_path " + std::string{ *params.external ? "<>" : "=" } + " ''"); + + switch (params.sortMethod) + { + case TrackLyricsSortMethod::None: + break; + case TrackLyricsSortMethod::ExternalFirst: + query.orderBy("CASE WHEN absolute_file_path <> '' THEN 0 ELSE 1 END"); + break; + case TrackLyricsSortMethod::EmbeddedFirst: + query.orderBy("CASE WHEN absolute_file_path = '' THEN 0 ELSE 1 END"); + break; + } + + return query; + } + } // namespace + + TrackLyrics::pointer TrackLyrics::create(Session& session) + { + return session.getDboSession()->add(std::unique_ptr{ new TrackLyrics{} }); + } + + std::size_t TrackLyrics::getCount(Session& session) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM track_lyrics")); + } + + std::size_t TrackLyrics::getExternalLyricsCount(Session& session) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(t_lrc.id) FROM track_lyrics t_lrc").where("t_lrc.absolute_file_path <> ''")); + } + + TrackLyrics::pointer TrackLyrics::find(Session& session, TrackLyricsId id) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT t_lrc from track_lyrics t_lrc").where("t_lrc.id = ?").bind(id)); + } + + TrackLyrics::pointer TrackLyrics::find(Session& session, const std::filesystem::path& path) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT t_lrc from track_lyrics t_lrc").where("t_lrc.absolute_file_path = ?").bind(path)); + } + + void TrackLyrics::find(Session& session, const FindParameters& params, const std::function& func) + { + session.checkReadTransaction(); + + auto query{ createQuery(session, params) }; + utils::forEachQueryRangeResult(query, params.range, func); + } + + void TrackLyrics::find(Session& session, TrackLyricsId& lastRetrievedId, std::size_t count, const std::function& func) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->query>("SELECT t_lrc from track_lyrics t_lrc").orderBy("t_lrc.id").where("t_lrc.id > ?").bind(lastRetrievedId).limit(static_cast(count)) }; + + utils::forEachQueryResult(query, [&](const TrackLyrics::pointer& lyrics) { + func(lyrics); + lastRetrievedId = lyrics->getId(); + }); + } + + RangeResults TrackLyrics::findOrphanIds(Session& session, std::optional range) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->query("select t_lrc.id from track_lyrics t_lrc") }; + query.leftJoin("track t ON t_lrc.track_id = t.id"); + query.where("t.id IS NULL"); + + return utils::execRangeQuery(query, range); + } + + TrackLyrics::SynchronizedLines TrackLyrics::getSynchronizedLines() const + { + SynchronizedLines synchronizedLines; + assert(_synchronized); + + { + Wt::Json::Object root; + Wt::Json::parse(_lines, root); + + assert(root.type("lines") == Wt::Json::Type::Array); + const Wt::Json::Array& linesArray = root.get("lines"); + for (const Wt::Json::Value& line : linesArray) + { + assert(line.type() == Wt::Json::Type::Object); + const Wt::Json::Object& entry = line; + std::chrono::milliseconds timestamp{ entry.get("timestamp") }; + std::string lineText{ static_cast(entry.get("value")) }; + synchronizedLines.emplace(timestamp, std::move(lineText)); + } + } + + return synchronizedLines; + } + + std::vector TrackLyrics::getUnsynchronizedLines() const + { + std::vector unsynchronizedLines; + assert(!_synchronized); + + { + Wt::Json::Object root; + Wt::Json::parse(_lines, root); + + assert(root.type("lines") == Wt::Json::Type::Array); + const Wt::Json::Array& linesArray = root.get("lines"); + for (const Wt::Json::Value& line : linesArray) + unsynchronizedLines.push_back(line.toString()); + } + + return unsynchronizedLines; + } + + void TrackLyrics::setAbsoluteFilePath(const std::filesystem::path& p) + { + assert(p.is_absolute()); + _fileAbsolutePath = p; + _fileStem = p.stem().string(); + } + + void TrackLyrics::setSynchronizedLines(const SynchronizedLines& synchronizedLines) + { + Wt::Json::Object root; + + Wt::Json::Array lines; + for (const auto& [timestamp, lineText] : synchronizedLines) + { + Wt::Json::Object line; + line["timestamp"] = Wt::Json::Value{ timestamp.count() }; + line["value"] = Wt::Json::Value{ lineText }; + + lines.push_back(std::move(line)); + } + + root["lines"] = std::move(lines); + + _synchronized = true; + _lines = Wt::Json::serialize(root); + } + + void TrackLyrics::setUnsynchronizedLines(std::span unsynchronizedLines) + { + Wt::Json::Object root; + + Wt::Json::Array lines; + for (const auto& lineText : unsynchronizedLines) + lines.push_back(Wt::Json::Value{ lineText }); + + root["lines"] = std::move(lines); + + _synchronized = false; + _lines = Wt::Json::serialize(root); + } + +} // namespace lms::db diff --git a/src/libs/database/impl/Utils.hpp b/src/libs/database/impl/Utils.hpp index 2c7386d2..59e83a81 100644 --- a/src/libs/database/impl/Utils.hpp +++ b/src/libs/database/impl/Utils.hpp @@ -172,5 +172,14 @@ namespace lms::db::utils } } + template + void executeCommand(Wt::Dbo::Session& session, std::string_view command, const Args&... args) + { + LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "ExecuteCommand", "Command", command); + + Wt::Dbo::Call call{ session.execute(std::string{ command }) }; + (call.bind(args), ...); + } + Wt::WDateTime normalizeDateTime(const Wt::WDateTime& dateTime); } // namespace lms::db::utils \ No newline at end of file diff --git a/src/libs/database/include/database/Release.hpp b/src/libs/database/include/database/Release.hpp index 728cced4..f1fab7cc 100644 --- a/src/libs/database/include/database/Release.hpp +++ b/src/libs/database/include/database/Release.hpp @@ -270,6 +270,7 @@ namespace lms::db // Get the artists of this release std::vector> getArtists(TrackArtistLinkType type = TrackArtistLinkType::Artist) const; + std::vector getArtistIds(TrackArtistLinkType type = TrackArtistLinkType::Artist) const; std::vector> getReleaseArtists() const { return getArtists(TrackArtistLinkType::ReleaseArtist); } bool hasVariousArtists() const; std::vector getSimilarReleases(std::optional offset = {}, std::optional count = {}) const; diff --git a/src/libs/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index 8e9d3d9a..3e6ee883 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -56,6 +56,7 @@ namespace lms::db class Release; class Session; class TrackArtistLink; + class TrackLyrics; class TrackStats; class User; @@ -67,6 +68,7 @@ namespace lms::db std::vector clusters; // if non empty, tracks that belong to these clusters std::vector keywords; // if non empty, name must match all of these keywords std::string name; // if non empty, must match this name + std::string stem; // if non empty, must match this stem TrackSortMethod sortMethod{ TrackSortMethod::None }; std::optional range; Wt::WDateTime writtenAfter; @@ -100,6 +102,11 @@ namespace lms::db name = _name; return *this; } + FindParameters& setStem(std::string_view _stem) + { + stem = _stem; + return *this; + } FindParameters& setSortMethod(TrackSortMethod _method) { sortMethod = _method; @@ -239,6 +246,9 @@ namespace lms::db void addArtistLink(const ObjectPtr& artistLink); void setRelease(ObjectPtr release) { _release = getDboPtr(release); } void setClusters(const std::vector>& clusters); + void clearLyrics(); + void clearEmbeddedLyrics(); + void addLyrics(const ObjectPtr& lyrics); void setMediaLibrary(ObjectPtr mediaLibrary) { _mediaLibrary = getDboPtr(mediaLibrary); } void setDirectory(ObjectPtr directory) { _directory = getDboPtr(directory); } @@ -264,6 +274,7 @@ namespace lms::db const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; } const Wt::WDateTime& getAddedTime() const { return _fileAdded; } bool hasCover() const { return _hasCover; } + bool hasLyrics() const; std::optional getTrackMBID() const { return core::UUID::fromString(_trackMBID); } std::optional getRecordingMBID() const { return core::UUID::fromString(_recordingMBID); } std::optional getCopyright() const; @@ -305,6 +316,7 @@ namespace lms::db Wt::Dbo::field(a, _originalYear, "original_year"); Wt::Dbo::field(a, _absoluteFilePath, "absolute_file_path"); Wt::Dbo::field(a, _relativeFilePath, "relative_file_path"); + Wt::Dbo::field(a, _fileStem, "file_stem"); Wt::Dbo::field(a, _fileSize, "file_size"); Wt::Dbo::field(a, _fileLastWrite, "file_last_write"); Wt::Dbo::field(a, _fileAdded, "file_added"); @@ -323,6 +335,7 @@ namespace lms::db Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasMany(a, _trackArtistLinks, Wt::Dbo::ManyToOne, "track"); Wt::Dbo::hasMany(a, _clusters, Wt::Dbo::ManyToMany, "track_cluster", "", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::hasMany(a, _trackLyrics, Wt::Dbo::ManyToOne, "track"); } private: @@ -350,6 +363,7 @@ namespace lms::db std::optional _originalYear; std::filesystem::path _absoluteFilePath; // full path std::filesystem::path _relativeFilePath; // relative to root (that may be deleted) + std::filesystem::path _fileStem; long long _fileSize{}; Wt::WDateTime _fileLastWrite; Wt::WDateTime _fileAdded; @@ -368,6 +382,7 @@ namespace lms::db Wt::Dbo::ptr _directory; Wt::Dbo::collection> _trackArtistLinks; Wt::Dbo::collection> _clusters; + Wt::Dbo::collection> _trackLyrics; }; namespace Debug diff --git a/src/libs/database/include/database/TrackLyrics.hpp b/src/libs/database/include/database/TrackLyrics.hpp new file mode 100644 index 00000000..fbb49629 --- /dev/null +++ b/src/libs/database/include/database/TrackLyrics.hpp @@ -0,0 +1,151 @@ +/* + * 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 . + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "database/IdType.hpp" +#include "database/Object.hpp" +#include "database/TrackId.hpp" +#include "database/Types.hpp" + +LMS_DECLARE_IDTYPE(TrackLyricsId) + +namespace lms::db +{ + class Directory; + class Session; + class Track; + + class TrackLyrics final : public Object + { + public: + TrackLyrics() = default; + + struct FindParameters + { + std::optional range; + TrackId track; + std::optional external; // if set, true means external, false means embedded + TrackLyricsSortMethod sortMethod{ TrackLyricsSortMethod::None }; + + FindParameters& setRange(std::optional _range) + { + range = _range; + return *this; + } + FindParameters& setTrack(TrackId _track) + { + track = _track; + return *this; + } + FindParameters& setExternal(std::optional _external) + { + external = _external; + return *this; + } + FindParameters& setSortMethod(TrackLyricsSortMethod _sortMethod) + { + sortMethod = _sortMethod; + return *this; + } + }; + + // Find utilities + static std::size_t getCount(Session& session); + static std::size_t getExternalLyricsCount(Session& session); + static pointer find(Session& session, TrackLyricsId id); + static pointer find(Session& session, const std::filesystem::path& file); + static void find(Session& session, const FindParameters& params, const std::function& func); + static void find(Session& session, TrackLyricsId& lastRetrievedId, std::size_t count, const std::function& func); + static RangeResults findOrphanIds(Session& session, std::optional range); + + using SynchronizedLines = std::map; + + // Readers + const std::filesystem::path& getAbsoluteFilePath() const { return _fileAbsolutePath; } + std::string_view getFileStem() const { return _fileStem; } + const Wt::WDateTime& getLastWriteTime() const { return _fileLastWrite; } + std::size_t getFileSize() const { return _fileSize; } + std::string_view getLanguage() const { return _language; } + std::string_view getDisplayArtist() const { return _displayArtist; } + std::string_view getDisplayTitle() const { return _displayTitle; } + std::chrono::milliseconds getOffset() const { return _offset; } + bool isSynchronized() const { return _synchronized; } + SynchronizedLines getSynchronizedLines() const; + std::vector getUnsynchronizedLines() const; + Wt::Dbo::ptr getTrack() const { return _track; } + Wt::Dbo::ptr getDirectory() const { return _directory; } + + // Writers + void setAbsoluteFilePath(const std::filesystem::path& path); + void setLastWriteTime(const Wt::WDateTime& fileLastWrite) { _fileLastWrite = fileLastWrite; } + void setFileSize(std::size_t fileSize) { _fileSize = fileSize; } + void setLanguage(std::string_view language) { _language = language; } + void setOffset(std::chrono::milliseconds offset) { _offset = offset; } + void setDisplayArtist(std::string_view displayArtist) { _displayArtist = displayArtist; } + void setDisplayTitle(std::string_view displayTitle) { _displayTitle = displayTitle; } + void setSynchronizedLines(const SynchronizedLines& lines); + void setUnsynchronizedLines(std::span lines); + void setTrack(const ObjectPtr& track) { _track = getDboPtr(track); } + void setDirectory(const ObjectPtr& directory) { _directory = getDboPtr(directory); } + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _fileAbsolutePath, "absolute_file_path"); + Wt::Dbo::field(a, _fileStem, "stem"); + Wt::Dbo::field(a, _fileLastWrite, "file_last_write"); + Wt::Dbo::field(a, _fileSize, "file_size"); + Wt::Dbo::field(a, _lines, "lines"); + Wt::Dbo::field(a, _language, "language"); + Wt::Dbo::field(a, _offset, "offset"); + Wt::Dbo::field(a, _displayArtist, "display_artist"); + Wt::Dbo::field(a, _displayTitle, "display_title"); + Wt::Dbo::field(a, _synchronized, "synchronized"); + Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade); + } + + private: + friend class Session; + static pointer create(Session& session); + + std::filesystem::path _fileAbsolutePath; // optional (if embedded in media file) + std::string _fileStem; // optional (if embedded in media file) + Wt::WDateTime _fileLastWrite; // optional (if embedded in media file) + int _fileSize{}; // optional (if embedded in media file) + std::string _lines; // a json encoded array of lines (with possibly offsets) + std::string _language; + std::chrono::duration _offset{}; + std::string _displayArtist; + std::string _displayTitle; + bool _synchronized{}; + + Wt::Dbo::ptr _track; + Wt::Dbo::ptr _directory; + }; +} // namespace lms::db diff --git a/src/libs/database/include/database/Types.hpp b/src/libs/database/include/database/Types.hpp index a1494e0e..c0376283 100644 --- a/src/libs/database/include/database/Types.hpp +++ b/src/libs/database/include/database/Types.hpp @@ -166,6 +166,13 @@ namespace lms::db TrackList, // order by asc order in tracklist }; + enum class TrackLyricsSortMethod + { + None, + ExternalFirst, + EmbeddedFirst, + }; + enum class TrackArtistLinkType { Artist = 0, // regular track artist diff --git a/src/libs/database/test/CMakeLists.txt b/src/libs/database/test/CMakeLists.txt index 0e1c4171..25d651ff 100644 --- a/src/libs/database/test/CMakeLists.txt +++ b/src/libs/database/test/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(test-database TrackBookmark.cpp TrackFeatures.cpp TrackList.cpp + TrackLyrics.cpp User.cpp ) diff --git a/src/libs/database/test/Migration.cpp b/src/libs/database/test/Migration.cpp index 763f8573..745c596f 100644 --- a/src/libs/database/test/Migration.cpp +++ b/src/libs/database/test/Migration.cpp @@ -29,6 +29,7 @@ #include "database/StarredArtist.hpp" #include "database/StarredRelease.hpp" #include "database/StarredTrack.hpp" +#include "database/TrackLyrics.hpp" #include "database/UIState.hpp" #include "database/User.hpp" @@ -351,6 +352,7 @@ VALUES EXPECT_FALSE(StarredTrack::find(session, StarredTrackId{})); EXPECT_FALSE(Track::find(session, TrackId{})); EXPECT_FALSE(TrackList::find(session, TrackListId{})); + EXPECT_FALSE(TrackLyrics::find(session, TrackLyricsId{})); EXPECT_FALSE(UIState::find(session, UIStateId{})); EXPECT_FALSE(User::find(session, UserId{})); } diff --git a/src/libs/database/test/Release.cpp b/src/libs/database/test/Release.cpp index 408b5ab0..73fa51dd 100644 --- a/src/libs/database/test/Release.cpp +++ b/src/libs/database/test/Release.cpp @@ -670,6 +670,58 @@ namespace lms::db::tests } } + TEST_F(DatabaseFixture, Release_releaseArtist) + { + ScopedRelease release{ session, "MyRelease" }; + ScopedTrack track{ session }; + ScopedArtist artist{ session, "MyArtist" }; + + { + auto transaction{ session.createReadTransaction() }; + + const auto releases{ Release::findIds(session, Release::FindParameters{}.setArtist(artist.getId(), { TrackArtistLinkType::ReleaseArtist })) }; + EXPECT_EQ(releases.results.size(), 0); + EXPECT_EQ(Release::getCount(session, Release::FindParameters{}.setArtist(artist.getId(), { TrackArtistLinkType::ReleaseArtist })), 0); + EXPECT_EQ(Release::getCount(session, Release::FindParameters{}.setArtist(artist.getId())), 0); + EXPECT_EQ(release->getArtists(TrackArtistLinkType::ReleaseArtist).size(), 0); + EXPECT_EQ(release->getArtistIds(TrackArtistLinkType::ReleaseArtist).size(), 0); + } + + { + auto transaction{ session.createWriteTransaction() }; + track.get().modify()->setRelease(release.get()); + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::ReleaseArtist); + } + + { + auto transaction{ session.createReadTransaction() }; + + auto artists{ release->getArtists(TrackArtistLinkType::ReleaseArtist) }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front()->getId(), artist.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + + auto artists{ release->getArtistIds(TrackArtistLinkType::ReleaseArtist) }; + ASSERT_EQ(artists.size(), 1); + EXPECT_EQ(artists.front(), artist.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + + EXPECT_EQ(Release::getCount(session, Release::FindParameters{}), 1); + + const auto releases{ Release::findIds(session, Release::FindParameters{}.setArtist(artist.getId(), { TrackArtistLinkType::ReleaseArtist })) }; + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release.getId()); + EXPECT_EQ(Release::getCount(session, Release::FindParameters{}.setArtist(artist.getId(), { TrackArtistLinkType::ReleaseArtist })), 1); + EXPECT_EQ(Release::getCount(session, Release::FindParameters{}.setArtist(artist.getId())), 1); + } + } + TEST_F(DatabaseFixture, Release_getDiscCount) { ScopedRelease release{ session, "MyRelease" }; diff --git a/src/libs/database/test/TrackLyrics.cpp b/src/libs/database/test/TrackLyrics.cpp new file mode 100644 index 00000000..323c69cc --- /dev/null +++ b/src/libs/database/test/TrackLyrics.cpp @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2021 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "Common.hpp" + +#include "database/Track.hpp" +#include "database/TrackLyrics.hpp" + +namespace lms::db::tests +{ + using ScopedTrackLyrics = ScopedEntity; + + TEST_F(DatabaseFixture, TrackLyrics_synchronized) + { + using namespace std::chrono_literals; + + ScopedTrack track{ session }; + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(TrackLyrics::getCount(session), 0); + } + + ScopedTrackLyrics lyrics{ session }; + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(TrackLyrics::getCount(session), 1); + EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 0); + + const TrackLyrics::pointer dbLyrics{ lyrics.get() }; + EXPECT_FALSE(dbLyrics->isSynchronized()); + EXPECT_FALSE(dbLyrics->getLastWriteTime().isValid()); + EXPECT_TRUE(dbLyrics->getLastWriteTime().isNull()); + } + + { + auto transaction{ session.createWriteTransaction() }; + + TrackLyrics::pointer dbLyrics{ lyrics.get() }; + + dbLyrics.modify()->setSynchronizedLines({ { 1s + 300ms, "First line\nSecondLine" }, { 2s + 351ms, "ThirdLine" } }); + EXPECT_TRUE(dbLyrics->isSynchronized()); + } + + { + auto transaction{ session.createReadTransaction() }; + + const TrackLyrics::pointer dbLyrics{ lyrics.get() }; + + EXPECT_TRUE(dbLyrics->isSynchronized()); + const auto synchronizedLines{ dbLyrics->getSynchronizedLines() }; + ASSERT_EQ(synchronizedLines.size(), 2); + ASSERT_TRUE(synchronizedLines.contains(1s + 300ms)); + EXPECT_EQ(synchronizedLines.find(1s + 300ms)->second, "First line\nSecondLine"); + ASSERT_TRUE(synchronizedLines.contains(2s + 351ms)); + EXPECT_EQ(synchronizedLines.find(2s + 351ms)->second, "ThirdLine"); + } + } + + TEST_F(DatabaseFixture, TrackLyrics_unsynchronized) + { + using namespace std::chrono_literals; + + ScopedTrack track{ session }; + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(TrackLyrics::getCount(session), 0); + } + + ScopedTrackLyrics lyrics{ session }; + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(TrackLyrics::getCount(session), 1); + EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 0); + + const TrackLyrics::pointer dbLyrics{ lyrics.get() }; + EXPECT_FALSE(dbLyrics->isSynchronized()); + EXPECT_FALSE(dbLyrics->getLastWriteTime().isValid()); + EXPECT_TRUE(dbLyrics->getLastWriteTime().isNull()); + } + + { + auto transaction{ session.createWriteTransaction() }; + + TrackLyrics::pointer dbLyrics{ lyrics.get() }; + + dbLyrics.modify()->setUnsynchronizedLines(std::array{ "First line\nSecondLine", "ThirdLine" }); + EXPECT_FALSE(dbLyrics->isSynchronized()); + } + + { + auto transaction{ session.createReadTransaction() }; + + const TrackLyrics::pointer dbLyrics{ lyrics.get() }; + + EXPECT_FALSE(dbLyrics->isSynchronized()); + const std::vector lines{ dbLyrics->getUnsynchronizedLines() }; + ASSERT_EQ(lines.size(), 2); + EXPECT_EQ(lines[0], "First line\nSecondLine"); + EXPECT_EQ(lines[1], "ThirdLine"); + } + } + + TEST_F(DatabaseFixture, TrackLyrics_external) + { + using namespace std::chrono_literals; + + ScopedTrack track{ session }; + ScopedTrackLyrics internalLyrics{ session }; + ScopedTrackLyrics externalLyrics{ session }; + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(TrackLyrics::getCount(session), 2); + EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 0); + } + + { + auto transaction{ session.createWriteTransaction() }; + externalLyrics.get().modify()->setAbsoluteFilePath("/tmp/test.lrc"); + externalLyrics.get().modify()->setTrack(track.get()); + internalLyrics.get().modify()->setTrack(track.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(TrackLyrics::getCount(session), 2); + EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 1); + } + + { + auto transaction{ session.createWriteTransaction() }; + track.get().modify()->clearEmbeddedLyrics(); + } + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(TrackLyrics::getCount(session), 1); + EXPECT_EQ(TrackLyrics::getExternalLyricsCount(session), 1); + + { + bool visited{}; + TrackLyrics::find(session, TrackLyrics::FindParameters{}.setTrack(track.getId()), [&](const TrackLyrics::pointer& lyrics) { + EXPECT_EQ(lyrics->getAbsoluteFilePath(), "/tmp/test.lrc"); + visited = true; + }); + EXPECT_TRUE(visited); + } + + { + bool visited{}; + TrackLyrics::find(session, TrackLyrics::FindParameters{}.setExternal(true), [&](const TrackLyrics::pointer& lyrics) { + EXPECT_EQ(lyrics->getAbsoluteFilePath(), "/tmp/test.lrc"); + visited = true; + }); + EXPECT_TRUE(visited); + } + + { + bool visited{}; + TrackLyrics::find(session, TrackLyrics::FindParameters{}.setExternal(false), [&](const TrackLyrics::pointer&) { + visited = true; + }); + EXPECT_FALSE(visited); + } + + { + bool visited{}; + TrackLyrics::find(session, TrackLyrics::FindParameters{}.setSortMethod(TrackLyricsSortMethod::EmbeddedFirst), [&](const TrackLyrics::pointer&) { + visited = true; + }); + EXPECT_TRUE(visited); + } + + { + bool visited{}; + TrackLyrics::find(session, TrackLyrics::FindParameters{}.setSortMethod(TrackLyricsSortMethod::ExternalFirst), [&](const TrackLyrics::pointer&) { + visited = true; + }); + EXPECT_TRUE(visited); + } + } + } +} // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/metadata/CMakeLists.txt b/src/libs/metadata/CMakeLists.txt index e8f84c24..11e08927 100644 --- a/src/libs/metadata/CMakeLists.txt +++ b/src/libs/metadata/CMakeLists.txt @@ -4,8 +4,13 @@ if(BUILD_TESTING) add_subdirectory(test) endif() +if (BUILD_BENCHMARKS) + add_subdirectory(bench) +endif() + add_library(lmsmetadata SHARED impl/AvFormatTagReader.cpp + impl/Lyrics.cpp impl/Parser.cpp impl/TagLibTagReader.cpp impl/Utils.cpp diff --git a/src/libs/metadata/bench/CMakeLists.txt b/src/libs/metadata/bench/CMakeLists.txt new file mode 100644 index 00000000..aa093de4 --- /dev/null +++ b/src/libs/metadata/bench/CMakeLists.txt @@ -0,0 +1,9 @@ + +add_executable(bench-metadata + LyricsBench.cpp + ) + +target_link_libraries(bench-metadata PRIVATE + lmsmetadata + benchmark + ) diff --git a/src/libs/metadata/bench/LyricsBench.cpp b/src/libs/metadata/bench/LyricsBench.cpp new file mode 100644 index 00000000..caa59ed5 --- /dev/null +++ b/src/libs/metadata/bench/LyricsBench.cpp @@ -0,0 +1,94 @@ +/* + * 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 . + */ + +#include +#include + +#include + +#include "metadata/Lyrics.hpp" + +namespace lms::metadata::benchmarks +{ + static void BM_Lyrics(benchmark::State& state) + { + std::istringstream lyricsStream{ R"( +[id: rkrzmqos] +[ar: Billie Eilish] +[al: HIT ME HARD AND SOFT] +[ti: WILDFLOWER] +[length: 04:21] +[00:15.16]Things fall apart and time breaks your heart +[00:21.57]I wasn't there, but I know +[00:28.01]She was your girl, you showed her the world +[00:34.18]You fell out of love and you both let go +[00:40.33]She was cryin' on my shoulder, all I could do was hold her +[00:46.71]Only made us closer until July +[00:53.47]Now I know that you love me, you don't need to remind me +[00:59.86]I should put it all behind me, shouldn't I? +[01:04.72]But I see her in the back of my mind +[01:11.50]All the time +[01:17.75]Like a fever, like I'm burning alive +[01:24.31]Like a sign +[01:32.67]Did I cross the line? +[01:37.72]Mm, hm +[01:48.97]Well, good things don't last (good things don't last) +[01:52.28]And life moves so fast (life moves so fast) +[01:55.50]I'd never ask who was better (I'd never ask who was better) +[02:01.68]'Cause she couldn't be (she couldn't be) +[02:05.20]More different from me (more different) +[02:08.53]Happy and free (happy and free) in leather +[02:14.51]And I know that you love me (you love me) +[02:18.01]You don't need to remind me (remind me) +[02:20.88]Wanna put it all behind me, but baby +[02:26.41]I see her in the back of my mind (back of my mind) +[02:32.66]All the time (all the time) +[02:38.95]Feels like a fever (like a fever) +[02:42.06]Like I'm burning alive (burning alive) +[02:45.66]Like a sign +[02:53.68]Did I cross the line? +[02:58.03]You say no one knows you so well (oh) +[03:02.63]But every time you touch me, I just wonder how she felt +[03:08.54]Valentine's Day, cryin' in the hotel +[03:14.48]I know you didn't mean to hurt me, so I kept it to myself +[03:21.13]And I wonder +[03:24.41]Do you see her in the back of your mind? +[03:31.13]In my eyes? +[03:51.94]You say no one knows you so well +[03:56.88]But every time you touch me, I just wonder how she felt +[04:03.87]Valentine's Day, cryin' in the hotel +[04:09.16]I know you didn't mean to hurt me, so I kept it to myself +[04:15.59] +)" }; + + for (auto _ : state) + { + lyricsStream.clear(); + lyricsStream.seekg(0, std::ios::beg); + + const Lyrics lyrics{ parseLyrics(lyricsStream) }; + assert(lyrics.synchronizedLines.size() == 41); + } + } + + BENCHMARK(BM_Lyrics); + +} // namespace lms::metadata::benchmarks + +BENCHMARK_MAIN(); \ No newline at end of file diff --git a/src/libs/metadata/impl/AvFormatTagReader.cpp b/src/libs/metadata/impl/AvFormatTagReader.cpp index f9ba4655..081144ce 100644 --- a/src/libs/metadata/impl/AvFormatTagReader.cpp +++ b/src/libs/metadata/impl/AvFormatTagReader.cpp @@ -79,7 +79,6 @@ namespace lms::metadata { TagType::LyricistSortOrder, { "LYRICISTSORT" } }, { TagType::Lyricists, { "LYRICISTS" } }, { TagType::LyricistsSortOrder, { "LYRICISTSSORT" } }, - { TagType::Lyrics, { "LYRICS" } }, { TagType::Media, { "TMED", "MEDIA", "WM/MEDIA" } }, { TagType::MixDJ, { "DJMIXER" } }, { TagType::Mixer, { "MIXER" } }, @@ -207,4 +206,23 @@ namespace lms::metadata visitor("", value); }); } + + void AvFormatTagReader::visitLyricsTags(LyricsVisitor visitor) const + { + // MPEG files: need to visit LYRICS-language entries + for (const auto& [tag, value] : _metaDataMap) + { + constexpr std::string_view lyricsPrefix{ "LYRICS-" }; + if (tag.starts_with(lyricsPrefix)) + { + const std::string language{ core::stringUtils::stringToLower(tag.substr(lyricsPrefix.size())) }; + visitor(language, value); + } + } + + // otherwise, just visit regular LYRICS tag with no language + visitTagValues("LYRICS", [&](std::string_view value) { + visitor("", value); + }); + } } // namespace lms::metadata diff --git a/src/libs/metadata/impl/AvFormatTagReader.hpp b/src/libs/metadata/impl/AvFormatTagReader.hpp index 15b806b0..cdf5db4f 100644 --- a/src/libs/metadata/impl/AvFormatTagReader.hpp +++ b/src/libs/metadata/impl/AvFormatTagReader.hpp @@ -40,6 +40,7 @@ namespace lms::metadata void visitTagValues(TagType tag, TagValueVisitor visitor) const override; void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override; void visitPerformerTags(PerformerVisitor visitor) const override; + void visitLyricsTags(LyricsVisitor visitor) const override; bool hasEmbeddedCover() const override { return _hasEmbeddedCover; } const AudioProperties& getAudioProperties() const override { return _audioProperties; } diff --git a/src/libs/metadata/impl/ITagReader.hpp b/src/libs/metadata/impl/ITagReader.hpp index da1a0d8b..5cad170a 100644 --- a/src/libs/metadata/impl/ITagReader.hpp +++ b/src/libs/metadata/impl/ITagReader.hpp @@ -76,7 +76,7 @@ namespace lms::metadata LyricistSortOrder, // non standard Lyricists, // non standard LyricistsSortOrder, // non standard - Lyrics, + // Lyrics, Handled separately Media, MixDJ, Mixer, @@ -104,6 +104,7 @@ namespace lms::metadata OriginalFilename, OriginalReleaseDate, OriginalReleaseYear, + // Performers, Handled separately Podcast, PodcastURL, Producer, @@ -154,6 +155,9 @@ namespace lms::metadata using PerformerVisitor = std::function; virtual void visitPerformerTags(PerformerVisitor visitor) const = 0; + using LyricsVisitor = std::function; + virtual void visitLyricsTags(LyricsVisitor visitor) const = 0; + virtual bool hasEmbeddedCover() const = 0; virtual const AudioProperties& getAudioProperties() const = 0; }; diff --git a/src/libs/metadata/impl/Lyrics.cpp b/src/libs/metadata/impl/Lyrics.cpp new file mode 100644 index 00000000..b7fe3e18 --- /dev/null +++ b/src/libs/metadata/impl/Lyrics.cpp @@ -0,0 +1,216 @@ +/* + * 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 . + */ + +#include "metadata/Lyrics.hpp" + +#include +#include + +#include "core/String.hpp" + +namespace lms::metadata +{ + std::span getSupportedLyricsFileExtensions() + { + static const std::array fileExtensions{ ".lrc" }; // TODO handle ".txt" and ".elrc" + return fileExtensions; + } + + namespace + { + std::string_view getSubmatchString(const std::csub_match& submatch) + { + assert(submatch.matched); + return std::string_view{ submatch.first, static_cast(submatch.length()) }; + } + + // Parse a single line with ID tags like [ar: Artist] and set the appropriate fields in the Lyrics object + bool parseIDTag(std::string_view line, Lyrics& lyrics) + { + static const std::regex idTagRegex{ R"(^\[([a-zA-Z_]+):(.+?)\])" }; + std::cmatch match; + + if (std::regex_search(line.data(), line.data() + line.size(), match, idTagRegex)) + { + std::string_view tagType{ getSubmatchString(match[1]) }; + std::string_view tagValue{ core::stringUtils::stringTrim(getSubmatchString(match[2])) }; + + if (tagType == "ar") + { + lyrics.displayArtist = tagValue; + } + else if (tagType == "al") + { + lyrics.displayAlbum = tagValue; + } + else if (tagType == "ti") + { + lyrics.displayTitle = tagValue; + } + else if (tagType == "la") + { + lyrics.language = tagValue; + } + else if (tagType == "offset") + { + if (const auto value{ core::stringUtils::readAs(tagValue) }) + lyrics.offset = std::chrono::milliseconds{ *value }; + } + // not interrested by other tags like 'duration', 'id', etc. + + return true; + } + + return false; + } + + // Parse timestamps from a line and return the associated times in milliseconds + void extractTimestamps(std::string_view line, std::vector& timestamps) + { + timestamps.clear(); + static const std::regex timeTagRegex{ R"(\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\])" }; + std::cregex_iterator regexIt(line.begin(), line.end(), timeTagRegex); + std::cregex_iterator regexEnd; + + while (regexIt != regexEnd) + { + std::cmatch match{ *regexIt }; + int hour{ match[1].matched ? std::stoi(match[1].str()) : 0 }; + int minute{ std::stoi(match[2].str()) }; + int second{ std::stoi(match[3].str()) }; + int fractional{ match[4].matched ? std::stoi(match[4].str()) : 0 }; + + std::chrono::milliseconds currentTimestamp{ std::chrono::hours{ hour } + std::chrono::minutes{ minute } + std::chrono::seconds{ second } }; + + if (match[4].length() == 2) // Centiseconds + { + currentTimestamp += std::chrono::milliseconds{ fractional * 10 }; + } + else // Milliseconds + { + currentTimestamp += std::chrono::milliseconds{ fractional }; + } + + timestamps.push_back(currentTimestamp); + ++regexIt; + } + } + + // Extract the lyric text from a line, removing any timestamps + std::string_view extractLyricText(std::string_view line) + { + return line.substr(line.find_last_of(']') + 1); + } + } // namespace + + // Main function to parse lyrics from an input stream + Lyrics parseLyrics(std::istream& is) + { + Lyrics lyrics; + + enum class State + { + None, + SynchronizedLyrics, + UnsynchronizedLyrics, + }; + State currentState{ State::None }; + + std::vector lastTimestamps; + std::vector timestamps; + std::string accumulatedLyrics; + + auto applyAccumulatedLyrics = [&](bool skipTrailingEmptyLines = false) { + if (lastTimestamps.empty()) + return; + + if (skipTrailingEmptyLines) + accumulatedLyrics.resize(core::stringUtils::stringTrimEnd(accumulatedLyrics, " \t\r\n").size()); + + if (accumulatedLyrics.empty()) + return; + + for (std::chrono::milliseconds timestamp : lastTimestamps) + { + std::string& synchronizedLine{ lyrics.synchronizedLines.find(timestamp)->second }; + synchronizedLine += accumulatedLyrics; + } + accumulatedLyrics.clear(); + }; + + std::string line; + while (std::getline(is, line)) + { + std::string_view trimmedLine{ core::stringUtils::stringTrimEnd(line) }; + + // Remove potential UTF8 BOM + constexpr std::string_view utf8BOM{ "\xEF\xBB\xBF" }; + if (trimmedLine.starts_with(utf8BOM)) + trimmedLine = trimmedLine.substr(utf8BOM.size()); + + // Skip comments + if (!trimmedLine.empty() && trimmedLine.front() == '#') + continue; + + // Skip empty lines before actual lyrics + if (currentState == State::None && trimmedLine.empty()) + continue; + + if (parseIDTag(trimmedLine, lyrics)) + continue; + + extractTimestamps(trimmedLine, timestamps); + + // If there are timestamps, add as synchronized lyrics + if (!timestamps.empty()) + { + if (currentState == State::UnsynchronizedLyrics) + lyrics.unsynchronizedLines.clear(); // choice: discard all lyrics parsed so far + + currentState = State::SynchronizedLyrics; + + applyAccumulatedLyrics(); + std::string_view lyricText{ extractLyricText(trimmedLine) }; + for (std::chrono::milliseconds timestamp : timestamps) + lyrics.synchronizedLines.emplace(timestamp, lyricText); + + lastTimestamps = timestamps; + } + else + { + if (!lastTimestamps.empty()) + { + accumulatedLyrics += '\n'; + accumulatedLyrics += trimmedLine; + } + else + { + assert(currentState != State::SynchronizedLyrics); // should be handled + currentState = State::UnsynchronizedLyrics; + + lyrics.unsynchronizedLines.push_back(std::string{ trimmedLine }); + } + } + } + if (currentState == State::SynchronizedLyrics) + applyAccumulatedLyrics(true); + + return lyrics; + } +} // namespace lms::metadata diff --git a/src/libs/metadata/impl/Parser.cpp b/src/libs/metadata/impl/Parser.cpp index 2a0ab2ab..0b077e27 100644 --- a/src/libs/metadata/impl/Parser.cpp +++ b/src/libs/metadata/impl/Parser.cpp @@ -121,6 +121,29 @@ namespace lms::metadata return getTagValueFirstMatchAs(tagReader, { tagType }); } + std::vector getLyrics(const ITagReader& tagReader) + { + std::vector res; + + tagReader.visitLyricsTags([&](std::string_view language, std::string_view lyricsText) { + std::istringstream iss{ std::string{ lyricsText } }; // TODO avoid copies (ispanstream?) + try + { + Lyrics lyrics{ parseLyrics(iss) }; + if (lyrics.language.empty()) + lyrics.language = language; + + res.emplace_back(std::move(lyrics)); + } + catch (const LyricsException& e) + { + LMS_LOG(METADATA, ERROR, "Failed to parse lyrics: " + std::string{ e.what() }); + } + }); + + return res; + } + std::vector getArtists(const ITagReader& tagReader, std::initializer_list artistTagNames, std::initializer_list artistSortTagNames, @@ -198,7 +221,7 @@ namespace lms::metadata return std::any_of(std::cbegin(subStrs), std::cend(subStrs), [&str](const std::string& subStr) { return str.find(subStr) != std::string_view::npos; }); } - std::string computeArtistDisplayName(std::span artists, const std::optional artistTag, std::span artistsTag, std::span artistTagDelimiters) + std::string computeArtistDisplayName(std::span artists, const std::optional artistTag, std::span artistTagDelimiters) { std::string artistDisplayName; @@ -209,22 +232,12 @@ namespace lms::metadata std::vector artistNames; std::transform(std::cbegin(artists), std::cend(artists), std::back_inserter(artistNames), [](const Artist& artist) -> std::string_view { return artist.name; }); - // Picard use case: if we manage to match all artists in the "artist" tag (considered single-valued), and if no custom delimiter was used, we use it as the display name + // Picard use case: if we manage to match all artists in the "artist" tag (considered single-valued), and if no custom delimiter is hit, we use it as the display name // Otherwise, we reconstruct the string using a standard, hardcoded, join if (artistTag && strIsMatchingArtistNames(*artistTag, artistNames)) { - if (artistsTag.size() == artists.size()) - { - // artists was used - if (std::none_of(std::begin(artistsTag), std::cend(artistsTag), [&](std::string_view tag) { return strIsContainingAny(tag, artistTagDelimiters); })) - artistDisplayName = *artistTag; - } - else - { - // artist was used - if (!strIsContainingAny(*artistTag, artistTagDelimiters)) - artistDisplayName = *artistTag; - } + if (!strIsContainingAny(*artistTag, artistTagDelimiters)) + artistDisplayName = *artistTag; } if (artistDisplayName.empty()) @@ -331,6 +344,7 @@ namespace lms::metadata track.originalYear = utils::parseYear(*dateStr); } + track.lyrics = getLyrics(tagReader); // no custom delimiter on lyrics track.comments = getTagValuesAs(tagReader, TagType::Comment, {} /* no custom delimiter on comments */); track.copyright = getTagValueAs(tagReader, TagType::Copyright).value_or(""); track.copyrightURL = getTagValueAs(tagReader, TagType::CopyrightURL).value_or(""); @@ -354,7 +368,7 @@ namespace lms::metadata track.medium = getMedium(tagReader); track.artists = getArtists(tagReader, { TagType::Artists, TagType::Artist }, { TagType::ArtistSortOrder }, { TagType::MusicBrainzArtistID }, _artistTagDelimiters, _defaultTagDelimiters); - track.artistDisplayName = computeArtistDisplayName(track.artists, getTagValueAs(tagReader, TagType::Artist), getTagValuesAs(tagReader, TagType::Artists, {}), _artistTagDelimiters); + track.artistDisplayName = computeArtistDisplayName(track.artists, getTagValueAs(tagReader, TagType::Artist), _artistTagDelimiters); track.conductorArtists = getArtists(tagReader, { TagType::Conductors, TagType::Conductor }, { TagType::ConductorsSortOrder, TagType::ConductorSortOrder }, {}, _artistTagDelimiters, _defaultTagDelimiters); track.composerArtists = getArtists(tagReader, { TagType::Composers, TagType::Composer }, { TagType::ComposersSortOrder, TagType::ComposerSortOrder }, {}, _artistTagDelimiters, _defaultTagDelimiters); @@ -415,7 +429,7 @@ namespace lms::metadata release->name = std::move(*releaseName); release->sortName = getTagValueAs(tagReader, TagType::AlbumSortOrder).value_or(""); release->artists = getArtists(tagReader, { TagType::AlbumArtists, TagType::AlbumArtist }, { TagType::AlbumArtistsSortOrder, TagType::AlbumArtistSortOrder }, { TagType::MusicBrainzReleaseArtistID }, _artistTagDelimiters, _defaultTagDelimiters); - release->artistDisplayName = computeArtistDisplayName(release->artists, getTagValueAs(tagReader, TagType::AlbumArtist), getTagValuesAs(tagReader, TagType::AlbumArtists, {}), _artistTagDelimiters); + release->artistDisplayName = computeArtistDisplayName(release->artists, getTagValueAs(tagReader, TagType::AlbumArtist), _artistTagDelimiters); release->mbid = getTagValueAs(tagReader, TagType::MusicBrainzReleaseID); release->groupMBID = getTagValueAs(tagReader, TagType::MusicBrainzReleaseGroupID); release->mediumCount = getTagValueAs(tagReader, TagType::TotalDiscs); diff --git a/src/libs/metadata/impl/TagLibTagReader.cpp b/src/libs/metadata/impl/TagLibTagReader.cpp index 6a4d0831..47c8de81 100644 --- a/src/libs/metadata/impl/TagLibTagReader.cpp +++ b/src/libs/metadata/impl/TagLibTagReader.cpp @@ -35,8 +35,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -100,7 +102,6 @@ namespace lms::metadata { TagType::LyricistSortOrder, { "LYRICISTSORT" } }, { TagType::Lyricists, { "LYRICISTS" } }, { TagType::LyricistsSortOrder, { "LYRICISTSSORT" } }, - { TagType::Lyrics, { "LYRICS" } }, { TagType::Media, { "MEDIA" } }, { TagType::MixDJ, { "DJMIXER" } }, { TagType::Mixer, { "MIXER" } }, @@ -220,8 +221,63 @@ namespace lms::metadata mergeTagMaps(_propertyMap, apeTag->properties()); }; - // Not that good embedded pictures handling - // + get some extra tags that may not be known by taglib + auto processID3v2Tags = [&](TagLib::ID3v2::Tag& id3v2Tags) { + const auto& frameListMap{ id3v2Tags.frameListMap() }; + + // Not that good embedded pictures handling + if (!frameListMap["APIC"].isEmpty()) + _hasEmbeddedCover = true; + + // Get some extra tags that may not be known by taglib + if (!frameListMap["TSST"].isEmpty() && !_propertyMap.contains("DISCSUBTITLE")) + _propertyMap["DISCSUBTITLE"] = { frameListMap["TSST"].front()->toString() }; + + // consider each frame hold a different set of lyrics + // Synchronized lyrics frames + for (const TagLib::ID3v2::Frame* frame : frameListMap["SYLT"]) + { + const auto* lyricsFrame{ dynamic_cast(frame) }; + if (!lyricsFrame) + continue; // TODO log or assert? + + const std::string language{ lyricsFrame->language().data(), lyricsFrame->language().size() }; + std::string lyrics; + for (const TagLib::ID3v2::SynchronizedLyricsFrame::SynchedText& synchedText : lyricsFrame->synchedText()) + { + std::chrono::milliseconds timestamp{}; + switch (lyricsFrame->timestampFormat()) + { + case TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds: + timestamp = std::chrono::milliseconds{ synchedText.time }; + break; + case TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames: + timestamp = std::chrono::milliseconds{ _audioProperties.sampleRate ? (synchedText.time * 1000) / _audioProperties.sampleRate : 0 }; + break; + case TagLib::ID3v2::SynchronizedLyricsFrame::Unknown: + break; + } + + if (!lyrics.empty()) + lyrics += '\n'; + + lyrics += core::stringUtils::formatTimestamp(timestamp); + lyrics += synchedText.text.to8Bit(true); + } + + _id3v2Lyrics.emplace(language, std::move(lyrics)); + } + + // Unsynchronized lyrics frames + for (const TagLib::ID3v2::Frame* frame : frameListMap["USLT"]) + { + const auto* lyricsFrame{ dynamic_cast(frame) }; + if (!lyricsFrame) + continue; // TODO log or assert? + + const std::string language{ lyricsFrame->language().data(), lyricsFrame->language().size() }; + _id3v2Lyrics.emplace(language, lyricsFrame->text().to8Bit(true)); + } + }; // WMA if (TagLib::ASF::File * asfFile{ dynamic_cast(_file.file()) }) @@ -255,16 +311,8 @@ namespace lms::metadata // MP3 else if (TagLib::MPEG::File * mp3File{ dynamic_cast(_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() }; - } + if (mp3File->hasID3v2Tag()) + processID3v2Tags(*mp3File->ID3v2Tag()); getAPETags(mp3File->APETag()); } @@ -326,22 +374,12 @@ namespace lms::metadata else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast(_file.file()) }) { if (aiffFile->hasID3v2Tag()) - { - const auto& frameListMap{ aiffFile->tag()->frameListMap() }; - - if (!frameListMap["APIC"].isEmpty()) - _hasEmbeddedCover = true; - } + processID3v2Tags(*aiffFile->tag()); } else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast(_file.file()) }) { if (wavFile->hasID3v2Tag()) - { - const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() }; - - if (!frameListMap["APIC"].isEmpty()) - _hasEmbeddedCover = true; - } + processID3v2Tags(*wavFile->ID3v2Tag()); } if (debug && core::Service::get()->isSeverityActive(core::logging::Severity::DEBUG)) @@ -442,4 +480,20 @@ namespace lms::metadata } } } + + void TagLibTagReader::visitLyricsTags(LyricsVisitor visitor) const + { + if (!_id3v2Lyrics.empty()) + { + for (const auto& [language, lyrics] : _id3v2Lyrics) + visitor(language, lyrics); + } + else + { + // otherwise, just visit regular LYRICS tag with no language + visitTagValues("LYRICS", [&](std::string_view value) { + visitor("", value); + }); + } + } } // namespace lms::metadata diff --git a/src/libs/metadata/impl/TagLibTagReader.hpp b/src/libs/metadata/impl/TagLibTagReader.hpp index 0b13853d..86341ba4 100644 --- a/src/libs/metadata/impl/TagLibTagReader.hpp +++ b/src/libs/metadata/impl/TagLibTagReader.hpp @@ -20,6 +20,8 @@ #pragma once #include +#include +#include #include #include @@ -43,6 +45,7 @@ namespace lms::metadata void visitTagValues(TagType tag, TagValueVisitor visitor) const override; void visitTagValues(std::string_view tag, TagValueVisitor visitor) const override; void visitPerformerTags(PerformerVisitor visitor) const override; + void visitLyricsTags(LyricsVisitor visitor) const override; bool hasEmbeddedCover() const override { return _hasEmbeddedCover; } const AudioProperties& getAudioProperties() const override { return _audioProperties; } @@ -51,5 +54,6 @@ namespace lms::metadata AudioProperties _audioProperties; TagLib::PropertyMap _propertyMap; // case-insensitive keys bool _hasEmbeddedCover{}; + std::multimap _id3v2Lyrics; }; } // namespace lms::metadata diff --git a/src/libs/metadata/include/metadata/Lyrics.hpp b/src/libs/metadata/include/metadata/Lyrics.hpp new file mode 100644 index 00000000..d7e61e63 --- /dev/null +++ b/src/libs/metadata/include/metadata/Lyrics.hpp @@ -0,0 +1,55 @@ +/* + * 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 . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "metadata/Exception.hpp" + +namespace lms::metadata +{ + struct Lyrics + { + std::string language; + std::chrono::milliseconds offset{}; + std::string displayArtist; + std::string displayAlbum; + std::string displayTitle; + + std::map synchronizedLines; + std::vector unsynchronizedLines; + }; + + class LyricsException : public metadata::Exception + { + public: + using metadata::Exception::Exception; + }; + + std::span getSupportedLyricsFileExtensions(); + Lyrics parseLyrics(std::istream& is); +} // namespace lms::metadata diff --git a/src/libs/metadata/include/metadata/Types.hpp b/src/libs/metadata/include/metadata/Types.hpp index 17f17276..4c3ff9d5 100644 --- a/src/libs/metadata/include/metadata/Types.hpp +++ b/src/libs/metadata/include/metadata/Types.hpp @@ -30,6 +30,8 @@ #include "core/UUID.hpp" +#include "Lyrics.hpp" + namespace lms::metadata { using Tags = std::map /* values */>; @@ -119,6 +121,7 @@ namespace lms::metadata std::string copyright; std::string copyrightURL; std::vector comments; + std::vector lyrics; std::optional replayGain; std::string artistDisplayName; std::vector artists; diff --git a/src/libs/metadata/test/CMakeLists.txt b/src/libs/metadata/test/CMakeLists.txt index 964ddd4f..d1d212ee 100644 --- a/src/libs/metadata/test/CMakeLists.txt +++ b/src/libs/metadata/test/CMakeLists.txt @@ -1,6 +1,7 @@ include(GoogleTest) add_executable(test-metadata + Lyrics.cpp Metadata.cpp Parser.cpp Utils.cpp diff --git a/src/libs/metadata/test/Lyrics.cpp b/src/libs/metadata/test/Lyrics.cpp new file mode 100644 index 00000000..ee859f88 --- /dev/null +++ b/src/libs/metadata/test/Lyrics.cpp @@ -0,0 +1,334 @@ + +/* + * 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 . + */ + +#include + +#include "metadata/Lyrics.hpp" + +namespace lms::metadata::tests +{ + using namespace std::chrono_literals; + + TEST(Lyrics, synchronized) + { + std::istringstream is{ R"([id: dqsxdkbu] +[ar: Lady Gaga] +[al: Lady Gaga] +[ti: Die With A Smile] +[la: eng] +[length: 04:12] +[offset: -34] +[00:03.30]Ooh, ooh +[00:06.75] +[00:09.16]I, I just woke up from a dream)" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_EQ(lyrics.displayArtist, "Lady Gaga"); + EXPECT_EQ(lyrics.displayAlbum, "Lady Gaga"); + EXPECT_EQ(lyrics.displayTitle, "Die With A Smile"); + EXPECT_EQ(lyrics.language, "eng"); + EXPECT_EQ(lyrics.offset, -34ms); + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 3); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, ""); + ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream"); + } + + TEST(Lyrics, tagInMidleOfLyrics) + { + std::istringstream is{ R"([00:03.30]Ooh, ooh +[id: dqsxdkbu] +[00:09.16]I, I just woke up from a dream)" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 2); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream"); + } + + TEST(Lyrics, tagAtTheEndOfLyrics) + { + std::istringstream is{ R"([00:03.30]Ooh, ooh +[00:09.16]I, I just woke up from a dream +[id: dqsxdkbu])" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 2); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream"); + } + + TEST(Lyrics, skipEmptyBeginLines) + { + std::istringstream is{ R"( + + +[00:03.30]Ooh, ooh)" }; + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 1); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + } + + TEST(Lyrics, skipEmptyEndLines) + { + std::istringstream is{ R"([00:03.30]Ooh, ooh + +)" }; + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 1); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + } + + TEST(Lyrics, skipLeadingUnsynchronizedLyrics) + { + std::istringstream is{ R"( +Some unsynchronized lyrics +[00:03.30]Ooh, ooh)" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 1); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + } + + TEST(Lyrics, skipComments) + { + std::istringstream is{ R"(### +[00:03.30]Ooh, ooh +## just dance +[00:09.16]I, I just woke up from a dream +##end)" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 2); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream"); + } + + TEST(Lyrics, synchronized_notags) + { + std::istringstream is{ R"([00:03.30]Ooh, ooh)" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_TRUE(lyrics.displayArtist.empty()); + EXPECT_TRUE(lyrics.displayAlbum.empty()); + EXPECT_TRUE(lyrics.displayTitle.empty()); + EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 }); + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 1); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + } + + TEST(Lyrics, synchronized_timestampFormats) + { + std::istringstream is{ R"([00:03.30]First line +[00:01.301]in milliseconds +[0:02.301]leading with only one digit +[61:01.30]more than 60 minutes +[02:01:01.30]With hours +[3:01:01.30]With hours with only one digit)" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + ASSERT_EQ(lyrics.synchronizedLines.size(), 6); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + ASSERT_TRUE(lyrics.synchronizedLines.contains(1s + 301ms)); + ASSERT_TRUE(lyrics.synchronizedLines.contains(2s + 301ms)); + ASSERT_TRUE(lyrics.synchronizedLines.contains(61min + 1s + 300ms)); + ASSERT_TRUE(lyrics.synchronizedLines.contains(2h + 1min + 1s + 300ms)); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3h + 1min + 1s + 300ms)); + } + + TEST(Lyrics, synchronized_keepBlankLinesExceptEOF) + { + std::istringstream is{ R"([00:03.30]Ooh, ooh + + +[00:06.75]Foo + )" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_TRUE(lyrics.displayArtist.empty()); + EXPECT_TRUE(lyrics.displayAlbum.empty()); + EXPECT_TRUE(lyrics.displayTitle.empty()); + EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 }); + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 2); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\n\n"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "Foo"); + } + + TEST(Lyrics, synchronized_blankLinesEnd) + { + std::istringstream is{ R"([00:03.30]Ooh, ooh +SecondLine + Even a third line!! +[00:06.75]Foo + )" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_TRUE(lyrics.displayArtist.empty()); + EXPECT_TRUE(lyrics.displayAlbum.empty()); + EXPECT_TRUE(lyrics.displayTitle.empty()); + EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 }); + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 2); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\nSecondLine\n Even a third line!!"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "Foo"); + } + + TEST(Lyrics, synchronized_multitimestamps) + { + std::istringstream is{ R"([00:03.30][00:09.16] [00:15.16]Ooh, ooh +[00:06.75]I, I just woke up from a dream)" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_TRUE(lyrics.displayArtist.empty()); + EXPECT_TRUE(lyrics.displayAlbum.empty()); + EXPECT_TRUE(lyrics.displayTitle.empty()); + EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 }); + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 4); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "I, I just woke up from a dream"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "Ooh, ooh"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(15s + 160ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(15s + 160ms)->second, "Ooh, ooh"); + } + + TEST(Lyrics, synchronized_multitimestamps_blank) + { + std::istringstream is{ R"([00:03.30]Ooh, ooh +[00:06.75] +[00:09.16]I, I just woke up from a dream +[00:10.16] + +)" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_TRUE(lyrics.displayArtist.empty()); + EXPECT_TRUE(lyrics.displayAlbum.empty()); + EXPECT_TRUE(lyrics.displayTitle.empty()); + EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 }); + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 4); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, ""); + ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "I, I just woke up from a dream"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(10s + 160ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(10s + 160ms)->second, ""); + } + + TEST(Lyrics, synchronized_multitimestamps_multilines) + { + std::istringstream is{ R"([00:03.30][00:09.16]Ooh, ooh +Second line + Third line + +Fifth line after an empty one... +[00:06.75]I, I just woke up from a dream +Cool)" }; + + const Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_TRUE(lyrics.displayArtist.empty()); + EXPECT_TRUE(lyrics.displayAlbum.empty()); + EXPECT_TRUE(lyrics.displayTitle.empty()); + EXPECT_EQ(lyrics.offset, std::chrono::milliseconds{ 0 }); + EXPECT_EQ(lyrics.unsynchronizedLines.size(), 0); + ASSERT_EQ(lyrics.synchronizedLines.size(), 3); + ASSERT_TRUE(lyrics.synchronizedLines.contains(3s + 300ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(3s + 300ms)->second, "Ooh, ooh\nSecond line\n Third line\n\nFifth line after an empty one..."); + ASSERT_TRUE(lyrics.synchronizedLines.contains(6s + 750ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(6s + 750ms)->second, "I, I just woke up from a dream\nCool"); + ASSERT_TRUE(lyrics.synchronizedLines.contains(9s + 160ms)); + EXPECT_EQ(lyrics.synchronizedLines.find(9s + 160ms)->second, "Ooh, ooh\nSecond line\n Third line\n\nFifth line after an empty one..."); + } + + TEST(Lyrics, unsynchronized) + { + std::istringstream is{ R"([id: dqsxdkbu] +[ar: Lady Gaga] +[al: Lady Gaga] +[ti: Die With A Smile] +[length: 04:12] +[offset: -34] +Ooh, ooh + + +I, I just woke up from a dream + +)" }; + + Lyrics lyrics{ parseLyrics(is) }; + + EXPECT_EQ(lyrics.displayArtist, "Lady Gaga"); + EXPECT_EQ(lyrics.displayAlbum, "Lady Gaga"); + EXPECT_EQ(lyrics.displayTitle, "Die With A Smile"); + EXPECT_EQ(lyrics.offset, -34ms); + ASSERT_EQ(lyrics.unsynchronizedLines.size(), 5); + EXPECT_EQ(lyrics.unsynchronizedLines[0], "Ooh, ooh"); + EXPECT_EQ(lyrics.unsynchronizedLines[1], ""); + EXPECT_EQ(lyrics.unsynchronizedLines[2], ""); + EXPECT_EQ(lyrics.unsynchronizedLines[3], "I, I just woke up from a dream"); + EXPECT_EQ(lyrics.unsynchronizedLines[4], ""); + } +} // namespace lms::metadata::tests \ No newline at end of file diff --git a/src/libs/metadata/test/Parser.cpp b/src/libs/metadata/test/Parser.cpp index 06e101fa..43258543 100644 --- a/src/libs/metadata/test/Parser.cpp +++ b/src/libs/metadata/test/Parser.cpp @@ -75,16 +75,17 @@ namespace lms::metadata { 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" } } } + } }; + testTags.setExtraUserTags({ { "MY_AWESOME_TAG_A", { "MyTagValue1ForTagA", "MyTagValue2ForTagA" } }, + { "MY_AWESOME_TAG_B", { "MyTagValue1ForTagB", "MyTagValue2ForTagB" } } }); + testTags.setPerformersTags({ { "RoleA", { "MyPerformer1ForRoleA", "MyPerformer2ForRoleA" } }, + { "RoleB", { "MyPerformer1ForRoleB", "MyPerformer2ForRoleB" } } }); + testTags.setLyricsTags({ { "eng", "[00:00.00]First line\n[00:01.00]Second line" } }); static_cast(parser).setUserExtraTags(std::vector{ "MY_AWESOME_TAG_A", "MY_AWESOME_TAG_B", "MY_AWESOME_MISSING_TAG" }); - std::unique_ptr track{ parser.parse(testTags) }; + const std::unique_ptr track{ parser.parse(testTags) }; // Audio properties { @@ -135,6 +136,13 @@ namespace lms::metadata ASSERT_EQ(track->lyricistArtists.size(), 2); EXPECT_EQ(track->lyricistArtists[0].name, "MyLyricist1"); EXPECT_EQ(track->lyricistArtists[1].name, "MyLyricist2"); + ASSERT_EQ(track->lyrics.size(), 1); + EXPECT_EQ(track->lyrics.front().language, "eng"); + ASSERT_EQ(track->lyrics.front().synchronizedLines.size(), 2); + ASSERT_TRUE(track->lyrics.front().synchronizedLines.contains(std::chrono::milliseconds{ 0 })); + EXPECT_EQ(track->lyrics.front().synchronizedLines.find(std::chrono::milliseconds{ 0 })->second, "First line"); + ASSERT_TRUE(track->lyrics.front().synchronizedLines.contains(std::chrono::milliseconds{ 1000 })); + EXPECT_EQ(track->lyrics.front().synchronizedLines.find(std::chrono::milliseconds{ 1000 })->second, "Second line"); ASSERT_TRUE(track->mbid.has_value()); EXPECT_EQ(track->mbid.value(), core::UUID::fromString("0afb190a-6735-46df-a16d-199f48206e4a")); ASSERT_EQ(track->mixerArtists.size(), 2); @@ -273,7 +281,47 @@ namespace lms::metadata EXPECT_EQ(track->medium->release->artistDisplayName, "AlbumArtist1, AlbumArtist2"); } - TEST(Parser, customDelimitersNotForDisplayString) + TEST(Parser, customDelimiters_foundInArtist) + { + const TestTagReader testTags{ + { + { TagType::Artist, { "Artist1; Artist2" } }, + { TagType::Artists, { "Artist1", "Artist2" } }, + } + }; + + Parser parser; + static_cast(parser).setArtistTagDelimiters(std::vector{ "; " }); + + std::unique_ptr track{ parser.parse(testTags) }; + + ASSERT_EQ(track->artists.size(), 2); + EXPECT_EQ(track->artists[0].name, "Artist1"); + EXPECT_EQ(track->artists[1].name, "Artist2"); + EXPECT_EQ(track->artistDisplayName, "Artist1, Artist2"); // reconstruct the display name since we hit a custom delimiter in Artist + } + + TEST(Parser, customDelimiters_foundInArtists) + { + const TestTagReader testTags{ + { + { TagType::Artist, { "Artist1 feat. Artist2" } }, + { TagType::Artists, { "Artist1; Artist2" } }, + } + }; + + Parser parser; + static_cast(parser).setArtistTagDelimiters(std::vector{ "; " }); + + std::unique_ptr track{ parser.parse(testTags) }; + + ASSERT_EQ(track->artists.size(), 2); + EXPECT_EQ(track->artists[0].name, "Artist1"); + EXPECT_EQ(track->artists[1].name, "Artist2"); + EXPECT_EQ(track->artistDisplayName, "Artist1 feat. Artist2"); + } + + TEST(Parser, customDelimiters_notUsed) { const TestTagReader testTags{ { @@ -282,6 +330,25 @@ namespace lms::metadata } }; + Parser parser; + static_cast(parser).setArtistTagDelimiters(std::vector{ "; " }); + + std::unique_ptr track{ parser.parse(testTags) }; + + ASSERT_EQ(track->artists.size(), 2); + EXPECT_EQ(track->artists[0].name, "Artist1"); + EXPECT_EQ(track->artists[1].name, "Artist2"); + EXPECT_EQ(track->artistDisplayName, "Artist1 & Artist2"); + } + + TEST(Parser, customDelimiters_onlyInArtist) + { + const TestTagReader testTags{ + { + { TagType::Artist, { "Artist1 & Artist2" } }, + } + }; + Parser parser; static_cast(parser).setArtistTagDelimiters(std::vector{ " & " }); @@ -290,14 +357,14 @@ namespace lms::metadata ASSERT_EQ(track->artists.size(), 2); EXPECT_EQ(track->artists[0].name, "Artist1"); EXPECT_EQ(track->artists[1].name, "Artist2"); - EXPECT_EQ(track->artistDisplayName, "Artist1 & Artist2"); + EXPECT_EQ(track->artistDisplayName, "Artist1, Artist2"); // reconstructed since a custom delimiter was hit for parsing } - TEST(Parser, customDelimitersUsedForArtist) + TEST(Parser, customDelimitersUsedForArtists) { const TestTagReader testTags{ { - { TagType::Artist, { "Artist1 & Artist2" } }, + { TagType::Artists, { "Artist1 & Artist2" } }, } }; @@ -432,6 +499,26 @@ namespace lms::metadata EXPECT_EQ(track->medium->release->artistDisplayName, "Artist1, Artist2"); // reconstruct artist display name since multiple entries are found } + TEST(Parser, multipleArtistsInAlbumArtists_displayName) + { + const TestTagReader testTags{ + { + { TagType::Album, { "MyAlbum" } }, + { TagType::AlbumArtist, { "Artist1 & Artist2" } }, + { TagType::AlbumArtists, { "Artist1", "Artist2" } }, + } + }; + + std::unique_ptr track{ Parser{}.parse(testTags) }; + + ASSERT_TRUE(track->medium); + ASSERT_TRUE(track->medium->release); + ASSERT_EQ(track->medium->release->artists.size(), 2); + EXPECT_EQ(track->medium->release->artists[0].name, "Artist1"); + EXPECT_EQ(track->medium->release->artists[1].name, "Artist2"); + EXPECT_EQ(track->medium->release->artistDisplayName, "Artist1 & Artist2"); + } + TEST(Parser, multipleArtistsInAlbumArtists) { const TestTagReader testTags{ diff --git a/src/libs/metadata/test/TestTagReader.hpp b/src/libs/metadata/test/TestTagReader.hpp index d27acfaf..77ebcdf5 100644 --- a/src/libs/metadata/test/TestTagReader.hpp +++ b/src/libs/metadata/test/TestTagReader.hpp @@ -38,15 +38,29 @@ namespace lms::metadata }; using Tags = std::unordered_map>; - using Performers = std::unordered_map>; + using Performers = std::unordered_map /*names*/>; using ExtraUserTags = std::unordered_map>; - TestTagReader(Tags&& tags, Performers&& performers = {}, ExtraUserTags&& extraUserTags = {}) + using LyricsTags = std::unordered_map; + TestTagReader(Tags&& tags) : _tags{ std::move(tags) } - , _performers{ std::move(performers) } - , _extraUserTags{ std::move(extraUserTags) } { } + void setPerformersTags(Performers&& performers) + { + _performers = std::move(performers); + } + + void setExtraUserTags(ExtraUserTags&& extraUserTags) + { + _extraUserTags = std::move(extraUserTags); + } + + void setLyricsTags(LyricsTags&& lyricsTags) + { + _lyricsTags = std::move(lyricsTags); + } + void visitTagValues(TagType tag, TagValueVisitor visitor) const override { auto itValues{ _tags.find(tag) }; @@ -75,13 +89,20 @@ namespace lms::metadata } } + void visitLyricsTags(LyricsVisitor visitor) const override + { + for (const auto& [language, lyrics] : _lyricsTags) + visitor(language, lyrics); + } + bool hasEmbeddedCover() const override { return false; }; const AudioProperties& getAudioProperties() const override { return audioProperties; } private: const Tags _tags; - const Performers _performers; - const ExtraUserTags _extraUserTags; + Performers _performers; + ExtraUserTags _extraUserTags; + LyricsTags _lyricsTags; }; } // namespace lms::metadata \ No newline at end of file diff --git a/src/libs/services/scanner/CMakeLists.txt b/src/libs/services/scanner/CMakeLists.txt index a0fdc56a..c7bfda15 100644 --- a/src/libs/services/scanner/CMakeLists.txt +++ b/src/libs/services/scanner/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(lmsscanner SHARED impl/ScannerService.cpp impl/ScannerStats.cpp impl/ScanStepAssociateArtistImages.cpp + impl/ScanStepAssociateExternalLyrics.cpp impl/ScanStepAssociateReleaseImages.cpp impl/ScanStepCheckForDuplicatedFiles.cpp impl/ScanStepCheckForRemovedFiles.cpp diff --git a/src/libs/services/scanner/impl/FileScanQueue.cpp b/src/libs/services/scanner/impl/FileScanQueue.cpp index 0e083663..0fcdf95f 100644 --- a/src/libs/services/scanner/impl/FileScanQueue.cpp +++ b/src/libs/services/scanner/impl/FileScanQueue.cpp @@ -19,6 +19,8 @@ #include "FileScanQueue.hpp" +#include + #include "core/Exception.hpp" #include "core/IConfig.hpp" #include "core/ILogger.hpp" @@ -62,6 +64,10 @@ namespace lms::scanner break; case ScanRequestType::ImageFile: result.scanData = scanImageFile(path); + break; + case ScanRequestType::LyricsFile: + result.scanData = scanLyricsFile(path); + break; } { @@ -114,6 +120,28 @@ namespace lms::scanner return optInfo; } + LyricsFileScanData FileScanQueue::scanLyricsFile(const std::filesystem::path& path) + { + LMS_SCOPED_TRACE_OVERVIEW("Scanner", "ScanLyricsFile"); + + LyricsFileScanData lyrics; + + try + { + std::ifstream ifs{ path.string() }; + if (!ifs) + LMS_LOG(DBUPDATER, ERROR, "Cannot open file '" << path.string() << "'"); + else + lyrics = metadata::parseLyrics(ifs); + } + catch (const std::exception& e) + { + LMS_LOG(DBUPDATER, ERROR, "Cannot read lyrics in file '" << path.string() << "': " << e.what()); + } + + return lyrics; + } + std::size_t FileScanQueue::getResultsCount() const { std::scoped_lock lock{ _mutex }; diff --git a/src/libs/services/scanner/impl/FileScanQueue.hpp b/src/libs/services/scanner/impl/FileScanQueue.hpp index c850a30b..5503c034 100644 --- a/src/libs/services/scanner/impl/FileScanQueue.hpp +++ b/src/libs/services/scanner/impl/FileScanQueue.hpp @@ -29,6 +29,7 @@ #include "core/IOContextRunner.hpp" #include "metadata/IParser.hpp" +#include "metadata/Lyrics.hpp" namespace lms::scanner { @@ -40,10 +41,11 @@ namespace lms::scanner using AudioFileScanData = std::unique_ptr; using ImageFileScanData = std::optional; + using LyricsFileScanData = std::optional; struct FileScanResult { std::filesystem::path path; - std::variant scanData; + std::variant scanData; }; class FileScanQueue @@ -57,6 +59,7 @@ namespace lms::scanner { AudioFile, ImageFile, + LyricsFile, }; void pushScanRequest(const std::filesystem::path& path, ScanRequestType type); @@ -68,6 +71,7 @@ namespace lms::scanner private: AudioFileScanData scanAudioFile(const std::filesystem::path& path); ImageFileScanData scanImageFile(const std::filesystem::path& path); + LyricsFileScanData scanLyricsFile(const std::filesystem::path& path); metadata::IParser& _metadataParser; boost::asio::io_context _scanContext; diff --git a/src/libs/services/scanner/impl/IScanStep.hpp b/src/libs/services/scanner/impl/IScanStep.hpp index ee30062d..6cecdff3 100644 --- a/src/libs/services/scanner/impl/IScanStep.hpp +++ b/src/libs/services/scanner/impl/IScanStep.hpp @@ -19,8 +19,6 @@ #pragma once -#include - #include "core/LiteralString.hpp" #include "services/scanner/ScannerOptions.hpp" #include "services/scanner/ScannerStats.hpp" diff --git a/src/libs/services/scanner/impl/ScanStepAssociateArtistImages.cpp b/src/libs/services/scanner/impl/ScanStepAssociateArtistImages.cpp index 8dee1f27..33729986 100644 --- a/src/libs/services/scanner/impl/ScanStepAssociateArtistImages.cpp +++ b/src/libs/services/scanner/impl/ScanStepAssociateArtistImages.cpp @@ -54,6 +54,7 @@ namespace lms::scanner { db::Session& session; db::ArtistId lastRetrievedArtistId; + std::size_t processedArtistCount{}; const std::vector& artistFileNames; }; @@ -107,27 +108,44 @@ namespace lms::scanner releasePaths.insert(directory->getAbsolutePath()); }); - // Expect layout like this: - // ReleaseArtist/Release/Tracks' - // /artist.jpg - // /someOtherUserConfiguredArtistFile.jpg if (!releasePaths.empty()) - { - const std::filesystem::path artistPath{ releasePaths.size() == 1 ? releasePaths.begin()->parent_path() : core::pathUtils::getLongestCommonPath(std::cbegin(releasePaths), std::cend(releasePaths)) }; - image = findImageInDirectory(searchContext, artistPath); - } - - if (!image) { // Expect layout like this: // ReleaseArtist/Release/Tracks' - // /artist.jpg - // /someOtherUserConfiguredArtistFile.jpg - for (const std::filesystem::path& releasePath : releasePaths) + // /artist.jpg + // /someOtherUserConfiguredArtistFile.jpg + // + // Or: + // ReleaseArtist/SomeGrouping/Release/Tracks' + // /artist.jpg + // /someOtherUserConfiguredArtistFile.jpg + // + std::filesystem::path directoryToInspect{ core::pathUtils::getLongestCommonPath(std::cbegin(releasePaths), std::cend(releasePaths)) }; + while (true) { - image = findImageInDirectory(searchContext, releasePath); + image = findImageInDirectory(searchContext, directoryToInspect); if (image) break; + + std::filesystem::path parentPath{ directoryToInspect.parent_path() }; + if (parentPath == directoryToInspect) + break; + + directoryToInspect = parentPath; + } + + if (!image) + { + // Expect layout like this: + // ReleaseArtist/Release/Tracks' + // /artist.jpg + // /someOtherUserConfiguredArtistFile.jpg + for (const std::filesystem::path& releasePath : releasePaths) + { + image = findImageInDirectory(searchContext, releasePath); + if (image) + break; + } } } } @@ -150,6 +168,7 @@ namespace lms::scanner LMS_LOG(DBUPDATER, DEBUG, "Updating artist image for artist '" << artist->getName() << "', using '" << (image ? image->getAbsoluteFilePath().c_str() : "") << "'"); artistImageAssociations.push_back(ArtistImageAssociation{ artist->getId(), image ? image->getId() : db::ImageId{} }); } + searchContext.processedArtistCount++; }); } @@ -231,7 +250,7 @@ namespace lms::scanner return; updateArtistImages(session, artistImageAssociations); - context.currentStepStats.processedElems += readBatchSize; + context.currentStepStats.processedElems = searchContext.processedArtistCount; _progressCallback(context.currentStepStats); } } diff --git a/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.cpp b/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.cpp new file mode 100644 index 00000000..19c76c79 --- /dev/null +++ b/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.cpp @@ -0,0 +1,172 @@ +/* + * 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 . + */ + +#include "ScanStepAssociateExternalLyrics.hpp" + +#include + +#include "core/ILogger.hpp" +#include "database/Db.hpp" +#include "database/Directory.hpp" +#include "database/Session.hpp" +#include "database/Track.hpp" +#include "database/TrackLyrics.hpp" + +namespace lms::scanner +{ + namespace + { + constexpr std::size_t readBatchSize{ 100 }; + constexpr std::size_t writeBatchSize{ 20 }; + + struct TrackLyricsAssociation + { + db::TrackLyricsId trackLyricsId; + db::TrackId trackId; + }; + using TrackLyricsAssociationContainer = std::deque; + + struct SearchTrackLyricsContext + { + db::Session& session; + db::TrackLyricsId lastRetrievedTrackLyricsId; + std::size_t processedLyricsCount{}; + }; + + db::Track::pointer getMatchingTrack(db::Session& session, const db::TrackLyrics::pointer& lyrics) + { + db::Track::pointer matchingTrack; + + auto tryMatch = [&](std::string_view stem) { + db::Track::FindParameters params; + assert(lyrics->getDirectory()->getId().isValid()); + assert(!lyrics->getFileStem().empty()); + + params.setDirectory(lyrics->getDirectory()->getId()); + params.setStem(stem); + + db::Track::find(session, params, [&](const db::Track::pointer track) { + if (matchingTrack) + LMS_LOG(DBUPDATER, DEBUG, "External lyrics '" << lyrics->getAbsoluteFilePath() << "' already matched with '" << matchingTrack->getAbsoluteFilePath() << "', replaced by '" << track->getAbsoluteFilePath() << "'"); + + matchingTrack = track; + }); + }; + + // First try with the stem. If it does not match, try again with the parent steam, if it exists, to handle the file.laguagecode.lrc case + tryMatch(lyrics->getFileStem()); + if (!matchingTrack) + { + std::filesystem::path stem{ lyrics->getFileStem() }; + if (stem.has_extension()) + tryMatch(stem.stem().string()); + } + + return matchingTrack; + } + + bool fetchNextTrackLyricsToUpdate(SearchTrackLyricsContext& searchContext, TrackLyricsAssociationContainer& trackLyricsAssociations) + { + const db::TrackLyricsId trackLyricsId{ searchContext.lastRetrievedTrackLyricsId }; + + { + auto transaction{ searchContext.session.createReadTransaction() }; + + db::TrackLyrics::find(searchContext.session, searchContext.lastRetrievedTrackLyricsId, readBatchSize, [&](const db::TrackLyrics::pointer& trackLyrics) { + // Only iterate over external lyrics + if (trackLyrics->getAbsoluteFilePath().empty()) + return; + + db::Track::pointer track{ getMatchingTrack(searchContext.session, trackLyrics) }; + if (track != trackLyrics->getTrack()) + { + LMS_LOG(DBUPDATER, DEBUG, "Updating track for external lyrics " << trackLyrics->getAbsoluteFilePath() << ", using " << (track ? track->getAbsoluteFilePath() : "")); + trackLyricsAssociations.push_back(TrackLyricsAssociation{ .trackLyricsId = trackLyrics->getId(), .trackId = (track ? track->getId() : db::TrackId{}) }); + } + else if (!track) + { + LMS_LOG(DBUPDATER, DEBUG, "No track found for external lyrics " << trackLyrics->getAbsoluteFilePath() << "'"); + } + + searchContext.processedLyricsCount++; + }); + } + + return trackLyricsId != searchContext.lastRetrievedTrackLyricsId; + } + + void updateTrackLyrics(db::Session& session, const TrackLyricsAssociation& trackLyricsAssociation) + { + db::TrackLyrics::pointer lyrics{ db::TrackLyrics::find(session, trackLyricsAssociation.trackLyricsId) }; + assert(lyrics); + + db::Track::pointer track; + if (trackLyricsAssociation.trackId.isValid()) + track = db::Track::find(session, trackLyricsAssociation.trackId); + + lyrics.modify()->setTrack(track); + } + + void updateTrackLyrics(db::Session& session, TrackLyricsAssociationContainer& lyricsAssociations) + { + while (!lyricsAssociations.empty()) + { + auto transaction{ session.createWriteTransaction() }; + + for (std::size_t i{}; !lyricsAssociations.empty() && i < writeBatchSize; ++i) + { + updateTrackLyrics(session, lyricsAssociations.front()); + lyricsAssociations.pop_front(); + } + } + } + } // namespace + + void ScanStepAssociateExternalLyrics::process(ScanContext& context) + { + if (_abortScan) + return; + + if (context.stats.nbChanges() == 0) + return; + + auto& session{ _db.getTLSSession() }; + + { + auto transaction{ session.createReadTransaction() }; + context.currentStepStats.totalElems = db::TrackLyrics::getExternalLyricsCount(session); + } + + SearchTrackLyricsContext searchContext{ + .session = session, + .lastRetrievedTrackLyricsId = {}, + }; + + TrackLyricsAssociationContainer trackLyricsAssociations; + while (fetchNextTrackLyricsToUpdate(searchContext, trackLyricsAssociations)) + { + if (_abortScan) + return; + + updateTrackLyrics(session, trackLyricsAssociations); + context.currentStepStats.processedElems = searchContext.processedLyricsCount; + _progressCallback(context.currentStepStats); + } + } +} // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.hpp b/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.hpp new file mode 100644 index 00000000..0a5c6951 --- /dev/null +++ b/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.hpp @@ -0,0 +1,36 @@ +/* + * 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 . + */ + +#pragma once + +#include "ScanStepBase.hpp" + +namespace lms::scanner +{ + class ScanStepAssociateExternalLyrics : public ScanStepBase + { + public: + using ScanStepBase::ScanStepBase; + + private: + ScanStep getStep() const override { return ScanStep::AssociateExternalLyrics; } + core::LiteralString getStepName() const override { return "Associate external lyrics"; } + void process(ScanContext& context) override; + }; +} // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/ScanStepAssociateReleaseImages.cpp b/src/libs/services/scanner/impl/ScanStepAssociateReleaseImages.cpp index 27767515..891eea6f 100644 --- a/src/libs/services/scanner/impl/ScanStepAssociateReleaseImages.cpp +++ b/src/libs/services/scanner/impl/ScanStepAssociateReleaseImages.cpp @@ -54,6 +54,7 @@ namespace lms::scanner { db::Session& session; db::ReleaseId lastRetrievedReleaseId; + std::size_t processedReleaseCount{}; const std::vector& releaseFileNames; }; @@ -146,6 +147,7 @@ namespace lms::scanner LMS_LOG(DBUPDATER, DEBUG, "Updating release image for release '" << release->getName() << "', using '" << (image ? image->getAbsoluteFilePath().c_str() : "") << "'"); releaseImageAssociations.push_back(ReleaseImageAssociation{ release->getId(), image ? image->getId() : db::ImageId{} }); } + searchContext.processedReleaseCount++; }); } @@ -227,7 +229,7 @@ namespace lms::scanner return; updateReleaseImages(session, releaseImageAssociations); - context.currentStepStats.processedElems += readBatchSize; + context.currentStepStats.processedElems = searchContext.processedReleaseCount; _progressCallback(context.currentStepStats); } } diff --git a/src/libs/services/scanner/impl/ScanStepAssociateReleaseImages.hpp b/src/libs/services/scanner/impl/ScanStepAssociateReleaseImages.hpp index 439d61d9..0b4c9ee8 100644 --- a/src/libs/services/scanner/impl/ScanStepAssociateReleaseImages.hpp +++ b/src/libs/services/scanner/impl/ScanStepAssociateReleaseImages.hpp @@ -32,7 +32,7 @@ namespace lms::scanner ScanStepAssociateReleaseImages(InitParams& initParams); private: - ScanStep getStep() const override { return ScanStep::AssociateArtistImages; } + ScanStep getStep() const override { return ScanStep::AssociateReleaseImages; } core::LiteralString getStepName() const override { return "Associate release images"; } void process(ScanContext& context) override; diff --git a/src/libs/services/scanner/impl/ScanStepCheckForRemovedFiles.cpp b/src/libs/services/scanner/impl/ScanStepCheckForRemovedFiles.cpp index 0fb4875b..7360a0aa 100644 --- a/src/libs/services/scanner/impl/ScanStepCheckForRemovedFiles.cpp +++ b/src/libs/services/scanner/impl/ScanStepCheckForRemovedFiles.cpp @@ -25,6 +25,7 @@ #include "database/Image.hpp" #include "database/Session.hpp" #include "database/Track.hpp" +#include "database/TrackLyrics.hpp" namespace lms::scanner { @@ -45,15 +46,17 @@ namespace lms::scanner context.currentStepStats.totalElems = 0; context.currentStepStats.totalElems += db::Track::getCount(session); context.currentStepStats.totalElems += db::Image::getCount(session); + context.currentStepStats.totalElems += db::TrackLyrics::getExternalLyricsCount(session); } LMS_LOG(DBUPDATER, DEBUG, context.currentStepStats.totalElems << " files to be checked..."); checkForRemovedFiles(context, _settings.supportedAudioFileExtensions); checkForRemovedFiles(context, _settings.supportedImageFileExtensions); + checkForRemovedFiles(context, _settings.supportedLyricsFileExtensions); } template - void ScanStepCheckForRemovedFiles::checkForRemovedFiles(ScanContext& context, const std::vector& supportedFileExtensions) + void ScanStepCheckForRemovedFiles::checkForRemovedFiles(ScanContext& context, std::span supportedFileExtensions) { using namespace db; @@ -79,6 +82,13 @@ namespace lms::scanner Object::find(session, lastCheckedId, batchSize, [&](const typename Object::pointer& object) { endReached = false; + // special case for track lyrics, only check external lyrics + if constexpr (std::is_same_v) + { + if (object->getAbsoluteFilePath().empty()) + return; + } + if (!checkFile(object->getAbsoluteFilePath(), supportedFileExtensions)) objectsToRemove.push_back(object); @@ -101,7 +111,7 @@ namespace lms::scanner } } - bool ScanStepCheckForRemovedFiles::checkFile(const std::filesystem::path& p, const std::vector& allowedExtensions) + bool ScanStepCheckForRemovedFiles::checkFile(const std::filesystem::path& p, std::span allowedExtensions) { try { @@ -109,7 +119,7 @@ namespace lms::scanner // and still belongs to a media directory if (!std::filesystem::exists(p) || !std::filesystem::is_regular_file(p)) { - LMS_LOG(DBUPDATER, INFO, "Removing '" << p.string() << "': missing"); + LMS_LOG(DBUPDATER, DEBUG, "Removing '" << p.string() << "': missing"); return false; } @@ -118,13 +128,13 @@ namespace lms::scanner return core::pathUtils::isPathInRootPath(p, libraryInfo.rootDirectory, &excludeDirFileName); })) { - LMS_LOG(DBUPDATER, INFO, "Removing '" << p.string() << "': out of media directory"); + LMS_LOG(DBUPDATER, DEBUG, "Removing '" << p.string() << "': out of media directory"); return false; } if (!core::pathUtils::hasFileAnyExtension(p, allowedExtensions)) { - LMS_LOG(DBUPDATER, INFO, "Removing '" << p.string() << "': file format no longer handled"); + LMS_LOG(DBUPDATER, DEBUG, "Removing '" << p.string() << "': file format no longer handled"); return false; } diff --git a/src/libs/services/scanner/impl/ScanStepCheckForRemovedFiles.hpp b/src/libs/services/scanner/impl/ScanStepCheckForRemovedFiles.hpp index 5c79ca72..18db8896 100644 --- a/src/libs/services/scanner/impl/ScanStepCheckForRemovedFiles.hpp +++ b/src/libs/services/scanner/impl/ScanStepCheckForRemovedFiles.hpp @@ -20,6 +20,7 @@ #pragma once #include +#include #include "ScanStepBase.hpp" @@ -36,8 +37,8 @@ namespace lms::scanner void process(ScanContext& context) override; template - void checkForRemovedFiles(ScanContext& context, const std::vector& supportedFileExtensions); + void checkForRemovedFiles(ScanContext& context, std::span supportedFileExtensions); - bool checkFile(const std::filesystem::path& p, const std::vector& allowedExtensions); + bool checkFile(const std::filesystem::path& p, std::span allowedExtensions); }; } // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/ScanStepCompact.cpp b/src/libs/services/scanner/impl/ScanStepCompact.cpp index 9a4f469f..482065bd 100644 --- a/src/libs/services/scanner/impl/ScanStepCompact.cpp +++ b/src/libs/services/scanner/impl/ScanStepCompact.cpp @@ -26,7 +26,7 @@ namespace lms::scanner { void ScanStepCompact::process(ScanContext& context) { - // Don't auto compact as it may be too annoying to block the whole application + // Don't auto compact as it may be too annoying to block the whole application for very large databases if (context.scanOptions.compact) _db.getTLSSession().vacuum(); } diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp index 844095bd..90e75144 100644 --- a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp +++ b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp @@ -35,6 +35,7 @@ #include "database/Track.hpp" #include "database/TrackArtistLink.hpp" #include "database/TrackFeatures.hpp" +#include "database/TrackLyrics.hpp" #include "metadata/Exception.hpp" #include "metadata/IParser.hpp" @@ -122,6 +123,22 @@ namespace lms::scanner return directory; } + db::TrackLyrics::pointer createLyrics(Session& session, const metadata::Lyrics& lyricsInfo) + { + db::TrackLyrics::pointer lyrics{ session.create() }; + + lyrics.modify()->setLanguage(!lyricsInfo.language.empty() ? lyricsInfo.language : "xxx"); + lyrics.modify()->setOffset(lyricsInfo.offset); + lyrics.modify()->setDisplayArtist(lyricsInfo.displayArtist); + lyrics.modify()->setDisplayTitle(lyricsInfo.displayTitle); + if (!lyricsInfo.synchronizedLines.empty()) + lyrics.modify()->setSynchronizedLines(lyricsInfo.synchronizedLines); + else + lyrics.modify()->setUnsynchronizedLines(lyricsInfo.unsynchronizedLines); + + return lyrics; + } + Artist::pointer createArtist(Session& session, const metadata::Artist& artistInfo) { Artist::pointer artist{ session.create(artistInfo.name) }; @@ -448,6 +465,12 @@ namespace lms::scanner if (checkImageFileNeedScan(context, path)) _fileScanQueue.pushScanRequest(path, FileScanQueue::ScanRequestType::ImageFile); } + else if (core::pathUtils::hasFileAnyExtension(path, _settings.supportedLyricsFileExtensions)) + { + fileMatched = true; + if (checkLyricsFileNeedScan(context, path)) + _fileScanQueue.pushScanRequest(path, FileScanQueue::ScanRequestType::LyricsFile); + } if (fileMatched) { @@ -555,6 +578,34 @@ namespace lms::scanner return true; // need to scan } + bool ScanStepScanFiles::checkLyricsFileNeedScan(ScanContext& context, const std::filesystem::path& file) + { + ScanStats& stats{ context.stats }; + + const Wt::WDateTime lastWriteTime{ retrieveFileGetLastWrite(file) }; + // Should rarely fail as we are currently iterating it + if (!lastWriteTime.isValid()) + { + stats.skips++; + return false; + } + + if (!context.scanOptions.fullScan) + { + db::Session& dbSession{ _db.getTLSSession() }; + auto transaction{ _db.getTLSSession().createReadTransaction() }; + + const db::TrackLyrics::pointer lyrics{ db::TrackLyrics::find(dbSession, file) }; + if (lyrics && lyrics->getLastWriteTime() == lastWriteTime) + { + stats.skips++; + return false; + } + } + + return true; // need to scan + } + void ScanStepScanFiles::processFileScanResults(ScanContext& context, std::span scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo) { LMS_SCOPED_TRACE_OVERVIEW("Scanner", "ProcessScanResults"); @@ -577,6 +628,11 @@ namespace lms::scanner context.stats.scans++; processImageFileScanData(context, scanResult.path, scanData->has_value() ? &scanData->value() : nullptr, libraryInfo); } + else if (const LyricsFileScanData * scanData{ std::get_if(&scanResult.scanData) }) + { + context.stats.scans++; + processLyricsFileScanData(context, scanResult.path, scanData->has_value() ? &scanData->value() : nullptr, libraryInfo); + } } } @@ -783,6 +839,13 @@ namespace lms::scanner track.modify()->setTrackReplayGain(trackMetadata->replayGain); track.modify()->setArtistDisplayName(trackMetadata->artistDisplayName); + track.modify()->clearEmbeddedLyrics(); + for (const metadata::Lyrics& lyricsInfo : trackMetadata->lyrics) + { + db::TrackLyrics::pointer lyrics{ createLyrics(dbSession, lyricsInfo) }; + track.modify()->addLyrics(lyrics); + } + if (added) { LMS_LOG(DBUPDATER, DEBUG, "Added audio file '" << file.string() << "'"); @@ -851,4 +914,70 @@ namespace lms::scanner stats.updates++; } } + + void ScanStepScanFiles::processLyricsFileScanData(ScanContext& context, const std::filesystem::path& file, const metadata::Lyrics* lyricsInfo, const ScannerSettings::MediaLibraryInfo& libraryInfo) + { + LMS_SCOPED_TRACE_DETAILED("Scanner", "ProcessImageScanData"); + + ScanStats& stats{ context.stats }; + + const std::optional fileInfo{ retrieveFileInfo(file, libraryInfo.rootDirectory) }; + if (!fileInfo) + { + stats.skips++; + return; + } + + db::Session& dbSession{ _db.getTLSSession() }; + db::TrackLyrics::pointer trackLyrics{ db::TrackLyrics::find(dbSession, file) }; + + if (!lyricsInfo) + { + if (trackLyrics) + { + trackLyrics.remove(); + stats.deletions++; + } + context.stats.errors.emplace_back(file, ScanErrorType::CannotReadLyricsFile); + return; + } + + bool added; + if (!trackLyrics) + { + trackLyrics = dbSession.create(); + trackLyrics.modify()->setAbsoluteFilePath(file); + added = true; + } + else + { + added = false; + } + + trackLyrics.modify()->setLastWriteTime(fileInfo->lastWriteTime); + trackLyrics.modify()->setFileSize(fileInfo->fileSize); + trackLyrics.modify()->setLanguage(!lyricsInfo->language.empty() ? lyricsInfo->language : "xxx"); + trackLyrics.modify()->setOffset(lyricsInfo->offset); + trackLyrics.modify()->setDisplayTitle(lyricsInfo->displayTitle); + trackLyrics.modify()->setDisplayArtist(lyricsInfo->displayArtist); + if (!lyricsInfo->synchronizedLines.empty()) + trackLyrics.modify()->setSynchronizedLines(lyricsInfo->synchronizedLines); + else + trackLyrics.modify()->setUnsynchronizedLines(lyricsInfo->unsynchronizedLines); + + MediaLibrary::pointer mediaLibrary{ MediaLibrary::find(dbSession, libraryInfo.id) }; // may be null if settings are updated in // => next scan will correct this + trackLyrics.modify()->setDirectory(getOrCreateDirectory(dbSession, file.parent_path(), mediaLibrary)); + + if (added) + { + LMS_LOG(DBUPDATER, DEBUG, "Added external lyrics '" << file.string() << "'"); + stats.additions++; + } + else + { + LMS_LOG(DBUPDATER, DEBUG, "Updated external lyrics '" << file.string() << "'"); + stats.updates++; + } + } + } // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.hpp b/src/libs/services/scanner/impl/ScanStepScanFiles.hpp index fd9687d9..01041114 100644 --- a/src/libs/services/scanner/impl/ScanStepScanFiles.hpp +++ b/src/libs/services/scanner/impl/ScanStepScanFiles.hpp @@ -44,10 +44,12 @@ namespace lms::scanner bool checkAudioFileNeedScan(ScanContext& context, const std::filesystem::path& file, const ScannerSettings::MediaLibraryInfo& libraryInfo); bool checkImageFileNeedScan(ScanContext& context, const std::filesystem::path& file); + bool checkLyricsFileNeedScan(ScanContext& context, const std::filesystem::path& file); void processFileScanResults(ScanContext& context, std::span scanResults, const ScannerSettings::MediaLibraryInfo& libraryInfo); void processAudioFileScanData(ScanContext& context, const std::filesystem::path& path, const metadata::Track* trackMetadata, const ScannerSettings::MediaLibraryInfo& libraryInfo); void processImageFileScanData(ScanContext& context, const std::filesystem::path& path, const ImageInfo* imageInfo, const ScannerSettings::MediaLibraryInfo& libraryInfo); + void processLyricsFileScanData(ScanContext& context, const std::filesystem::path& path, const metadata::Lyrics* lyrics, const ScannerSettings::MediaLibraryInfo& libraryInfo); std::unique_ptr _metadataParser; const std::vector _extraTagsToParse; diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp index 437bfff0..afb82f32 100644 --- a/src/libs/services/scanner/impl/ScannerService.cpp +++ b/src/libs/services/scanner/impl/ScannerService.cpp @@ -32,6 +32,7 @@ #include "image/Image.hpp" #include "ScanStepAssociateArtistImages.hpp" +#include "ScanStepAssociateExternalLyrics.hpp" #include "ScanStepAssociateReleaseImages.hpp" #include "ScanStepCheckForDuplicatedFiles.hpp" #include "ScanStepCheckForRemovedFiles.hpp" @@ -271,7 +272,8 @@ namespace lms::scanner refreshScanSettings(); - IScanStep::ScanContext scanContext{ scanOptions, ScanStats{}, ScanStepStats{} }; + IScanStep::ScanContext scanContext; + scanContext.scanOptions = scanOptions; ScanStats& stats{ scanContext.stats }; stats.startTime = Wt::WDateTime::currentDateTime(); @@ -281,7 +283,14 @@ namespace lms::scanner LMS_SCOPED_TRACE_OVERVIEW("Scanner", scanStep->getStepName()); LMS_LOG(DBUPDATER, DEBUG, "Starting scan step '" << scanStep->getStepName() << "'"); - scanContext.currentStepStats = ScanStepStats{ .startTime = Wt::WDateTime::currentDateTime(), .stepCount = _scanSteps.size(), .stepIndex = stepIndex++, .currentStep = scanStep->getStep() }; + scanContext.currentStepStats = ScanStepStats{ + .startTime = Wt::WDateTime::currentDateTime(), + .stepCount = _scanSteps.size(), + .stepIndex = stepIndex++, + .currentStep = scanStep->getStep(), + .totalElems = 0, + .processedElems = 0 + }; notifyInProgress(scanContext.currentStepStats); scanStep->process(scanContext); @@ -341,7 +350,7 @@ namespace lms::scanner _db }; - // Order is important, steps are sequential + // Order is important: steps are sequential _scanSteps.clear(); _scanSteps.push_back(std::make_unique(params)); _scanSteps.push_back(std::make_unique(params)); @@ -349,6 +358,7 @@ namespace lms::scanner _scanSteps.push_back(std::make_unique(params)); _scanSteps.push_back(std::make_unique(params)); _scanSteps.push_back(std::make_unique(params)); + _scanSteps.push_back(std::make_unique(params)); _scanSteps.push_back(std::make_unique(params)); _scanSteps.push_back(std::make_unique(params)); _scanSteps.push_back(std::make_unique(params)); @@ -373,14 +383,21 @@ namespace lms::scanner { const auto audioFileExtensions{ scanSettings->getAudioFileExtensions() }; newSettings.supportedAudioFileExtensions.reserve(audioFileExtensions.size()); - std::transform(std::cbegin(audioFileExtensions), std::end(audioFileExtensions), std::back_inserter(newSettings.supportedAudioFileExtensions), + std::transform(std::cbegin(audioFileExtensions), std::cend(audioFileExtensions), std::back_inserter(newSettings.supportedAudioFileExtensions), [](const std::filesystem::path& extension) { return std::filesystem::path{ core::stringUtils::stringToLower(extension.string()) }; }); } { const auto imageFileExtensions{ image::getSupportedFileExtensions() }; newSettings.supportedImageFileExtensions.reserve(imageFileExtensions.size()); - std::transform(std::cbegin(imageFileExtensions), std::end(imageFileExtensions), std::back_inserter(newSettings.supportedImageFileExtensions), + std::transform(std::cbegin(imageFileExtensions), std::cend(imageFileExtensions), std::back_inserter(newSettings.supportedImageFileExtensions), + [](const std::filesystem::path& extension) { return std::filesystem::path{ core::stringUtils::stringToLower(extension.string()) }; }); + } + + { + const auto lyricsFileExtensions{ metadata::getSupportedLyricsFileExtensions() }; + newSettings.supportedLyricsFileExtensions.reserve(lyricsFileExtensions.size()); + std::transform(std::cbegin(lyricsFileExtensions), std::cend(lyricsFileExtensions), std::back_inserter(newSettings.supportedLyricsFileExtensions), [](const std::filesystem::path& extension) { return std::filesystem::path{ core::stringUtils::stringToLower(extension.string()) }; }); } @@ -410,7 +427,7 @@ namespace lms::scanner } const std::chrono::system_clock::time_point now{ std::chrono::system_clock::now() }; - _events.scanInProgress(stepStats); + _events.scanInProgress.emit(stepStats); _lastScanInProgressEmit = now; } @@ -418,7 +435,7 @@ namespace lms::scanner { std::chrono::system_clock::time_point now{ std::chrono::system_clock::now() }; - if (std::chrono::duration_cast(now - _lastScanInProgressEmit).count() > 1) + if (now - _lastScanInProgressEmit >= std::chrono::seconds{ 1 }) notifyInProgress(stepStats); } } // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/ScannerSettings.hpp b/src/libs/services/scanner/impl/ScannerSettings.hpp index 4b228694..4f313291 100644 --- a/src/libs/services/scanner/impl/ScannerSettings.hpp +++ b/src/libs/services/scanner/impl/ScannerSettings.hpp @@ -37,6 +37,7 @@ namespace lms::scanner db::ScanSettings::UpdatePeriod updatePeriod{ db::ScanSettings::UpdatePeriod::Never }; std::vector supportedAudioFileExtensions; std::vector supportedImageFileExtensions; + std::vector supportedLyricsFileExtensions; bool skipDuplicateMBID{}; std::vector extraTags; std::vector artistTagDelimiters; diff --git a/src/libs/services/scanner/impl/ScannerStats.cpp b/src/libs/services/scanner/impl/ScannerStats.cpp index be05106b..38c179f1 100644 --- a/src/libs/services/scanner/impl/ScannerStats.cpp +++ b/src/libs/services/scanner/impl/ScannerStats.cpp @@ -40,6 +40,8 @@ namespace lms::scanner unsigned ScanStepStats::progress() const { - return (processedElems / static_cast(totalElems ? totalElems : 1)) * 100; + const unsigned res{ static_cast((processedElems / static_cast(totalElems ? totalElems : 1)) * 100) }; + // can technically be above 100% since we may add files while iterating the filesystem + return res; } } // namespace lms::scanner diff --git a/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp b/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp index e3fc37f6..c70842e2 100644 --- a/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp +++ b/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp @@ -30,11 +30,12 @@ namespace lms::scanner { enum class ScanErrorType { - CannotReadFile, // cannot read file - CannotReadAudioFile, // cannot parse audio file - CannotReadImageFile, // cannot parse image file - NoAudioTrack, // no audio track found - BadDuration, // bad duration + CannotReadFile, // cannot read file + CannotReadAudioFile, // cannot parse audio file + CannotReadImageFile, // cannot parse image file + CannotReadLyricsFile, // cannot parse lyrics file + NoAudioTrack, // no audio track found + BadDuration, // bad duration }; enum class DuplicateReason @@ -62,6 +63,7 @@ namespace lms::scanner enum class ScanStep { AssociateArtistImages, + AssociateExternalLyrics, AssociateReleaseImages, CheckForDuplicatedFiles, CheckForRemovedFiles, diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index c35720ee..7d9f81e6 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -18,6 +18,7 @@ add_library(lmssubsonic SHARED impl/responses/ItemDate.cpp impl/responses/ItemGenre.cpp impl/responses/Genre.cpp + impl/responses/Lyrics.cpp impl/responses/Playlist.cpp impl/responses/RecordLabel.cpp impl/responses/ReplayGain.cpp diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 7e67c05e..bf034115 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -200,7 +200,8 @@ namespace lms::api::subsonic // Media retrieval { "/hls", { handleNotImplemented } }, { "/getCaptions", { handleNotImplemented } }, - { "/getLyrics", { handleNotImplemented } }, + { "/getLyrics", { handleGetLyrics } }, + { "/getLyricsBySongId", { handleGetLyricsBySongId } }, { "/getAvatar", { handleNotImplemented } }, // Media annotation diff --git a/src/libs/subsonic/impl/SubsonicResponse.cpp b/src/libs/subsonic/impl/SubsonicResponse.cpp index 6788aead..76e4dee0 100644 --- a/src/libs/subsonic/impl/SubsonicResponse.cpp +++ b/src/libs/subsonic/impl/SubsonicResponse.cpp @@ -196,7 +196,11 @@ namespace lms::api::subsonic auto valueToPropertyTree = [](const Node::ValueType& value) { boost::property_tree::ptree res; std::visit([&](const auto& rawValue) { - res.put_value(rawValue); + using RawValueType = std::decay_t; + if constexpr (std::is_same_v) + res.put_value(core::stringUtils::replaceInString(rawValue, "\n", "\\n")); + else + res.put_value(rawValue); }, value); diff --git a/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp index d3c8c315..4db5610e 100644 --- a/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp +++ b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.cpp @@ -31,11 +31,13 @@ #include "core/Utils.hpp" #include "database/Session.hpp" #include "database/Track.hpp" +#include "database/TrackLyrics.hpp" #include "database/User.hpp" #include "services/artwork/IArtworkService.hpp" #include "ParameterParsing.hpp" #include "SubsonicId.hpp" +#include "responses/Lyrics.hpp" namespace lms::api::subsonic { @@ -189,6 +191,74 @@ namespace lms::api::subsonic } } // namespace + Response handleGetLyrics(RequestContext& context) + { + std::string artistName{ getParameterAs(context.parameters, "artist").value_or("") }; + std::string titleName{ getParameterAs(context.parameters, "title").value_or("") }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + + // best effort search, as this API is really limited + auto transaction{ context.dbSession.createReadTransaction() }; + + db::Track::FindParameters params; + params.name = titleName; + params.artistName = artistName; + params.range = Range{ 0, 2 }; + + // Choice: we return nothing if there are too many results + const auto tracks{ db::Track::findIds(context.dbSession, params) }; + if (tracks.results.size() == 1) + { + // Choice: we return only the first lyrics if the track has many lyrics + db::TrackLyrics::FindParameters lyricsParams; + lyricsParams.setTrack(tracks.results[0]); + lyricsParams.setSortMethod(TrackLyricsSortMethod::ExternalFirst); + lyricsParams.setRange(db::Range{ 0, 1 }); + + db::TrackLyrics::find(context.dbSession, lyricsParams, [&](const db::TrackLyrics::pointer& lyrics) { + response.addNode("lyrics", createLyricsNode(context, lyrics)); + }); + } + + return response; + } + + Response handleGetLyricsBySongId(RequestContext& context) + { + // mandatory params + db::TrackId id{ getMandatoryParameterAs(context.parameters, "id") }; + + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; + Response::Node& lyricsList{ response.createNode("lyricsList") }; + lyricsList.createEmptyArrayChild("structuredLyrics"); + + auto transaction{ context.dbSession.createReadTransaction() }; + const db::Track::pointer track{ db::Track::find(context.dbSession, id) }; + if (track) + { + db::TrackLyrics::FindParameters params; + params.setTrack(track->getId()); + params.setExternal(true); // First try to only report external lyrics as they are often duplicate of embedded lyrics and support more features + + bool hasExternalLyrics{}; + db::TrackLyrics::find(context.dbSession, params, [&](const db::TrackLyrics::pointer& lyrics) { + lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics)); + hasExternalLyrics = true; + }); + + if (!hasExternalLyrics) + { + params.setExternal(false); + db::TrackLyrics::find(context.dbSession, params, [&](const db::TrackLyrics::pointer& lyrics) { + lyricsList.addArrayChild("structuredLyrics", createStructuredLyricsNode(context, lyrics)); + }); + } + } + + return response; + } + void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response) { std::shared_ptr resourceHandler; diff --git a/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp index f99085d1..d5be9ac7 100644 --- a/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp +++ b/src/libs/subsonic/impl/entrypoints/MediaRetrieval.hpp @@ -26,6 +26,9 @@ namespace lms::api::subsonic { + Response handleGetLyrics(RequestContext& context); + Response handleGetLyricsBySongId(RequestContext& context); + void handleDownload(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); void handleStream(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); void handleGetCoverArt(RequestContext& context, const Wt::Http::Request& request, Wt::Http::Response& response); diff --git a/src/libs/subsonic/impl/entrypoints/System.cpp b/src/libs/subsonic/impl/entrypoints/System.cpp index b6ae4be6..76ad01ca 100644 --- a/src/libs/subsonic/impl/entrypoints/System.cpp +++ b/src/libs/subsonic/impl/entrypoints/System.cpp @@ -35,6 +35,12 @@ namespace lms::api::subsonic formPostNode.addArrayValue("versions", 1); } + { + Response::Node& songLyricsNode{ response.createArrayNode("openSubsonicExtensions") }; + songLyricsNode.setAttribute("name", "songLyrics"); + songLyricsNode.addArrayValue("versions", 1); + } + return response; }; } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/responses/Lyrics.cpp b/src/libs/subsonic/impl/responses/Lyrics.cpp new file mode 100644 index 00000000..bc00620c --- /dev/null +++ b/src/libs/subsonic/impl/responses/Lyrics.cpp @@ -0,0 +1,121 @@ +/* + * 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 . + */ + +#include "responses/Lyrics.hpp" + +#include "database/TrackLyrics.hpp" + +#include "RequestContext.hpp" + +namespace lms::api::subsonic +{ + Response::Node createLyricsNode(RequestContext& context, const db::TrackLyrics::pointer& lyrics) + { + Response::Node lyricsNode; + + if (!lyrics->getDisplayArtist().empty()) + lyricsNode.setAttribute("artist", lyrics->getDisplayArtist()); + + if (!lyrics->getDisplayTitle().empty()) + lyricsNode.setAttribute("title", lyrics->getDisplayTitle()); + + std::string lyricsText; + auto addLine{ [&](std::string&& line) { + if (!lyricsText.empty()) + lyricsText += "\n"; + lyricsText += std::move(line); + } }; + if (!lyrics->isSynchronized()) + { + std::vector lines{ lyrics->getUnsynchronizedLines() }; + for (std::string& line : lines) + addLine(std::move(line)); + } + else + { + // Reconstruct the lyrics without timestamp if needed + db::TrackLyrics::SynchronizedLines lines{ lyrics->getSynchronizedLines() }; + for (auto& [timestamp, line] : lines) + addLine(std::move(line)); + } + + switch (context.responseFormat) + { + case ResponseFormat::json: + lyricsNode.setAttribute("value", lyricsText); + break; + case ResponseFormat::xml: + lyricsNode.setValue(lyricsText); + break; + } + + return lyricsNode; + } + + Response::Node createStructuredLyricsNode(RequestContext& context, const db::ObjectPtr& lyrics) + { + Response::Node lyricsNode; + + if (!lyrics->getDisplayArtist().empty()) + lyricsNode.setAttribute("artist", lyrics->getDisplayArtist()); + + if (!lyrics->getDisplayTitle().empty()) + lyricsNode.setAttribute("title", lyrics->getDisplayTitle()); + + lyricsNode.setAttribute("lang", lyrics->getLanguage()); + lyricsNode.setAttribute("synced", lyrics->isSynchronized()); + if (lyrics->getOffset() != std::chrono::milliseconds{}) + lyricsNode.setAttribute("offset", lyrics->getOffset().count()); + + lyricsNode.createEmptyArrayChild("lines"); + auto addLine{ [&](std::string&& line, std::optional timestamp = std::nullopt) { + Response::Node lineNode; + if (timestamp) + lineNode.setAttribute("start", std::chrono::duration_cast(*timestamp).count()); + + switch (context.responseFormat) + { + case ResponseFormat::json: + lineNode.setAttribute("value", std::move(line)); + break; + case ResponseFormat::xml: + lineNode.setValue(std::move(line)); + break; + } + lyricsNode.addArrayChild("lines", std::move(lineNode)); + } }; + + if (!lyrics->isSynchronized()) + { + std::vector lines{ lyrics->getUnsynchronizedLines() }; + + for (std::string& line : lines) + addLine(std::move(line)); + } + else + { + db::TrackLyrics::SynchronizedLines lines{ lyrics->getSynchronizedLines() }; + for (auto& [timestamp, line] : lines) + addLine(std::move(line), timestamp); + } + + return lyricsNode; + } + +} // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Lyrics.hpp b/src/libs/subsonic/impl/responses/Lyrics.hpp new file mode 100644 index 00000000..78c2f1b4 --- /dev/null +++ b/src/libs/subsonic/impl/responses/Lyrics.hpp @@ -0,0 +1,37 @@ +/* + * 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 . + */ + +#pragma once + +#include "database/Object.hpp" + +#include "SubsonicResponse.hpp" + +namespace lms::db +{ + class TrackLyrics; +} + +namespace lms::api::subsonic +{ + struct RequestContext; + + Response::Node createLyricsNode(RequestContext& context, const db::ObjectPtr& lyrics); + Response::Node createStructuredLyricsNode(RequestContext& context, const db::ObjectPtr& lyrics); +} // namespace lms::api::subsonic diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index 1e5ca064..71d9249b 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -111,6 +111,18 @@ namespace lms::ui return res; } + Wt::WLocale createLocale(const std::string& name) + { + Wt::WLocale locale{ name }; + locale.setDecimalPoint(Wt::WString::tr("Lms.locale.decimal-point").toUTF8()); + locale.setGroupSeparator(Wt::WString::tr("Lms.locale.group-separator").toUTF8()); + locale.setDateFormat(Wt::WString::tr("Lms.locale.date-format").toUTF8()); + locale.setTimeFormat(Wt::WString::tr("Lms.locale.time-format").toUTF8()); + locale.setDateTimeFormat(Wt::WString::tr("Lms.locale.date-time-format").toUTF8()); + + return locale; + } + enum IdxRoot { IdxExplore = 0, @@ -274,6 +286,7 @@ namespace lms::ui setTitle(); setLocalizedStrings(getOrCreateMessageBundle()); + setLocale(createLocale(Wt::WLocale::currentLocale().name())); // Handle Media Scanner events and other session events enableUpdates(true); @@ -362,7 +375,7 @@ namespace lms::ui setUserInfo(userId, strongAuth); - LMS_LOG(UI, INFO, "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent()); + LMS_LOG(UI, INFO, "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent() << ", locale = '" << locale().name() << "'"); _appManager.registerApplication(*this); _appManager.applicationRegistered.connect(this, [this](LmsApplication& otherApplication) { diff --git a/src/lms/ui/Utils.cpp b/src/lms/ui/Utils.cpp index f0e39ec9..f353af0d 100644 --- a/src/lms/ui/Utils.cpp +++ b/src/lms/ui/Utils.cpp @@ -239,29 +239,24 @@ namespace lms::ui::utils { using namespace db; - Artist::FindParameters params; - params.setRelease(release->getId()); - params.setLinkType(TrackArtistLinkType::ReleaseArtist); - - if (const auto releaseArtists{ Artist::findIds(LmsApp->getDbSession(), params) }; !releaseArtists.results.empty()) + if (const std::vector releaseArtists{ release->getArtistIds(TrackArtistLinkType::ReleaseArtist) }; !releaseArtists.empty()) { - if (releaseArtists.results.size() == 1 && releaseArtists.results.front() == omitIfMatchThisArtist) + if (releaseArtists.size() == 1 && releaseArtists.front() == omitIfMatchThisArtist) return {}; - return createArtistDisplayNameWithAnchors(release->getArtistDisplayName(), releaseArtists.results, cssAnchorClass); + return createArtistDisplayNameWithAnchors(release->getArtistDisplayName(), releaseArtists, cssAnchorClass); } - params.setLinkType(TrackArtistLinkType::Artist); - const auto artists{ Artist::findIds(LmsApp->getDbSession(), params) }; - if (artists.results.size() == 1) + const auto artists{ release->getArtistIds(TrackArtistLinkType::Artist) }; + if (artists.size() == 1) { - if (artists.results.front() == omitIfMatchThisArtist) + if (artists.front() == omitIfMatchThisArtist) return {}; - return createArtistAnchorList({ artists.results.front() }, cssAnchorClass); + return createArtistAnchorList({ artists.front() }, cssAnchorClass); } - if (artists.results.size() > 1) + if (artists.size() > 1) { auto res{ std::make_unique() }; res->addNew(Wt::WString::tr("Lms.Explore.various-artists")); diff --git a/src/lms/ui/admin/ScannerController.cpp b/src/lms/ui/admin/ScannerController.cpp index 0289e157..8070d7dd 100644 --- a/src/lms/ui/admin/ScannerController.cpp +++ b/src/lms/ui/admin/ScannerController.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -40,17 +41,7 @@ namespace lms::ui { std::string durationToString(const Wt::WDateTime& begin, const Wt::WDateTime& end) { - const auto secs{ std::chrono::duration_cast(end.toTimePoint() - begin.toTimePoint()).count() }; - - std::ostringstream oss; - - if (secs >= 3600) - oss << secs / 3600 << "h"; - if (secs >= 60) - oss << std::setw(2) << std::setfill('0') << (secs % 3600) / 60 << "m"; - oss << std::setw(2) << std::setfill('0') << (secs % 60) << "s"; - - return oss.str(); + return begin.timeTo(end).toUTF8(); } } // namespace @@ -123,6 +114,8 @@ namespace lms::ui return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-audio-file"); case scanner::ScanErrorType::CannotReadImageFile: return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-image-file"); + case scanner::ScanErrorType::CannotReadLyricsFile: + return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-lyrics-file"); case scanner::ScanErrorType::NoAudioTrack: return Wt::WString::tr("Lms.Admin.ScannerController.no-audio-track"); case scanner::ScanErrorType::BadDuration: @@ -220,7 +213,8 @@ namespace lms::ui _lastScanStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.last-scan-status") .arg(status.lastCompleteScanStats->nbFiles()) .arg(durationToString(status.lastCompleteScanStats->startTime, status.lastCompleteScanStats->stopTime)) - .arg(status.lastCompleteScanStats->stopTime.toString()) + .arg(status.lastCompleteScanStats->stopTime.date().toString(Wt::WLocale::currentLocale().dateFormat())) + .arg(status.lastCompleteScanStats->stopTime.time().toString(Wt::WLocale::currentLocale().timeFormat())) .arg(status.lastCompleteScanStats->errors.size()) .arg(status.lastCompleteScanStats->duplicates.size())); @@ -247,7 +241,8 @@ namespace lms::ui case IScannerService::State::Scheduled: _status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled") - .arg(status.nextScheduledScan.toString())); + .arg(status.nextScheduledScan.date().toString(Wt::WLocale::currentLocale().dateFormat())) + .arg(status.nextScheduledScan.time().toString(Wt::WLocale::currentLocale().timeFormat()))); _stepStatus->setText(""); break; @@ -274,6 +269,11 @@ namespace lms::ui .arg(stepStats.progress())); break; + case ScanStep::AssociateExternalLyrics: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-associating-external-lyrics") + .arg(stepStats.progress())); + break; + case ScanStep::AssociateReleaseImages: _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-associating-release-images") .arg(stepStats.progress())); diff --git a/src/lms/ui/explore/ReleaseView.cpp b/src/lms/ui/explore/ReleaseView.cpp index 0c969d19..ae69b29c 100644 --- a/src/lms/ui/explore/ReleaseView.cpp +++ b/src/lms/ui/explore/ReleaseView.cpp @@ -466,6 +466,14 @@ namespace lms::ui entry->bindNew("track-info", Wt::WString::tr("Lms.Explore.track-info")) ->clicked() .connect([this, trackId] { TrackListHelpers::showTrackInfoModal(trackId, _filters); }); + + if (track->hasLyrics()) + { + entry->setCondition("if-has-lyrics", true); + entry->bindNew("track-lyrics", Wt::WString::tr("Lms.Explore.track-lyrics")) + ->clicked() + .connect([trackId] { TrackListHelpers::showTrackLyricsModal(trackId); }); + } } entry->bindString("duration", utils::durationToString(track->getDuration()), Wt::TextFormat::Plain); diff --git a/src/lms/ui/explore/TrackListHelpers.cpp b/src/lms/ui/explore/TrackListHelpers.cpp index 6ed07c76..f8bb10bb 100644 --- a/src/lms/ui/explore/TrackListHelpers.cpp +++ b/src/lms/ui/explore/TrackListHelpers.cpp @@ -33,6 +33,7 @@ #include "database/Session.hpp" #include "database/Track.hpp" #include "database/TrackArtistLink.hpp" +#include "database/TrackLyrics.hpp" #include "database/User.hpp" #include "services/feedback/IFeedbackService.hpp" #include "services/scrobbling/IScrobblingService.hpp" @@ -155,6 +156,52 @@ namespace lms::ui::TrackListHelpers LmsApp->getModalManager().show(std::move(trackInfo)); } + void showTrackLyricsModal(db::TrackId trackId) + { + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; + + auto trackLyrics{ std::make_unique