From 72b1367ea6c9d9ec06fe1388e82b43aaf30b32e2 Mon Sep 17 00:00:00 2001 From: emeric Date: Sat, 26 Oct 2024 16:58:13 +0200 Subject: [PATCH] Added embedded/external lyrics support: parsing + indexing, ref #379 --- approot/messages.xml | 2 + approot/messages_fr.xml | 2 + approot/messages_it.xml | 2 + approot/messages_pl.xml | 2 + approot/messages_zh.xml | 3 +- src/libs/core/impl/Path.cpp | 2 +- src/libs/core/include/core/Path.hpp | 4 +- src/libs/core/include/core/String.hpp | 4 +- src/libs/database/CMakeLists.txt | 1 + src/libs/database/impl/AuthToken.cpp | 2 +- src/libs/database/impl/Directory.cpp | 2 + src/libs/database/impl/Migration.cpp | 324 ++++++++++-------- src/libs/database/impl/Session.cpp | 150 ++++---- src/libs/database/impl/Track.cpp | 23 ++ src/libs/database/impl/TrackLyrics.cpp | 187 ++++++++++ src/libs/database/impl/Utils.hpp | 9 + src/libs/database/include/database/Track.hpp | 14 + .../database/include/database/TrackLyrics.hpp | 118 +++++++ src/libs/database/test/CMakeLists.txt | 1 + src/libs/database/test/Migration.cpp | 2 + src/libs/database/test/TrackLyrics.cpp | 168 +++++++++ src/libs/metadata/CMakeLists.txt | 5 + src/libs/metadata/bench/CMakeLists.txt | 9 + src/libs/metadata/bench/LyricsBench.cpp | 94 +++++ src/libs/metadata/impl/Lyrics.cpp | 212 ++++++++++++ src/libs/metadata/impl/Parser.cpp | 14 + src/libs/metadata/include/metadata/Lyrics.hpp | 55 +++ src/libs/metadata/include/metadata/Types.hpp | 3 + src/libs/metadata/test/CMakeLists.txt | 1 + src/libs/metadata/test/Lyrics.cpp | 318 +++++++++++++++++ src/libs/metadata/test/Parser.cpp | 48 ++- src/libs/services/scanner/CMakeLists.txt | 1 + .../services/scanner/impl/FileScanQueue.cpp | 28 ++ .../services/scanner/impl/FileScanQueue.hpp | 6 +- src/libs/services/scanner/impl/IScanStep.hpp | 2 - .../impl/ScanStepAssociateArtistImages.cpp | 4 +- .../impl/ScanStepAssociateExternalLyrics.cpp | 167 +++++++++ .../impl/ScanStepAssociateExternalLyrics.hpp | 36 ++ .../impl/ScanStepAssociateReleaseImages.cpp | 4 +- .../impl/ScanStepAssociateReleaseImages.hpp | 2 +- .../impl/ScanStepCheckForRemovedFiles.cpp | 14 +- .../impl/ScanStepCheckForRemovedFiles.hpp | 5 +- .../services/scanner/impl/ScanStepCompact.cpp | 2 +- .../scanner/impl/ScanStepScanFiles.cpp | 129 +++++++ .../scanner/impl/ScanStepScanFiles.hpp | 2 + .../services/scanner/impl/ScannerService.cpp | 31 +- .../services/scanner/impl/ScannerSettings.hpp | 1 + .../services/scanner/impl/ScannerStats.cpp | 4 +- .../include/services/scanner/ScannerStats.hpp | 12 +- src/lms/ui/admin/ScannerController.cpp | 7 + src/tools/metadata/LmsMetadata.cpp | 49 ++- 51 files changed, 2034 insertions(+), 253 deletions(-) create mode 100644 src/libs/database/impl/TrackLyrics.cpp create mode 100644 src/libs/database/include/database/TrackLyrics.hpp create mode 100644 src/libs/database/test/TrackLyrics.cpp create mode 100644 src/libs/metadata/bench/CMakeLists.txt create mode 100644 src/libs/metadata/bench/LyricsBench.cpp create mode 100644 src/libs/metadata/impl/Lyrics.cpp create mode 100644 src/libs/metadata/include/metadata/Lyrics.hpp create mode 100644 src/libs/metadata/test/Lyrics.cpp create mode 100644 src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.cpp create mode 100644 src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.hpp diff --git a/approot/messages.xml b/approot/messages.xml index 1a7e401c..faac59d5 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -89,6 +89,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: @@ -110,6 +111,7 @@ Scheduled on {1} 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}% diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index 5e3cdef5..154354d0 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -89,6 +89,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 : @@ -110,6 +111,7 @@ Planifié le {1} 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}% diff --git a/approot/messages_it.xml b/approot/messages_it.xml index e5755c71..19f6fdc9 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -89,6 +89,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: @@ -110,6 +111,7 @@ Pianificato il {1} 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}% diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index 01343f09..4637126d 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -90,6 +90,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! @@ -119,6 +120,7 @@ Zaplanowano na {1} 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 diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index efb8dd4f..f497ca20 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -85,13 +85,13 @@ 更新开始时间 每周 - 无法获得音轨时间 无法解析文件 无法读取文件 + {1} 个重复文件: {1} 个错误: @@ -113,6 +113,7 @@ + 检查文件中... {1}% 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/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..2b2fedda 100644 --- a/src/libs/core/include/core/String.hpp +++ b/src/libs/core/include/core/String.hpp @@ -51,8 +51,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); 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/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..4e0d32be 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,21 @@ 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)); + } + 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..7d687744 --- /dev/null +++ b/src/libs/database/impl/TrackLyrics.cpp @@ -0,0 +1,187 @@ +/* + * 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 +{ + 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, TrackId trackId, const std::function& func) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->query>("SELECT t_lrc from track_lyrics t_lrc").where("t_lrc.track_id = ?").bind(trackId) }; + + utils::forEachQueryResult(query, [&](const TrackLyrics::pointer& lyrics) { + func(lyrics); + }); + } + + 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/Track.hpp b/src/libs/database/include/database/Track.hpp index 8e9d3d9a..2c8e9451 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); } @@ -305,6 +315,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 +334,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 +362,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 +381,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..502c4eaa --- /dev/null +++ b/src/libs/database/include/database/TrackLyrics.hpp @@ -0,0 +1,118 @@ +/* + * 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; + + // 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, TrackId trackId, 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; } + 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/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/TrackLyrics.cpp b/src/libs/database/test/TrackLyrics.cpp new file mode 100644 index 00000000..cf8d7c2f --- /dev/null +++ b/src/libs/database/test/TrackLyrics.cpp @@ -0,0 +1,168 @@ +/* + * 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, track.getId(), [&](const TrackLyrics::pointer& lyrics) { + EXPECT_EQ(lyrics->getAbsoluteFilePath(), "/tmp/test.lrc"); + 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/Lyrics.cpp b/src/libs/metadata/impl/Lyrics.cpp new file mode 100644 index 00000000..e698348b --- /dev/null +++ b/src/libs/metadata/impl/Lyrics.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 "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) }; + + // 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 0dab9927..9206c7cf 100644 --- a/src/libs/metadata/impl/Parser.cpp +++ b/src/libs/metadata/impl/Parser.cpp @@ -321,6 +321,20 @@ namespace lms::metadata track.originalYear = utils::parseYear(*dateStr); } + std::vector lyricsEntries{ getTagValuesAs(tagReader, TagType::Lyrics, {} /* no custom delimiter on lyrics */) }; + for (const std::string& lyrics : lyricsEntries) + { + std::istringstream iss{ lyrics }; + try + { + track.lyrics.emplace_back(parseLyrics(iss)); + } + catch (const LyricsException& e) + { + LMS_LOG(METADATA, ERROR, "Failed to parse lyrics: " + std::string{ e.what() }); + } + } + 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(""); diff --git a/src/libs/metadata/include/metadata/Lyrics.hpp b/src/libs/metadata/include/metadata/Lyrics.hpp new file mode 100644 index 00000000..31afca36 --- /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{ "und" }; + 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..b5c1604c --- /dev/null +++ b/src/libs/metadata/test/Lyrics.cpp @@ -0,0 +1,318 @@ + +/* + * 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, 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 40871d43..9986743e 100644 --- a/src/libs/metadata/test/Parser.cpp +++ b/src/libs/metadata/test/Parser.cpp @@ -66,6 +66,7 @@ namespace lms::metadata { TagType::Remixer, { "MyRemixer1", "MyRemixer2" } }, { TagType::RecordLabel, { "Label1", "Label2" } }, { TagType::Language, { "Language1", "Language2" } }, + { TagType::Lyrics, { "[00:00.00]First line\n[00:01.00]Second line" } }, { TagType::Lyricist, { "MyLyricist1", "MyLyricist2" } }, { TagType::OriginalReleaseDate, { "2019/02/03" } }, { TagType::ReleaseType, { "Album", "Compilation" } }, @@ -84,7 +85,7 @@ namespace lms::metadata 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,12 @@ 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); + 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); @@ -352,6 +359,25 @@ namespace lms::metadata EXPECT_EQ(track->artistDisplayName, "Artist1, Artist2"); // reconstructed since a custom delimiter was hit for parsing } + TEST(Parser, customDelimitersUsedForArtists) + { + const TestTagReader testTags{ + { + { 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"); // reconstructed since a custom delimiter was hit for parsing + } + TEST(Parser, noArtistInArtist) { const TestTagReader testTags{ @@ -472,6 +498,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/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..79aed040 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; }; @@ -150,6 +151,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 +233,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..38d8b014 --- /dev/null +++ b/src/libs/services/scanner/impl/ScanStepAssociateExternalLyrics.cpp @@ -0,0 +1,167 @@ +/* + * 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().c_str() : "") << "'"); + trackLyricsAssociations.push_back(TrackLyricsAssociation{ .trackLyricsId = trackLyrics->getId(), .trackId = (track ? track->getId() : db::TrackId{}) }); + } + 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..888ccc2e 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 { 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..2825fdd7 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); + 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); + 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..ce2988c7 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/lms/ui/admin/ScannerController.cpp b/src/lms/ui/admin/ScannerController.cpp index 0289e157..a2cfbf29 100644 --- a/src/lms/ui/admin/ScannerController.cpp +++ b/src/lms/ui/admin/ScannerController.cpp @@ -123,6 +123,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: @@ -274,6 +276,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/tools/metadata/LmsMetadata.cpp b/src/tools/metadata/LmsMetadata.cpp index 0af49eb3..8458085f 100644 --- a/src/tools/metadata/LmsMetadata.cpp +++ b/src/tools/metadata/LmsMetadata.cpp @@ -18,6 +18,7 @@ */ #include +#include #include #include #include @@ -35,6 +36,22 @@ namespace lms::metadata { + std::ostream& operator<<(std::ostream& os, const Lyrics& lyrics) + { + os << "\tTitle display name: " << lyrics.displayTitle << std::endl; + os << "\tArtist display name: " << lyrics.displayArtist << std::endl; + os << "\tAlbum display name: " << lyrics.displayAlbum << std::endl; + os << "\tOffset: " << lyrics.offset.count() << "ms" << std::endl; + os << "\tLanguage: " << lyrics.language << std::endl; + os << "\tSynchronized: " << !lyrics.synchronizedLines.empty() << std::endl; + for (const auto& [timestamp, line] : lyrics.synchronizedLines) + os << "\t[" << timestamp.count() << "] '" << line << "'" << std::endl; + for (const auto& line : lyrics.unsynchronizedLines) + os << "\t'" << line << "'" << std::endl; + + return os; + } + std::ostream& operator<<(std::ostream& os, const AudioProperties& audioProperties) { os << "\tBitrate: " << audioProperties.bitrate << " bps" << std::endl; @@ -221,6 +238,10 @@ namespace lms::metadata if (!track->copyright.empty()) std::cout << "Copyright: " << track->copyright << std::endl; + for (const Lyrics& lyrics : track->lyrics) + std::cout << "Lyrics:\n" + << lyrics << std::endl; + for (const auto& comment : track->comments) std::cout << "Comment: '" << comment << "'" << std::endl; @@ -244,7 +265,7 @@ int main(int argc, char* argv[]) ("help,h", "Display this help message") ("tag-delimiter", program_options::value>()->default_value(std::vector{}, "[]"), "Tag delimiters (multiple allowed)") ("artist-tag-delimiter", program_options::value>()->default_value(std::vector{}, "[]"), "Artist tag delimiters (multiple allowed)") - ("parser", program_options::value>()->default_value(std::vector{ "taglib" }, "[taglib]"), "Parser to be used (value can be \"taglib\" or \"ffmpeg\")"); + ("parser", program_options::value>()->default_value(std::vector{ "taglib" }, "[taglib]"), "Parser to be used (value can be \"taglib\", \"ffmpeg\" or \"lyrics\")"); // clang-format on program_options::options_description hiddenOptions{ "Hidden options" }; @@ -286,6 +307,7 @@ int main(int argc, char* argv[]) enum class Parser { + Lyrics, Taglib, Ffmpeg, }; @@ -299,7 +321,9 @@ int main(int argc, char* argv[]) core::EnumSet parsers; for (const std::string& strParser : vm["parser"].as>()) { - if (core::stringUtils::stringCaseInsensitiveEqual(strParser, "taglib")) + if (core::stringUtils::stringCaseInsensitiveEqual(strParser, "lyrics")) + parsers.insert(Parser::Lyrics); + else if (core::stringUtils::stringCaseInsensitiveEqual(strParser, "taglib")) parsers.insert(Parser::Taglib); else if (core::stringUtils::stringCaseInsensitiveEqual(strParser, "ffmpeg")) parsers.insert(Parser::Ffmpeg); @@ -333,6 +357,27 @@ int main(int argc, char* argv[]) std::cout << "Parsing file '" << file << "'" << std::endl; + if (parsers.contains(Parser::Lyrics)) + { + try + { + std::cout << "Using Lyrics:" << std::endl; + + std::ifstream ifs{ file.string() }; + if (ifs) + { + const metadata::Lyrics lyrics{ metadata::parseLyrics(ifs) }; + std::cout << lyrics << std::endl; + } + else + std::cerr << "Cannot open file '" << file.string() << "'"; + } + catch (metadata::Exception& e) + { + std::cerr << "Parsing failed: " << e.what() << std::endl; + } + } + if (parsers.contains(Parser::Ffmpeg)) { try