Added embedded/external lyrics support: parsing + indexing, ref #379

This commit is contained in:
emeric
2024-10-26 16:58:13 +02:00
parent 74a1a3063f
commit 72b1367ea6
51 changed files with 2034 additions and 253 deletions
+2
View File
@@ -89,6 +89,7 @@
<message id="Lms.Admin.ScannerController.bad-duration">Cannot get track duration</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Cannot parse audio file</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Cannot parse image file</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Cannot parse lyrics file</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Cannot read file</message>
<message id="Lms.Admin.ScannerController.compact">Compact the database. <strong>Caution:</strong> this may take a while and will block the whole application during the compact step!</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} duplicate files:</message>
@@ -110,6 +111,7 @@
<message id="Lms.Admin.ScannerController.status-scheduled">Scheduled on {1}</message>
<message id="Lms.Admin.ScannerController.status-in-progress">Scanning: step {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-associating-artist-images">Associating artist images: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Associating external lyrics: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Associating release images: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Checking for duplicate files... {1} files</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Checking for removed files... {1}%</message>
+2
View File
@@ -89,6 +89,7 @@
<message id="Lms.Admin.ScannerController.bad-duration">Impossible de récupérer la durée de la piste</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Impossible d'analyser le fichier audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Impossible d'analyser le fichier image</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Impossible d'analyser le fichier de paroles</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Impossible de lire le fichier</message>
<message id="Lms.Admin.ScannerController.compact">Compacter la base de données. <strong>Attention :</strong> cette opération peut prendre du temps et va vérouiller l'application pendant toute l'étape de compactage!</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} fichiers dupliqués :</message>
@@ -110,6 +111,7 @@
<message id="Lms.Admin.ScannerController.status-scheduled">Planifié le {1}</message>
<message id="Lms.Admin.ScannerController.status-in-progress">En cours de scan : étape {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-associating-artist-images">Association des images des artistes: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Association des paroles externes: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Association des images des albums: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Vérification des fichiers dupliqués... {1} fichiers</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Vérification des fichiers supprimés... {1}%</message>
+2
View File
@@ -89,6 +89,7 @@
<message id="Lms.Admin.ScannerController.bad-duration">Non sono stato in grado di determinare la durata della traccia</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Impossibile analizzare il file audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Impossibile analizzare il file immagine</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Impossibile analizzare il file dei testi</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Non in grado di leggere il file</message>
<message id="Lms.Admin.ScannerController.compact">Compatta il database. <strong>Attenzione:</strong> ciò potrebbe richiedere del tempo e bloccherà l'intera applicazione durante il passaggio di compattazione!</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} file duplicati:</message>
@@ -110,6 +111,7 @@
<message id="Lms.Admin.ScannerController.status-scheduled">Pianificato il {1}</message>
<message id="Lms.Admin.ScannerController.status-in-progress">Scansione: passo {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-associating-artist-images">Associando immagini degli artisti: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Associazione dei testi esterni: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Associando immagini degli album: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">Controllo duplicati... {1} files</message>
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">Controllo file... {1}%</message>
+2
View File
@@ -90,6 +90,7 @@
<message id="Lms.Admin.ScannerController.bad-duration">Nie udało się ustalić długości ścieżki</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">Nie można przeanalizować pliku audio</message>
<message id="Lms.Admin.ScannerController.cannot-read-image-file">Nie można przeanalizować pliku obrazu</message>
<message id="Lms.Admin.ScannerController.cannot-read-lyrics-file">Nie można przetworzyć pliku z tekstem</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">Nie udało się odczytać pliku</message>
<message id="Lms.Admin.ScannerController.compact">Sprasuj bazę danych. <strong>Uwaga:</strong> może to trochę zająć, a cała aplikacja będzie w tym czasie zablokowana!</message>
<message id="Lms.Admin.ScannerController.duplicates-header">
@@ -119,6 +120,7 @@
<message id="Lms.Admin.ScannerController.status-scheduled">Zaplanowano na {1}</message>
<message id="Lms.Admin.ScannerController.status-in-progress">Skanowanie: krok {1}/{2}</message>
<message id="Lms.Admin.ScannerController.step-associating-artist-images">Kojarzenie obrazów artystów: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-external-lyrics">Kojarzenie zewnętrznych tekstów: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-associating-release-images">Kojarzenie obrazów albumów: {1}%...</message>
<message id="Lms.Admin.ScannerController.step-checking-for-duplicate-files">
<plural case="0">Sprawdzanie duplikatów... {1} plik</plural>
+2 -1
View File
@@ -85,13 +85,13 @@
<message id="Lms.Admin.Database.update-start-time">更新开始时间</message>
<message id="Lms.Admin.Database.weekly">每周</message>
<!--Scanner Controller-->
<message id="Lms.Admin.ScannerController.bad-duration">无法获得音轨时间</message>
<message id="Lms.Admin.ScannerController.cannot-read-audio-file">无法解析文件</message>
<message id="Lms.Admin.ScannerController.cannot-read-file">无法读取文件</message>
<message id="Lms.Admin.ScannerController.duplicates-header">{1} 个重复文件:</message>
<message id="Lms.Admin.ScannerController.errors-header">{1} 个错误:</message>
@@ -113,6 +113,7 @@
<message id="Lms.Admin.ScannerController.step-checking-for-removed-files">检查文件中... {1}%</message>
+1 -1
View File
@@ -133,7 +133,7 @@ namespace lms::core::pathUtils
return true;
}
bool hasFileAnyExtension(const std::filesystem::path& file, const std::vector<std::filesystem::path>& supportedExtensions)
bool hasFileAnyExtension(const std::filesystem::path& file, std::span<const std::filesystem::path> supportedExtensions)
{
const std::filesystem::path extension{ stringUtils::stringToLower(file.extension().string()) };
+2 -2
View File
@@ -21,8 +21,8 @@
#include <filesystem>
#include <functional>
#include <span>
#include <string>
#include <vector>
#include <Wt/WDateTime.h>
@@ -41,7 +41,7 @@ namespace lms::core::pathUtils
bool exploreFilesRecursive(const std::filesystem::path& directory, std::function<bool(std::error_code, const std::filesystem::path&)> 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<std::filesystem::path>& extensions);
bool hasFileAnyExtension(const std::filesystem::path& file, std::span<const std::filesystem::path> 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
+2 -2
View File
@@ -51,8 +51,8 @@ namespace lms::core::stringUtils
[[nodiscard]] std::string escapeAndJoinStrings(std::span<const std::string_view> strings, char delimiter, char escapeChar);
[[nodiscard]] std::vector<std::string> splitEscapedStrings(std::string_view string, char delimiter, char escapeChar);
[[nodiscard]] std::string_view stringTrim(std::string_view str, std::string_view whitespaces = " \t");
[[nodiscard]] std::string_view stringTrimEnd(std::string_view str, std::string_view whitespaces = " \t");
[[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);
+1
View File
@@ -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
+1 -1
View File
@@ -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)
+2
View File
@@ -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<DirectoryId>(query, range);
}
+177 -147
View File
@@ -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<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
session.getDboSession()->execute("ALTER TABLE starred_release ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
session.getDboSession()->execute("ALTER TABLE starred_track ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_artist ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_release ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*ScrobblingState::PendingAdd*/ 0)) + ")");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE starred_track ADD scrobbling_state INTEGER NOT NULL DEFAULT(" + std::to_string(static_cast<int>(/*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<int>(/*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<int>(/*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<int>("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<std::string> indexeNames{ utils::fetchQueryResults(session.getDboSession()->query<std::string>(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<std::string> indexeNames{ utils::fetchQueryResults(session.getDboSession()->query<std::string>(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{};
+78 -72
View File
@@ -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<TrackFeatures>("track_features");
_session.mapClass<TrackList>("tracklist");
_session.mapClass<TrackListEntry>("tracklist_entry");
_session.mapClass<TrackLyrics>("track_lyrics");
_session.mapClass<UIState>("ui_state");
_session.mapClass<User>("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!");
}
+23
View File
@@ -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<TrackLyrics>& lyrics)
{
_trackLyrics.insert(getDboPtr(lyrics));
}
std::optional<std::string> Track::getCopyright() const
{
return _copyright != "" ? std::make_optional<std::string>(_copyright) : std::nullopt;
+187
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "database/TrackLyrics.hpp"
#include <Wt/Dbo/SqlTraits.h>
#include <Wt/Dbo/WtSqlTraits.h>
#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<TrackLyrics>{ new TrackLyrics{} });
}
std::size_t TrackLyrics::getCount(Session& session)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT COUNT(*) FROM track_lyrics"));
}
std::size_t TrackLyrics::getExternalLyricsCount(Session& session)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("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<Wt::Dbo::ptr<TrackLyrics>>("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<Wt::Dbo::ptr<TrackLyrics>>("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<void(const TrackLyrics::pointer&)>& func)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("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<void(const TrackLyrics::pointer&)>& func)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<Wt::Dbo::ptr<TrackLyrics>>("SELECT t_lrc from track_lyrics t_lrc").orderBy("t_lrc.id").where("t_lrc.id > ?").bind(lastRetrievedId).limit(static_cast<int>(count)) };
utils::forEachQueryResult(query, [&](const TrackLyrics::pointer& lyrics) {
func(lyrics);
lastRetrievedId = lyrics->getId();
});
}
RangeResults<TrackLyricsId> TrackLyrics::findOrphanIds(Session& session, std::optional<Range> range)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<TrackLyricsId>("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<TrackLyricsId>(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<std::string>(entry.get("value")) };
synchronizedLines.emplace(timestamp, std::move(lineText));
}
}
return synchronizedLines;
}
std::vector<std::string> TrackLyrics::getUnsynchronizedLines() const
{
std::vector<std::string> 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<const std::string> 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
+9
View File
@@ -172,5 +172,14 @@ namespace lms::db::utils
}
}
template<typename... Args>
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
@@ -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<ClusterId> clusters; // if non empty, tracks that belong to these clusters
std::vector<std::string_view> 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> 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<TrackArtistLink>& artistLink);
void setRelease(ObjectPtr<Release> release) { _release = getDboPtr(release); }
void setClusters(const std::vector<ObjectPtr<Cluster>>& clusters);
void clearLyrics();
void clearEmbeddedLyrics();
void addLyrics(const ObjectPtr<TrackLyrics>& lyrics);
void setMediaLibrary(ObjectPtr<MediaLibrary> mediaLibrary) { _mediaLibrary = getDboPtr(mediaLibrary); }
void setDirectory(ObjectPtr<Directory> 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<int> _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> _directory;
Wt::Dbo::collection<Wt::Dbo::ptr<TrackArtistLink>> _trackArtistLinks;
Wt::Dbo::collection<Wt::Dbo::ptr<Cluster>> _clusters;
Wt::Dbo::collection<Wt::Dbo::ptr<TrackLyrics>> _trackLyrics;
};
namespace Debug
@@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <filesystem>
#include <map>
#include <span>
#include <string>
#include <Wt/Dbo/Dbo.h>
#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<TrackLyrics, TrackLyricsId>
{
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<void(const TrackLyrics::pointer&)>& func);
static void find(Session& session, TrackLyricsId& lastRetrievedId, std::size_t count, const std::function<void(const TrackLyrics::pointer&)>& func);
static RangeResults<TrackLyricsId> findOrphanIds(Session& session, std::optional<Range> range);
using SynchronizedLines = std::map<std::chrono::milliseconds, std::string>;
// 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<std::string> getUnsynchronizedLines() const;
Wt::Dbo::ptr<Track> getTrack() const { return _track; }
Wt::Dbo::ptr<Directory> 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<const std::string> lines);
void setTrack(const ObjectPtr<Track>& track) { _track = getDboPtr(track); }
void setDirectory(const ObjectPtr<Directory>& directory) { _directory = getDboPtr(directory); }
template<class Action>
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<int, std::milli> _offset{};
std::string _displayArtist;
std::string _displayTitle;
bool _synchronized{};
Wt::Dbo::ptr<Track> _track;
Wt::Dbo::ptr<Directory> _directory;
};
} // namespace lms::db
+1
View File
@@ -19,6 +19,7 @@ add_executable(test-database
TrackBookmark.cpp
TrackFeatures.cpp
TrackList.cpp
TrackLyrics.cpp
User.cpp
)
+2
View File
@@ -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{}));
}
+168
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "Common.hpp"
#include "database/Track.hpp"
#include "database/TrackLyrics.hpp"
namespace lms::db::tests
{
using ScopedTrackLyrics = ScopedEntity<db::TrackLyrics>;
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<std::string, 2>{ "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<std::string> 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
+5
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
add_executable(bench-metadata
LyricsBench.cpp
)
target_link_libraries(bench-metadata PRIVATE
lmsmetadata
benchmark
)
+94
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include <iostream>
#include <sstream>
#include <benchmark/benchmark.h>
#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();
+212
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "metadata/Lyrics.hpp"
#include <cassert>
#include <regex>
#include "core/String.hpp"
namespace lms::metadata
{
std::span<const std::filesystem::path> getSupportedLyricsFileExtensions()
{
static const std::array<std::filesystem::path, 1> 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<std::string_view::size_type>(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<int>(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<std::chrono::milliseconds>& 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<std::chrono::milliseconds> lastTimestamps;
std::vector<std::chrono::milliseconds> 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
+14
View File
@@ -321,6 +321,20 @@ namespace lms::metadata
track.originalYear = utils::parseYear(*dateStr);
}
std::vector<std::string> lyricsEntries{ getTagValuesAs<std::string>(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<std::string>(tagReader, TagType::Comment, {} /* no custom delimiter on comments */);
track.copyright = getTagValueAs<std::string>(tagReader, TagType::Copyright).value_or("");
track.copyrightURL = getTagValueAs<std::string>(tagReader, TagType::CopyrightURL).value_or("");
@@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <filesystem>
#include <iosfwd>
#include <map>
#include <memory>
#include <span>
#include <string>
#include <vector>
#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<std::chrono::milliseconds, std::string> synchronizedLines;
std::vector<std::string> unsynchronizedLines;
};
class LyricsException : public metadata::Exception
{
public:
using metadata::Exception::Exception;
};
std::span<const std::filesystem::path> getSupportedLyricsFileExtensions();
Lyrics parseLyrics(std::istream& is);
} // namespace lms::metadata
@@ -30,6 +30,8 @@
#include "core/UUID.hpp"
#include "Lyrics.hpp"
namespace lms::metadata
{
using Tags = std::map<std::string /* type */, std::vector<std::string> /* values */>;
@@ -119,6 +121,7 @@ namespace lms::metadata
std::string copyright;
std::string copyrightURL;
std::vector<std::string> comments;
std::vector<Lyrics> lyrics;
std::optional<float> replayGain;
std::string artistDisplayName;
std::vector<Artist> artists;
+1
View File
@@ -1,6 +1,7 @@
include(GoogleTest)
add_executable(test-metadata
Lyrics.cpp
Metadata.cpp
Parser.cpp
Utils.cpp
+318
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#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
+47 -1
View File
@@ -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<IParser&>(parser).setUserExtraTags(std::vector<std::string>{ "MY_AWESOME_TAG_A", "MY_AWESOME_TAG_B", "MY_AWESOME_MISSING_TAG" });
std::unique_ptr<Track> track{ parser.parse(testTags) };
const std::unique_ptr<Track> 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<IParser&>(parser).setArtistTagDelimiters(std::vector<std::string>{ " & " });
std::unique_ptr<Track> 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> 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{
+1
View File
@@ -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
@@ -19,6 +19,8 @@
#include "FileScanQueue.hpp"
#include <fstream>
#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 };
@@ -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<metadata::Track>;
using ImageFileScanData = std::optional<ImageInfo>;
using LyricsFileScanData = std::optional<metadata::Lyrics>;
struct FileScanResult
{
std::filesystem::path path;
std::variant<std::monostate, AudioFileScanData, ImageFileScanData> scanData;
std::variant<std::monostate, AudioFileScanData, ImageFileScanData, LyricsFileScanData> 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;
@@ -19,8 +19,6 @@
#pragma once
#include <vector>
#include "core/LiteralString.hpp"
#include "services/scanner/ScannerOptions.hpp"
#include "services/scanner/ScannerStats.hpp"
@@ -54,6 +54,7 @@ namespace lms::scanner
{
db::Session& session;
db::ArtistId lastRetrievedArtistId;
std::size_t processedArtistCount{};
const std::vector<std::string>& 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() : "<none>") << "'");
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);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "ScanStepAssociateExternalLyrics.hpp"
#include <deque>
#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<TrackLyricsAssociation>;
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() : "<none>") << "'");
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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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
@@ -54,6 +54,7 @@ namespace lms::scanner
{
db::Session& session;
db::ReleaseId lastRetrievedReleaseId;
std::size_t processedReleaseCount{};
const std::vector<std::string>& 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() : "<none>") << "'");
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);
}
}
@@ -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;
@@ -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<db::Track>(context, _settings.supportedAudioFileExtensions);
checkForRemovedFiles<db::Image>(context, _settings.supportedImageFileExtensions);
checkForRemovedFiles<db::TrackLyrics>(context, _settings.supportedLyricsFileExtensions);
}
template<typename Object>
void ScanStepCheckForRemovedFiles::checkForRemovedFiles(ScanContext& context, const std::vector<std::filesystem::path>& supportedFileExtensions)
void ScanStepCheckForRemovedFiles::checkForRemovedFiles(ScanContext& context, std::span<const std::filesystem::path> 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<Object, TrackLyrics>)
{
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<std::filesystem::path>& allowedExtensions)
bool ScanStepCheckForRemovedFiles::checkFile(const std::filesystem::path& p, std::span<const std::filesystem::path> allowedExtensions)
{
try
{
@@ -20,6 +20,7 @@
#pragma once
#include <filesystem>
#include <span>
#include "ScanStepBase.hpp"
@@ -36,8 +37,8 @@ namespace lms::scanner
void process(ScanContext& context) override;
template<typename Object>
void checkForRemovedFiles(ScanContext& context, const std::vector<std::filesystem::path>& supportedFileExtensions);
void checkForRemovedFiles(ScanContext& context, std::span<const std::filesystem::path> supportedFileExtensions);
bool checkFile(const std::filesystem::path& p, const std::vector<std::filesystem::path>& allowedExtensions);
bool checkFile(const std::filesystem::path& p, std::span<const std::filesystem::path> allowedExtensions);
};
} // namespace lms::scanner
@@ -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();
}
@@ -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<db::TrackLyrics>() };
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<Artist>(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<const FileScanResult> 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<LyricsFileScanData>(&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> 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<db::TrackLyrics>();
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
@@ -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<const FileScanResult> 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<metadata::IParser> _metadataParser;
const std::vector<std::string> _extraTagsToParse;
@@ -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<ScanStepDiscoverFiles>(params));
_scanSteps.push_back(std::make_unique<ScanStepScanFiles>(params));
@@ -349,6 +358,7 @@ namespace lms::scanner
_scanSteps.push_back(std::make_unique<ScanStepUpdateLibraryFields>(params));
_scanSteps.push_back(std::make_unique<ScanStepAssociateArtistImages>(params));
_scanSteps.push_back(std::make_unique<ScanStepAssociateReleaseImages>(params));
_scanSteps.push_back(std::make_unique<ScanStepAssociateExternalLyrics>(params));
_scanSteps.push_back(std::make_unique<ScanStepRemoveOrphanedDbEntries>(params));
_scanSteps.push_back(std::make_unique<ScanStepCompact>(params));
_scanSteps.push_back(std::make_unique<ScanStepOptimize>(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<std::chrono::seconds>(now - _lastScanInProgressEmit).count() > 1)
if (now - _lastScanInProgressEmit >= std::chrono::seconds{ 1 })
notifyInProgress(stepStats);
}
} // namespace lms::scanner
@@ -37,6 +37,7 @@ namespace lms::scanner
db::ScanSettings::UpdatePeriod updatePeriod{ db::ScanSettings::UpdatePeriod::Never };
std::vector<std::filesystem::path> supportedAudioFileExtensions;
std::vector<std::filesystem::path> supportedImageFileExtensions;
std::vector<std::filesystem::path> supportedLyricsFileExtensions;
bool skipDuplicateMBID{};
std::vector<std::string> extraTags;
std::vector<std::string> artistTagDelimiters;
@@ -40,6 +40,8 @@ namespace lms::scanner
unsigned ScanStepStats::progress() const
{
return (processedElems / static_cast<float>(totalElems ? totalElems : 1)) * 100;
const unsigned res{ static_cast<unsigned>((processedElems / static_cast<float>(totalElems ? totalElems : 1)) * 100) };
// can technically be above 100% since we may add files while iterating the filesystem
return res;
}
} // namespace lms::scanner
@@ -33,6 +33,7 @@ namespace lms::scanner
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
};
@@ -62,6 +63,7 @@ namespace lms::scanner
enum class ScanStep
{
AssociateArtistImages,
AssociateExternalLyrics,
AssociateReleaseImages,
CheckForDuplicatedFiles,
CheckForRemovedFiles,
+7
View File
@@ -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()));
+47 -2
View File
@@ -18,6 +18,7 @@
*/
#include <chrono>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <optional>
@@ -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<std::vector<std::string>>()->default_value(std::vector<std::string>{}, "[]"), "Tag delimiters (multiple allowed)")
("artist-tag-delimiter", program_options::value<std::vector<std::string>>()->default_value(std::vector<std::string>{}, "[]"), "Artist tag delimiters (multiple allowed)")
("parser", program_options::value<std::vector<std::string>>()->default_value(std::vector<std::string>{ "taglib" }, "[taglib]"), "Parser to be used (value can be \"taglib\" or \"ffmpeg\")");
("parser", program_options::value<std::vector<std::string>>()->default_value(std::vector<std::string>{ "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<Parser> parsers;
for (const std::string& strParser : vm["parser"].as<std::vector<std::string>>())
{
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