diff --git a/approot/admin-medialibraries.xml b/approot/admin-medialibraries.xml new file mode 100644 index 00000000..1760b95a --- /dev/null +++ b/approot/admin-medialibraries.xml @@ -0,0 +1,47 @@ + + + + + ${tr:Lms.Admin.MediaLibraries.media-libraries} + ${libraries class="d-grid gap-1 mb-3 Lms-row-container"} + ${add-btn class="btn btn-primary"} + + + +
+
+
+
+ ${name class="d-block text-truncate"} +
+
+
+
+ ${path class="d-block text-truncate"} +
+
+
+
+ ${edit-btn class="btn btn-sm btn-outline-secondary border-0"} + ${del-btn class="btn btn-sm btn-outline-danger border-0"} +
+
+
+ + + + + +
diff --git a/approot/admin-medialibrary.xml b/approot/admin-medialibrary.xml new file mode 100644 index 00000000..1138d58b --- /dev/null +++ b/approot/admin-medialibrary.xml @@ -0,0 +1,44 @@ + + + + + + + + \ No newline at end of file diff --git a/approot/admin-scannercontroller.xml b/approot/admin-scannercontroller.xml index d5e17584..1b971a16 100644 --- a/approot/admin-scannercontroller.xml +++ b/approot/admin-scannercontroller.xml @@ -2,46 +2,42 @@ -
-
${tr:Lms.Admin.ScannerController.scanner}
-
-
-
-
- - ${status class="form-control"} -
-
- - ${step-status class="form-control"} -
-
- -
- ${last-scan class="form-control"} - ${report-btn class="btn btn-outline-info"} -
-
-
-
- ${scan-btn class="btn btn-primary"} - - -
-
+ + ${tr:Lms.Admin.ScannerController.scanner} +
+
+ + ${status class="form-control"} +
+
+ + ${step-status class="form-control"} +
+
+ +
+ ${last-scan class="form-control"} + ${report-btn class="btn btn-outline-info"}
- +
+
+
+ ${scan-btn class="btn btn-primary"} + + +
+
-
+ diff --git a/approot/admin-database.xml b/approot/admin-scansettings.xml similarity index 71% rename from approot/admin-database.xml rename to approot/admin-scansettings.xml index 4f298605..15f84aa7 100644 --- a/approot/admin-database.xml +++ b/approot/admin-scansettings.xml @@ -5,17 +5,8 @@
- ${tr:Lms.Admin.Database.database} -
- - ${media-directory class="form-control"} -
- ${media-directory-info} -
-
-
+ ${tr:Lms.Admin.Database.scan-settings} +
@@ -24,7 +15,7 @@ ${update-period-info"}
-
+
@@ -33,7 +24,6 @@ ${update-start-time-info}
- ${tr:Lms.Admin.Database.scan-options}
- ${apply-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"} + ${save-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"}

- ${scanner-controller} diff --git a/approot/admin-users.xml b/approot/admin-users.xml index 7b43499c..a746adc4 100644 --- a/approot/admin-users.xml +++ b/approot/admin-users.xml @@ -2,7 +2,7 @@ -

${tr:Lms.Admin.Users.users}

+ ${tr:Lms.Admin.Users.users} ${users class="d-grid gap-1 mb-3 Lms-row-container"} ${} ${add-btn class="btn btn-primary"} diff --git a/approot/main.xml b/approot/main.xml index 5947b3db..3facacf1 100644 --- a/approot/main.xml +++ b/approot/main.xml @@ -45,7 +45,9 @@ diff --git a/approot/messages.xml b/approot/messages.xml index 1fcfeea3..a48a3cd2 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -5,7 +5,6 @@ Add Administration -Apply Cancel Tags Create @@ -16,7 +15,6 @@ Loading... Login Logout -Not a directory Ok Password Bad login / password combination @@ -29,6 +27,7 @@ Passwords don't match Another session has been open. Reopen this one? Save +Settings saved! {1} track {1} tracks @@ -45,20 +44,36 @@ User not found + Music libraries + Scan settings + Scanner + Users + + +Music libraries +Create library +Delete library? +Edit library +Library created! +Library deleted! +Library name +Library name already exists +Path must be absolute +Path must not overlap that of another library +Path must be an existing directory +Root directory + +Add library Daily -Music collection Extra tags to scan (use ; as separator) Hourly Scan now! Monthly - Music collection Never -Media root directory -Directories containing a .lmsignore file are skipped +Scan aborted! Scan complete: {1} total files, {2} additions, {3} updates, {4} deletions, {5} duplicates, {6} errors Scan launched! -Scan options -New settings saved! +Scan settings Similarity engine Tag-based None @@ -100,7 +115,6 @@ Delete user? Demo Users - Users Demo account diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index f396330e..60e79fc8 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -5,7 +5,6 @@ Ajouter Administration -Appliquer Annuler Tags Créer @@ -54,10 +53,9 @@ Collection de musiques Jamais Dossier racine des fichiers de musique -Les dossiers contenant un fichier .lmsignore sont ignorés Scan terminé : {1} fichiers, {2} ajouts, {3} mises à jour, {4} suppressions, {5} duplicatas, {6} erreurs Scan lancé ! -Options +Options Nouveaux paramètres sauvegardés ! Moteur de similarité Basé sur les tags diff --git a/approot/messages_it.xml b/approot/messages_it.xml index 69d30b39..ffa01c6e 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -5,7 +5,6 @@ Aggiungi Amministrazione -Applica Annulla Tags Crea @@ -57,7 +56,7 @@ Scansione completata: {1} file totali, {2} aggiunti, {3} aggiornati, {4} eliminati, {5} duplicati, {6} errati Scansione avviata! -Impostazioni scansione +Impostazioni scansione Impostazioni salvate! diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index 60853a67..d559d788 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -5,7 +5,6 @@ 添加 管理 -应用 取消 标签 新建 @@ -54,10 +53,9 @@ 音乐收藏 从不 媒体根目录 -包含 .lmsignore 文件的目录将会被跳过 扫描完成:总文件 {1},添加文件 {2},更新文件 {3},删除文件 {4},重复文件 {5},错误文件 {6} 扫描已完成! -扫描选项 +扫描选项 新设置已保存! diff --git a/approot/misc.xml b/approot/misc.xml index 92bb948d..528c6685 100644 --- a/approot/misc.xml +++ b/approot/misc.xml @@ -14,6 +14,7 @@ ${loading-indicator} + diff --git a/approot/settings.xml b/approot/settings.xml index dbf53bf5..ae63455f 100644 --- a/approot/settings.xml +++ b/approot/settings.xml @@ -177,7 +177,7 @@
${}
- ${apply-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"} + ${save-btn class="btn btn-primary me-1"}${discard-btn class="btn btn-secondary"}
diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index 1d147d23..7188f977 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(lmsdatabase SHARED impl/Cluster.cpp impl/Db.cpp impl/Listen.cpp + impl/MediaLibrary.cpp impl/Migration.cpp impl/TrackArtistLink.cpp impl/TrackFeatures.cpp diff --git a/src/libs/database/impl/Artist.cpp b/src/libs/database/impl/Artist.cpp index 9fca99a5..9c8eda7d 100644 --- a/src/libs/database/impl/Artist.cpp +++ b/src/libs/database/impl/Artist.cpp @@ -46,7 +46,8 @@ namespace Database || params.linkType || params.track.isValid() || params.release.isValid() - || params.clusters.size() == 1) + || params.clusters.size() == 1 + || params.mediaLibrary.isValid()) { query.join("track t ON t.id = t_a_l.track_id"); query.join("track_artist_link t_a_l ON t_a_l.artist_id = a.id"); @@ -120,6 +121,9 @@ namespace Database if (params.release.isValid()) query.where("t.release_id = ?").bind(params.release); + if (params.mediaLibrary.isValid()) + query.where("t.media_library_id = ?").bind(params.mediaLibrary); + switch (params.sortMethod) { case ArtistSortMethod::None: diff --git a/src/libs/database/impl/Cluster.cpp b/src/libs/database/impl/Cluster.cpp index dceed659..d9842ff0 100644 --- a/src/libs/database/impl/Cluster.cpp +++ b/src/libs/database/impl/Cluster.cpp @@ -20,6 +20,7 @@ #include "database/Cluster.hpp" #include "database/Artist.hpp" +#include "database/MediaLibrary.hpp" #include "database/Release.hpp" #include "database/ScanSettings.hpp" #include "database/Session.hpp" diff --git a/src/libs/database/impl/EnumSetTraits.hpp b/src/libs/database/impl/EnumSetTraits.hpp index 7aef83af..3c29a928 100644 --- a/src/libs/database/impl/EnumSetTraits.hpp +++ b/src/libs/database/impl/EnumSetTraits.hpp @@ -26,29 +26,28 @@ namespace Wt::Dbo { + template + struct sql_value_traits, void> : public sql_value_traits + { + using ValueType = typename EnumSet::ValueType; + static_assert(sizeof(long long) > sizeof(ValueType)); - template - struct sql_value_traits, void> : public sql_value_traits - { - using ValueType = typename EnumSet::ValueType; - static_assert(sizeof(long long) > sizeof(ValueType)); + static void bind(EnumSet v, SqlStatement* statement, int column, int size) + { + sql_value_traits::bind(static_cast(v.getBitfield()), statement, column, size); + } - static void bind(EnumSet v, SqlStatement *statement, int column, int size) - { - sql_value_traits::bind(static_cast(v.getBitfield()), statement, column, size); - } - - static bool read(EnumSet& v, SqlStatement *statement, int column, int size) - { - long long val; - if (sql_value_traits::read(val, statement, column, size)) - { - v.setBitfield(val); - return true; - } - v.clear(); - return false; - } - }; + static bool read(EnumSet& v, SqlStatement* statement, int column, int size) + { + long long val; + if (sql_value_traits::read(val, statement, column, size)) + { + v.setBitfield(val); + return true; + } + v.clear(); + return false; + } + }; } diff --git a/src/libs/database/impl/Listen.cpp b/src/libs/database/impl/Listen.cpp index 87391546..df308871 100644 --- a/src/libs/database/impl/Listen.cpp +++ b/src/libs/database/impl/Listen.cpp @@ -29,19 +29,26 @@ namespace Database { namespace { - Wt::Dbo::Query createArtistsQuery(Wt::Dbo::Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional linkType) + Wt::Dbo::Query createArtistsQuery(Wt::Dbo::Session& session, const Listen::ArtistStatsFindParameters& params) { auto query{ session.query("SELECT a.id from artist a") .join("track t ON t.id = t_a_l.track_id") .join("track_artist_link t_a_l ON t_a_l.artist_id = a.id") .join("listen l ON l.track_id = t.id") - .where("l.user_id = ?").bind(userId) - .where("l.backend = ?").bind(backend) }; + .where("l.user_id = ?").bind(params.user) }; - if (linkType) - query.where("t_a_l.type = ?").bind(*linkType); + if (params.backend) + query.where("l.backend = ?").bind(*params.backend); - if (!clusterIds.empty()) + assert(!params.artist.isValid()); // poor check + + if (params.library.isValid()) + query.where("t.media_library_id = ?").bind(params.library); + + if (params.linkType) + query.where("t_a_l.type = ?").bind(*params.linkType); + + if (!params.clusters.empty()) { std::ostringstream oss; oss << "a.id IN (SELECT DISTINCT a.id FROM artist a" @@ -51,14 +58,14 @@ namespace Database " INNER JOIN track_cluster t_c ON t_c.track_id = t.id"; WhereClause clusterClause; - for (auto id : clusterIds) + for (auto id : params.clusters) { clusterClause.Or(WhereClause("c.id = ?")); query.bind(id); } oss << " " << clusterClause.get(); - oss << " GROUP BY t.id,a.id HAVING COUNT(DISTINCT c.id) = " << clusterIds.size() << ")"; + oss << " GROUP BY t.id,a.id HAVING COUNT(DISTINCT c.id) = " << params.clusters.size() << ")"; query.where(oss.str()); } @@ -66,15 +73,26 @@ namespace Database return query; } - Wt::Dbo::Query createReleasesQuery(Wt::Dbo::Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds) + Wt::Dbo::Query createReleasesQuery(Wt::Dbo::Session& session, const Listen::StatsFindParameters& params) { auto query{ session.query("SELECT r.id from release r") .join("track t ON t.release_id = r.id") .join("listen l ON l.track_id = t.id") - .where("l.user_id = ?").bind(userId) - .where("l.backend = ?").bind(backend) }; + .where("l.user_id = ?").bind(params.user) }; - if (!clusterIds.empty()) + if (params.backend) + query.where("l.backend = ?").bind(*params.backend); + + if (params.artist.isValid()) + { + query.join("track_artist_link t_a_l ON t_a_l.track_id = t.id") + .where("t_a_l.artist_id = ?").bind(params.artist); + } + + if (params.library.isValid()) + query.where("t.media_library_id = ?").bind(params.library); + + if (!params.clusters.empty()) { std::ostringstream oss; oss << "r.id IN (SELECT DISTINCT r.id FROM release r" @@ -83,14 +101,14 @@ namespace Database " INNER JOIN track_cluster t_c ON t_c.track_id = t.id"; WhereClause clusterClause; - for (ClusterId id : clusterIds) + for (ClusterId id : params.clusters) { clusterClause.Or(WhereClause("c.id = ?")); query.bind(id); } oss << " " << clusterClause.get(); - oss << " GROUP BY t.id HAVING COUNT(DISTINCT c.id) = " << clusterIds.size() << ")"; + oss << " GROUP BY t.id HAVING COUNT(DISTINCT c.id) = " << params.clusters.size() << ")"; query.where(oss.str()); } @@ -98,17 +116,25 @@ namespace Database return query; } - Wt::Dbo::Query createTracksQuery(Wt::Dbo::Session& session, UserId userId, ArtistId artistId, ScrobblingBackend backend, const std::vector& clusterIds) + Wt::Dbo::Query createTracksQuery(Wt::Dbo::Session& session, const Listen::StatsFindParameters& params) { auto query{ session.query("SELECT t.id from track t") .join("listen l ON l.track_id = t.id") - .where("l.user_id = ?").bind(userId) - .where("l.backend = ?").bind(backend) }; + .where("l.user_id = ?").bind(params.user) }; - if (artistId.isValid()) - query.join("track_artist_link t_a_l ON t_a_l.track_id = t.id").where("t_a_l.artist_id = ?").bind(artistId); + if (params.backend) + query.where("l.backend = ?").bind(*params.backend); - if (!clusterIds.empty()) + if (params.artist.isValid()) + { + query.join("track_artist_link t_a_l ON t_a_l.track_id = t.id") + .where("t_a_l.artist_id = ?").bind(params.artist); + } + + if (params.library.isValid()) + query.where("t.media_library_id = ?").bind(params.library); + + if (!params.clusters.empty()) { std::ostringstream oss; oss << "t.id IN (SELECT DISTINCT t.id FROM track t" @@ -116,14 +142,14 @@ namespace Database " INNER JOIN cluster c ON c.id = t_c.cluster_id"; WhereClause clusterClause; - for (auto id : clusterIds) + for (auto id : params.clusters) { clusterClause.Or(WhereClause("c.id = ?")).bind(id.toString()); query.bind(id); } oss << " " << clusterClause.get(); - oss << " GROUP BY t.id HAVING COUNT(*) = " << clusterIds.size() << ")"; + oss << " GROUP BY t.id HAVING COUNT(*) = " << params.clusters.size() << ")"; query.where(oss.str()); } @@ -188,76 +214,66 @@ namespace Database .resultValue(); } - RangeResults Listen::getTopArtists(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional linkType, std::optional range) + RangeResults Listen::getTopArtists(Session& session, const ArtistStatsFindParameters& params) { session.checkReadTransaction(); - auto query{ createArtistsQuery(session.getDboSession(), userId, backend, clusterIds, linkType) }; + auto query{ createArtistsQuery(session.getDboSession(), params) }; auto collection{ query .orderBy("COUNT(a.id) DESC") .groupBy("a.id") }; - return Utils::execQuery(query, range); + return Utils::execQuery(query, params.range); } - RangeResults Listen::getTopReleases(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range) + RangeResults Listen::getTopReleases(Session& session, const StatsFindParameters& params) { session.checkReadTransaction(); - auto query{ createReleasesQuery(session.getDboSession(), userId, backend, clusterIds) + auto query{ createReleasesQuery(session.getDboSession(), params) .orderBy("COUNT(r.id) DESC") .groupBy("r.id") }; - return Utils::execQuery(query, range); + return Utils::execQuery(query, params.range); } - RangeResults Listen::getTopTracks(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range) + RangeResults Listen::getTopTracks(Session& session, const StatsFindParameters& params) { session.checkReadTransaction(); - auto query{ createTracksQuery(session.getDboSession(), userId, ArtistId{}, backend, clusterIds) + auto query{ createTracksQuery(session.getDboSession(), params) .orderBy("COUNT(t.id) DESC") .groupBy("t.id") }; - return Utils::execQuery(query, range); + return Utils::execQuery(query, params.range); } - RangeResults Listen::getTopTracks(Session& session, UserId userId, ArtistId artistId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range) + RangeResults Listen::getRecentArtists(Session& session, const ArtistStatsFindParameters& params) { session.checkReadTransaction(); - auto query{ createTracksQuery(session.getDboSession(), userId, artistId, backend, clusterIds) - .orderBy("COUNT(t.id) DESC") - .groupBy("t.id") }; - - return Utils::execQuery(query, range); - } - - RangeResults Listen::getRecentArtists(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional linkType, std::optional range) - { - session.checkReadTransaction(); - auto query{ createArtistsQuery(session.getDboSession(), userId, backend, clusterIds, linkType) + auto query{ createArtistsQuery(session.getDboSession(), params) .groupBy("a.id").having("l.date_time = MAX(l.date_time)") .orderBy("l.date_time DESC") }; - return Utils::execQuery(query, range); + return Utils::execQuery(query, params.range); } - RangeResults Listen::getRecentReleases(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range) + RangeResults Listen::getRecentReleases(Session& session, const StatsFindParameters& params) { session.checkReadTransaction(); - auto query{ createReleasesQuery(session.getDboSession(), userId, backend, clusterIds) + auto query{ createReleasesQuery(session.getDboSession(), params) .groupBy("r.id").having("l.date_time = MAX(l.date_time)") .orderBy("l.date_time DESC") }; - return Utils::execQuery(query, range); + return Utils::execQuery(query, params.range); } - RangeResults Listen::getRecentTracks(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range) + RangeResults Listen::getRecentTracks(Session& session, const StatsFindParameters& params) { session.checkReadTransaction(); - auto query{ createTracksQuery(session.getDboSession(), userId, ArtistId{}, backend, clusterIds) + auto query{ createTracksQuery(session.getDboSession(), params) .groupBy("t.id").having("l.date_time = MAX(l.date_time)") .orderBy("l.date_time DESC") }; - return Utils::execQuery(query, range); + return Utils::execQuery(query, params.range); } std::size_t Listen::getCount(Session& session, UserId userId, TrackId trackId) diff --git a/src/libs/database/impl/MediaLibrary.cpp b/src/libs/database/impl/MediaLibrary.cpp new file mode 100644 index 00000000..2538caad --- /dev/null +++ b/src/libs/database/impl/MediaLibrary.cpp @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2013-2016 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "database/MediaLibrary.hpp" + +#include "database/ScanSettings.hpp" +#include "database/Session.hpp" +#include "database/Track.hpp" +#include "IdTypeTraits.hpp" +#include "PathTraits.hpp" +#include "StringViewTraits.hpp" + +namespace Database +{ + MediaLibrary::MediaLibrary(const std::filesystem::path& p, std::string_view name) + : _path{ p }, + _name{ std::string {name, 0, maxNameLength} } + { + } + + MediaLibrary::pointer MediaLibrary::create(Session& session, const std::filesystem::path& p, std::string_view name) + { + return session.getDboSession().add(std::unique_ptr{ new MediaLibrary{ p, name } }); + } + + std::size_t MediaLibrary::getCount(Session& session) + { + session.checkReadTransaction(); + + return session.getDboSession().query("SELECT COUNT(*) FROM cluster"); + } + + MediaLibrary::pointer MediaLibrary::find(Session& session, MediaLibraryId id) + { + session.checkReadTransaction(); + + return session.getDboSession().find().where("id = ?").bind(id).resultValue(); + } + + MediaLibrary::pointer MediaLibrary::find(Session& session, std::string_view name) + { + session.checkReadTransaction(); + + return session.getDboSession().find().where("name = ?").bind(name).resultValue(); + } + + MediaLibrary::pointer MediaLibrary::find(Session& session, const std::filesystem::path& p) + { + session.checkReadTransaction(); + + return session.getDboSession().find().where("path = ?").bind(p).resultValue(); + } + + void MediaLibrary::find(Session& session, std::function func) + { + session.checkReadTransaction(); + + auto results{ session.getDboSession().find().resultList() }; + for (const auto& result : results) + func(result); + } +} // namespace Database diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index 3cc5bcfe..405cf5a9 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -309,6 +309,67 @@ CREATE TABLE IF NOT EXISTS "track_backup" ( session.getDboSession().execute("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" ( + "id" integer primary key autoincrement, + "version" integer not null, + "path" text not null, + "name" text not null +))"); + + const int scanSettingsId{ session.getDboSession().query("SELECT id FROM scan_settings") }; + + // Convert the existing media_directory in the scan_settings table to a media_library with id '1' + session.getDboSession().execute(R"(INSERT INTO "media_library" ("id", "version", "path", "name") +SELECT 1, 0, s_s.media_directory, "Main" +FROM scan_settings s_s +WHERE id = ?)").bind(scanSettingsId); + + // Remove the outdated column in scan_settings + session.getDboSession().execute("ALTER TABLE scan_settings DROP media_directory"); + + // Add the media_library column in tracks, with id '1' + session.getDboSession().execute(R"( +CREATE TABLE IF NOT EXISTS "track_backup" ( + "id" integer primary key autoincrement, + "version" integer not null, + "scan_version" integer not null, + "track_number" integer, + "disc_number" integer, + "total_track" integer, + "disc_subtitle" text not null, + "name" text not null, + "duration" integer, + "bitrate" integer not null, + "date" text, + "year" integer, + "original_date" text, + "original_year" integer, + "file_path" text not null, + "file_last_write" text, + "file_added" text, + "has_cover" boolean not null, + "mbid" text not null, + "recording_mbid" text not null, + "copyright" text not null, + "copyright_url" text not null, + "track_replay_gain" real, + "release_replay_gain" real, + "artist_display_name" text not null, + "release_id" bigint, + "media_library_id" bigint, + constraint "fk_track_release" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred, + constraint "fk_track_media_library" foreign key ("media_library_id") references "media_library" ("id") on delete set null deferrable initially deferred +))"); + +// Migrate data, with the new media_library_id field set to 1 + session.getDboSession().execute("INSERT INTO track_backup SELECT id, version, scan_version, track_number, disc_number, total_track, disc_subtitle, name, duration, bitrate, date, year, original_date, original_year, file_path, file_last_write, file_added, has_cover, mbid, recording_mbid, copyright, copyright_url, track_replay_gain, release_replay_gain, artist_display_name, release_id, 1 FROM track"); + session.getDboSession().execute("DROP TABLE track"); + session.getDboSession().execute("ALTER TABLE track_backup RENAME TO track"); + } + void doDbMigration(Session& session) { static const std::string outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -337,6 +398,7 @@ CREATE TABLE IF NOT EXISTS "track_backup" ( {47, migrateFromV47}, {48, migrateFromV48}, {49, migrateFromV49}, + {50, migrateFromV50}, }; { diff --git a/src/libs/database/impl/Migration.hpp b/src/libs/database/impl/Migration.hpp index 2e4b5b5d..61452001 100644 --- a/src/libs/database/impl/Migration.hpp +++ b/src/libs/database/impl/Migration.hpp @@ -26,7 +26,7 @@ namespace Database class Session; using Version = std::size_t; - static constexpr Version LMS_DATABASE_VERSION{ 50 }; + static constexpr Version LMS_DATABASE_VERSION{ 51 }; class VersionInfo { public: diff --git a/src/libs/database/impl/PathTraits.hpp b/src/libs/database/impl/PathTraits.hpp new file mode 100644 index 00000000..7ea12684 --- /dev/null +++ b/src/libs/database/impl/PathTraits.hpp @@ -0,0 +1,54 @@ +/* + * 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 . + */ + +#pragma once + +#include +#include +#include + +namespace Wt::Dbo +{ + template<> + struct sql_value_traits + { + static std::string type(SqlConnection* conn, int size) + { + return sql_value_traits::type(conn, size); + } + + static void bind(const std::filesystem::path& path, SqlStatement* statement, int column, int /* size */) + { + statement->bind(column, path.string()); + } + + static bool read(std::filesystem::path& p, SqlStatement* statement, int column, int size) + { + std::string s; + bool result = statement->getResult(column, &s, size); + if (!result) + return false; + + p = std::filesystem::path{ s }; + + return true; + } + }; +} + diff --git a/src/libs/database/impl/Release.cpp b/src/libs/database/impl/Release.cpp index dbfcbfe0..8997318d 100644 --- a/src/libs/database/impl/Release.cpp +++ b/src/libs/database/impl/Release.cpp @@ -42,18 +42,23 @@ namespace Database { auto query{ session.getDboSession().query("SELECT " + std::string{ itemToSelect } + " from release r") }; - if (params.sortMethod == ReleaseSortMethod::LastWritten + if (params.sortMethod == ReleaseSortMethod::ArtistNameThenName + || params.sortMethod == ReleaseSortMethod::LastWritten || params.sortMethod == ReleaseSortMethod::Date || params.sortMethod == ReleaseSortMethod::OriginalDate || params.sortMethod == ReleaseSortMethod::OriginalDateDesc || params.writtenAfter.isValid() || params.dateRange || params.artist.isValid() - || params.clusters.size() == 1) + || params.clusters.size() == 1 + || params.mediaLibrary.isValid()) { query.join("track t ON t.release_id = r.id"); } + if (params.mediaLibrary.isValid()) + query.where("t.media_library_id = ?").bind(params.mediaLibrary); + if (!params.releaseType.empty()) { query.join("release_release_type r_r_t ON r_r_t.release_id = r.id"); @@ -82,7 +87,8 @@ namespace Database .where("s_r.sync_state <> ?").bind(SyncState::PendingRemove); } - if (params.artist.isValid()) + if (params.artist.isValid() + || params.sortMethod == ReleaseSortMethod::ArtistNameThenName) { query.join("artist a ON a.id = t_a_l.artist_id") .join("track_artist_link t_a_l ON t_a_l.track_id = t.id") @@ -163,6 +169,9 @@ namespace Database case ReleaseSortMethod::Name: query.orderBy("r.name COLLATE NOCASE"); break; + case ReleaseSortMethod::ArtistNameThenName: + query.orderBy("a.name COLLATE NOCASE, r.name COLLATE NOCASE"); + break; case ReleaseSortMethod::Random: query.orderBy("RANDOM()"); break; @@ -276,21 +285,6 @@ namespace Database return session.getDboSession().query("SELECT COUNT(*) FROM release"); } - RangeResults Release::findIdsOrderedByArtist(Session& session, std::optional range) - { - session.checkReadTransaction(); - - // TODO merge with find - auto query{ session.getDboSession().query( - "SELECT DISTINCT r.id FROM release r" - " INNER JOIN track t ON r.id = t.release_id" - " INNER JOIN track_artist_link t_a_l ON t_a_l.track_id = t.id" - " INNER JOIN artist a ON t_a_l.artist_id = a.id") - .orderBy("a.name COLLATE NOCASE, r.name COLLATE NOCASE") }; - - return Utils::execQuery(query, range); - } - RangeResults Release::findOrphanIds(Session& session, std::optional range) { session.checkReadTransaction(); diff --git a/src/libs/database/impl/ScanSettings.cpp b/src/libs/database/impl/ScanSettings.cpp index 5e5f7cc1..d75c0995 100644 --- a/src/libs/database/impl/ScanSettings.cpp +++ b/src/libs/database/impl/ScanSettings.cpp @@ -21,12 +21,9 @@ #include -#include "utils/Path.hpp" -#include "utils/ILogger.hpp" -#include "utils/String.hpp" - -#include "database/Cluster.hpp" +#include "database/MediaLibrary.hpp" #include "database/Session.hpp" +#include "utils/String.hpp" namespace Database { @@ -68,11 +65,6 @@ namespace Database return StringUtils::splitString(_extraTagsToScan, ";"); } - void ScanSettings::setMediaDirectory(const std::filesystem::path& p) - { - _mediaDirectory = StringUtils::stringTrimEnd(p.string(), "/\\"); - } - void ScanSettings::setExtraTagsToScan(const std::vector& extraTags) { std::string newTagsToScan{ StringUtils::joinStrings(extraTags, ";") }; diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index 9079c664..56db76f0 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -29,6 +29,7 @@ #include "database/Cluster.hpp" #include "database/Db.hpp" #include "database/Listen.hpp" +#include "database/MediaLibrary.hpp" #include "database/Release.hpp" #include "database/ScanSettings.hpp" #include "database/StarredArtist.hpp" @@ -42,6 +43,7 @@ #include "database/TransactionChecker.hpp" #include "database/User.hpp" #include "EnumSetTraits.hpp" +#include "PathTraits.hpp" #include "Migration.hpp" namespace Database @@ -81,6 +83,7 @@ namespace Database _session.mapClass("cluster"); _session.mapClass("cluster_type"); _session.mapClass("listen"); + _session.mapClass("media_library"); _session.mapClass("release"); _session.mapClass("release_type"); _session.mapClass("scan_settings"); @@ -152,7 +155,10 @@ namespace Database _session.execute("CREATE INDEX IF NOT EXISTS track_release_idx ON track(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_date_idx ON track(date)"); + _session.execute("CREATE INDEX IF NOT EXISTS track_year_idx ON track(year)"); _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_media_library_idx ON track(media_library_id)"); _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)"); _session.execute("CREATE INDEX IF NOT EXISTS track_features_track_idx ON track_features(track_id)"); diff --git a/src/libs/database/impl/StringViewTraits.hpp b/src/libs/database/impl/StringViewTraits.hpp index bfbd65be..c82443c0 100644 --- a/src/libs/database/impl/StringViewTraits.hpp +++ b/src/libs/database/impl/StringViewTraits.hpp @@ -19,15 +19,18 @@ #pragma once +#include +#include + namespace Wt::Dbo { - template<> - struct sql_value_traits - { - static void bind(std::string_view str, SqlStatement *statement, int column, int /* size */) - { - statement->bind(column, std::string {str}); - } - }; + template<> + struct sql_value_traits + { + static void bind(std::string_view str, SqlStatement* statement, int column, int /* size */) + { + statement->bind(column, std::string{ str }); + } + }; } diff --git a/src/libs/database/impl/Track.cpp b/src/libs/database/impl/Track.cpp index 55fdf722..0f9ae193 100644 --- a/src/libs/database/impl/Track.cpp +++ b/src/libs/database/impl/Track.cpp @@ -23,6 +23,7 @@ #include "database/Artist.hpp" #include "database/Cluster.hpp" +#include "database/MediaLibrary.hpp" #include "database/Release.hpp" #include "database/TrackArtistLink.hpp" #include "database/TrackFeatures.hpp" @@ -140,6 +141,9 @@ namespace Database if (params.trackNumber) query.where("t.track_number = ?").bind(*params.trackNumber); + if (params.mediaLibrary.isValid()) + query.where("t.media_library_id = ?").bind(params.mediaLibrary); + switch (params.sortMethod) { case TrackSortMethod::None: diff --git a/src/libs/database/include/database/Artist.hpp b/src/libs/database/include/database/Artist.hpp index 24875565..c7e9d7bb 100644 --- a/src/libs/database/include/database/Artist.hpp +++ b/src/libs/database/include/database/Artist.hpp @@ -29,6 +29,7 @@ #include "database/ArtistId.hpp" #include "database/ClusterId.hpp" +#include "database/MediaLibraryId.hpp" #include "database/Object.hpp" #include "database/ReleaseId.hpp" #include "database/Types.hpp" @@ -64,6 +65,7 @@ namespace Database std::optional feedbackBackend; // and for this feedback backend TrackId track; // artists involved in this track ReleaseId release; // artists involved in this release + MediaLibraryId mediaLibrary; // artists that belong to this library FindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } FindParameters& setKeywords(const std::vector& _keywords) { keywords = _keywords; return *this; } @@ -74,6 +76,7 @@ namespace Database FindParameters& setStarringUser(UserId _user, FeedbackBackend _feedbackBackend) { starringUser = _user; feedbackBackend = _feedbackBackend; return *this; } FindParameters& setTrack(TrackId _track) { track = _track; return *this; } FindParameters& setRelease(ReleaseId _release) { release = _release; return *this; } + FindParameters& setMediaLibrary(MediaLibraryId _mediaLibrary) { mediaLibrary = _mediaLibrary; return *this; } }; Artist() = default; diff --git a/src/libs/database/include/database/Listen.hpp b/src/libs/database/include/database/Listen.hpp index b745112c..1f9c7c1f 100644 --- a/src/libs/database/include/database/Listen.hpp +++ b/src/libs/database/include/database/Listen.hpp @@ -27,6 +27,7 @@ #include "database/ArtistId.hpp" #include "database/ClusterId.hpp" #include "database/ListenId.hpp" +#include "database/MediaLibraryId.hpp" #include "database/Object.hpp" #include "database/ReleaseId.hpp" #include "database/TrackId.hpp" @@ -65,14 +66,37 @@ namespace Database static RangeResults find(Session& session, const FindParameters& parameters); // Stats - static RangeResults getTopArtists(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional linkType, std::optional range = std::nullopt); - static RangeResults getTopReleases(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range = std::nullopt); - static RangeResults getTopTracks(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range = std::nullopt); - static RangeResults getTopTracks(Session& session, UserId userId, ArtistId artistId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range = std::nullopt); + struct StatsFindParameters + { + UserId user; + std::optional backend; + std::vector clusters; // if non empty, entities that belong to these clusters + std::optional range; + ArtistId artist; // if set, matching this artist + MediaLibraryId library; - static RangeResults getRecentArtists(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional linkType, std::optional range = std::nullopt); - static RangeResults getRecentReleases(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range = std::nullopt); - static RangeResults getRecentTracks(Session& session, UserId userId, ScrobblingBackend backend, const std::vector& clusterIds, std::optional range = std::nullopt); + StatsFindParameters& setUser(UserId _user) { user = _user; return *this; } + StatsFindParameters& setScrobblingBackend(std::optional _backend) { backend = _backend; return *this; } + StatsFindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } + StatsFindParameters& setRange(std::optional _range) { range = _range; return *this; } + StatsFindParameters& setArtist(ArtistId _artist) { artist = _artist; return *this; } + StatsFindParameters& setMediaLibrary(MediaLibraryId _library) { library = _library; return *this; } + }; + + struct ArtistStatsFindParameters : public StatsFindParameters + { + std::optional linkType; // if set, only artists that have produced at least one track with this link type + + ArtistStatsFindParameters& setLinkType(std::optional _linkType) { linkType = _linkType; return *this; } + }; + + static RangeResults getTopArtists(Session& session, const ArtistStatsFindParameters& params); + static RangeResults getTopReleases(Session& session, const StatsFindParameters& params); + static RangeResults getTopTracks(Session& session, const StatsFindParameters& params); + + static RangeResults getRecentArtists(Session& session, const ArtistStatsFindParameters& params); + static RangeResults getRecentReleases(Session& session, const StatsFindParameters& params); + static RangeResults getRecentTracks(Session& session, const StatsFindParameters& params); static std::size_t getCount(Session& session, UserId userId, TrackId trackId); // for the current backend static std::size_t getCount(Session& session, UserId userId, ReleaseId trackId); // for the current backend diff --git a/src/libs/database/include/database/MediaLibrary.hpp b/src/libs/database/include/database/MediaLibrary.hpp new file mode 100644 index 00000000..ca3582ec --- /dev/null +++ b/src/libs/database/include/database/MediaLibrary.hpp @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2024 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include + +#include + +#include "database/MediaLibraryId.hpp" +#include "database/Object.hpp" + +namespace Database +{ + class Session; + + class MediaLibrary : public Object + { + public: + static const std::size_t maxNameLength{ 128 }; + + MediaLibrary() = default; + + // find + std::size_t getCount(Session& session); + static pointer find(Session& session, MediaLibraryId id); + static pointer find(Session& session, std::string_view name); + static pointer find(Session& session, const std::filesystem::path& path); + static void find(Session& session, std::function func); + static std::vector find(Session& session); + + // getters + std::string_view getName() const { return _name; } + const std::filesystem::path& getPath() const { return _path; } + + // setters + void setName(std::string_view name) { _name = name; } + void setPath(const std::filesystem::path& p) { _path = p; } + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _path, "path"); + Wt::Dbo::field(a, _name, "name"); + } + + private: + friend class ::Database::Session; + MediaLibrary(const std::filesystem::path& p, std::string_view name); + static pointer create(Session& session, const std::filesystem::path& p = {}, std::string_view name = {}); + + std::filesystem::path _path; + std::string _name; + }; +} // namespace Database diff --git a/src/lms/ui/common/DirectoryValidator.hpp b/src/libs/database/include/database/MediaLibraryId.hpp similarity index 78% rename from src/lms/ui/common/DirectoryValidator.hpp rename to src/libs/database/include/database/MediaLibraryId.hpp index 8acec345..fcd2e532 100644 --- a/src/lms/ui/common/DirectoryValidator.hpp +++ b/src/libs/database/include/database/MediaLibraryId.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 Emeric Poupon + * Copyright (C) 2024 Emeric Poupon * * This file is part of LMS. * @@ -19,10 +19,6 @@ #pragma once -#include - -namespace UserInterface -{ - std::unique_ptr createDirectoryValidator(); -} // namespace UserInterface +#include "database/IdType.hpp" +LMS_DECLARE_IDTYPE(MediaLibraryId) diff --git a/src/libs/database/include/database/Object.hpp b/src/libs/database/include/database/Object.hpp index 1e06537a..8d84ae3b 100644 --- a/src/libs/database/include/database/Object.hpp +++ b/src/libs/database/include/database/Object.hpp @@ -26,61 +26,61 @@ namespace Database { - template - class ObjectPtr - { - public: - ObjectPtr() = default; - ObjectPtr(Wt::Dbo::ptr obj) : _obj {obj} {} + template + class ObjectPtr + { + public: + ObjectPtr() = default; + ObjectPtr(Wt::Dbo::ptr obj) : _obj{ obj } {} - const T* operator->() const { return _obj.get(); } - operator bool() const { return _obj.get(); } - bool operator!() const { return !_obj.get(); } - bool operator==(const ObjectPtr& other) const { return _obj == other._obj; } - bool operator!=(const ObjectPtr& other) const { return other._obj != _obj; } + const T* operator->() const { return _obj.get(); } + operator bool() const { return _obj.get(); } + bool operator!() const { return !_obj.get(); } + bool operator==(const ObjectPtr& other) const { return _obj == other._obj; } + bool operator!=(const ObjectPtr& other) const { return other._obj != _obj; } - auto modify() { TransactionChecker::checkWriteTransaction(*_obj.session()); return _obj.modify(); } - void remove() - { - TransactionChecker::checkWriteTransaction(*_obj.session()); + auto modify() { TransactionChecker::checkWriteTransaction(*_obj.session()); return _obj.modify(); } + void remove() + { + TransactionChecker::checkWriteTransaction(*_obj.session()); - if (_obj->hasOnPreRemove()) - _obj.modify()->onPreRemove(); - _obj.remove(); - } + if (_obj->hasOnPreRemove()) + _obj.modify()->onPreRemove(); + _obj.remove(); + } - private: - template friend class Object; - Wt::Dbo::ptr _obj; - }; + private: + template friend class Object; + Wt::Dbo::ptr _obj; + }; - template - class Object : public Wt::Dbo::Dbo - { - static_assert(std::is_base_of_v); - static_assert(!std::is_same_v); + template + class Object : public Wt::Dbo::Dbo + { + static_assert(std::is_base_of_v); + static_assert(!std::is_same_v); - public: - using pointer = ObjectPtr; - using IdType = ObjectIdType; + public: + using pointer = ObjectPtr; + using IdType = ObjectIdType; - IdType getId() const { return Wt::Dbo::Dbo::self()->Wt::Dbo::template Dbo::id(); } + IdType getId() const { return Wt::Dbo::Dbo::self()->Wt::Dbo::template Dbo::id(); } - // catch some misuses - typename Wt::Dbo::dbo_traits::IdType id() const = delete; + // catch some misuses + typename Wt::Dbo::dbo_traits::IdType id() const = delete; - protected: - template friend class ObjectPtr; + protected: + template friend class ObjectPtr; - virtual bool hasOnPreRemove() const { return false; } - virtual void onPreRemove() {} + virtual bool hasOnPreRemove() const { return false; } + virtual void onPreRemove() {} - virtual bool hasOnPostCreated() const { return false; } - virtual void onPostCreated() {} + virtual bool hasOnPostCreated() const { return false; } + virtual void onPostCreated() {} - // Can get raw dbo ptr only from Objects - template - static - Wt::Dbo::ptr getDboPtr(ObjectPtr ptr) { return ptr._obj; } - }; + // Can get raw dbo ptr only from Objects + template + static + Wt::Dbo::ptr getDboPtr(ObjectPtr ptr) { return ptr._obj; } + }; } diff --git a/src/libs/database/include/database/Release.hpp b/src/libs/database/include/database/Release.hpp index c79addd0..738497f8 100644 --- a/src/libs/database/include/database/Release.hpp +++ b/src/libs/database/include/database/Release.hpp @@ -30,6 +30,7 @@ #include "database/ArtistId.hpp" #include "database/ClusterId.hpp" +#include "database/MediaLibraryId.hpp" #include "database/Object.hpp" #include "database/ReleaseId.hpp" #include "database/ReleaseTypeId.hpp" @@ -93,6 +94,7 @@ namespace Database EnumSet trackArtistLinkTypes; // and for these link types EnumSet excludedTrackArtistLinkTypes; // but not for these link types std::string releaseType; // If set, albums that has this release type + MediaLibraryId mediaLibrary; // If set, releases that has at least a track in this library FindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } FindParameters& setKeywords(const std::vector& _keywords) { keywords = _keywords; return *this; } @@ -109,6 +111,7 @@ namespace Database return *this; } FindParameters& setReleaseType(std::string_view _releaseType) { releaseType = _releaseType; return *this; } + FindParameters& setMediaLibrary(MediaLibraryId _mediaLibrary) { mediaLibrary = _mediaLibrary; return *this; } }; Release() = default; @@ -124,7 +127,6 @@ namespace Database static RangeResults findIds(Session& session, const FindParameters& parameters); static std::size_t getCount(Session& session, const FindParameters& parameters); static RangeResults findOrphanIds(Session& session, std::optional range = std::nullopt); // not track related - static RangeResults findIdsOrderedByArtist(Session& session, std::optional range = std::nullopt); // Get the cluster of the tracks that belong to this release // Each clusters are grouped by cluster type, sorted by the number of occurence (max to min) diff --git a/src/libs/database/include/database/ScanSettings.hpp b/src/libs/database/include/database/ScanSettings.hpp index 2ed2cf5d..0026e489 100644 --- a/src/libs/database/include/database/ScanSettings.hpp +++ b/src/libs/database/include/database/ScanSettings.hpp @@ -63,7 +63,6 @@ namespace Database // Getters std::size_t getScanVersion() const { return _scanVersion; } - std::filesystem::path getMediaDirectory() const { return _mediaDirectory; } Wt::WTime getUpdateStartTime() const { return _startTime; } UpdatePeriod getUpdatePeriod() const { return _updatePeriod; } std::vector getExtraTagsToScan() const; @@ -72,7 +71,6 @@ namespace Database // Setters void addAudioFileExtension(const std::filesystem::path& ext); - void setMediaDirectory(const std::filesystem::path& p); void setUpdateStartTime(Wt::WTime t) { _startTime = t; } void setUpdatePeriod(UpdatePeriod p) { _updatePeriod = p; } void setExtraTagsToScan(const std::vector& extraTags); @@ -83,7 +81,6 @@ namespace Database void persist(Action& a) { Wt::Dbo::field(a, _scanVersion, "scan_version"); - Wt::Dbo::field(a, _mediaDirectory, "media_directory"); Wt::Dbo::field(a, _startTime, "start_time"); Wt::Dbo::field(a, _updatePeriod, "update_period"); Wt::Dbo::field(a, _audioFileExtensions, "audio_file_extensions"); @@ -93,7 +90,6 @@ namespace Database private: int _scanVersion{}; - std::string _mediaDirectory; Wt::WTime _startTime = Wt::WTime{ 0,0,0 }; UpdatePeriod _updatePeriod{ UpdatePeriod::Never }; SimilarityEngineType _similarityEngineType{ SimilarityEngineType::Clusters }; diff --git a/src/libs/database/include/database/Track.hpp b/src/libs/database/include/database/Track.hpp index 3de943a4..2583e64c 100644 --- a/src/libs/database/include/database/Track.hpp +++ b/src/libs/database/include/database/Track.hpp @@ -37,6 +37,7 @@ #include "database/ArtistId.hpp" #include "database/ClusterId.hpp" +#include "database/MediaLibraryId.hpp" #include "database/Object.hpp" #include "database/ReleaseId.hpp" #include "database/TrackId.hpp" @@ -49,6 +50,7 @@ namespace Database { class Artist; class Cluster; class ClusterType; + class MediaLibrary; class Release; class Session; class TrackArtistLink; @@ -76,6 +78,7 @@ namespace Database { std::string releaseName; // matching this release name TrackListId trackList; // matching this trackList std::optional trackNumber; // matching this track number + MediaLibraryId mediaLibrary; // If set, tracks in this library bool distinct{ true }; FindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } @@ -92,6 +95,7 @@ namespace Database { FindParameters& setReleaseName(std::string_view _releaseName) { releaseName = _releaseName; return *this; } FindParameters& setTrackList(TrackListId _trackList) { trackList = _trackList; return *this; } FindParameters& setTrackNumber(int _trackNumber) { trackNumber = _trackNumber; return *this; } + FindParameters& setMediaLibrary(MediaLibraryId _mediaLibrary) { mediaLibrary = _mediaLibrary; return *this; } FindParameters& setDistinct(bool _distinct) { distinct = _distinct; return *this; } }; @@ -147,6 +151,7 @@ namespace Database { void addArtistLink(const ObjectPtr& artistLink); void setRelease(ObjectPtr release) { _release = getDboPtr(release); } void setClusters(const std::vector>& clusters); + void setMediaLibrary(ObjectPtr mediaLibrary) { _mediaLibrary = getDboPtr(mediaLibrary); } std::size_t getScanVersion() const { return _scanVersion; } std::optional getTrackNumber() const { return _trackNumber; } @@ -158,9 +163,9 @@ namespace Database { std::chrono::milliseconds getDuration() const { return _duration; } std::size_t getBitrate() const { return _bitrate; } const Wt::WDateTime& getLastWritten() const { return _fileLastWrite; } - const Wt::WDate& getDate() const { return _date; } + const Wt::WDate& getDate() const { return _date; } std::optional getYear() const { return _year; } - const Wt::WDate& getOriginalDate() const { return _originalDate; } + const Wt::WDate& getOriginalDate() const { return _originalDate; } std::optional getOriginalYear() const { return _originalYear; }; Wt::WDateTime getLastWriteTime() const { return _fileLastWrite; } Wt::WDateTime getAddedTime() const { return _fileAdded; } @@ -179,6 +184,7 @@ namespace Database { ObjectPtr getRelease() const { return _release; } std::vector> getClusters() const; std::vector getClusterIds() const; + ObjectPtr getMediaLibrary() const { return _mediaLibrary; } std::vector>> getClusterGroups(const std::vector& clusterTypes, std::size_t size) const; @@ -209,6 +215,7 @@ namespace Database { Wt::Dbo::field(a, _releaseReplayGain, "release_replay_gain"); // here in Track since Release does not have concept of "disc" (yet?) Wt::Dbo::field(a, _artistDisplayName, "artist_display_name"); Wt::Dbo::belongsTo(a, _release, "release", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::belongsTo(a, _mediaLibrary, "media_library", Wt::Dbo::OnDeleteSetNull); // don't delete track on media library removal, we want to wait for the next scan to have a chance to migrate files Wt::Dbo::hasMany(a, _trackArtistLinks, Wt::Dbo::ManyToOne, "track"); Wt::Dbo::hasMany(a, _clusters, Wt::Dbo::ManyToMany, "track_cluster", "", Wt::Dbo::OnDeleteCascade); } @@ -246,9 +253,10 @@ namespace Database { std::optional _releaseReplayGain; std::string _artistDisplayName; - Wt::Dbo::ptr _release; - Wt::Dbo::collection> _trackArtistLinks; - Wt::Dbo::collection> _clusters; + Wt::Dbo::ptr _release; + Wt::Dbo::ptr _mediaLibrary; + Wt::Dbo::collection> _trackArtistLinks; + Wt::Dbo::collection> _clusters; }; namespace Debug diff --git a/src/libs/database/include/database/Types.hpp b/src/libs/database/include/database/Types.hpp index a992f6bd..44f08e94 100644 --- a/src/libs/database/include/database/Types.hpp +++ b/src/libs/database/include/database/Types.hpp @@ -119,6 +119,7 @@ namespace Database { None, Name, + ArtistNameThenName, Date, OriginalDate, OriginalDateDesc, diff --git a/src/libs/database/test/Artist.cpp b/src/libs/database/test/Artist.cpp index c1ebded7..38ef7a94 100644 --- a/src/libs/database/test/Artist.cpp +++ b/src/libs/database/test/Artist.cpp @@ -148,6 +148,39 @@ TEST_F(DatabaseFixture, Artist_singleTrack) } } +TEST_F(DatabaseFixture, Artist_singleTrack_mediaLibrary) +{ + ScopedTrack track{ session, "MyTrack" }; + ScopedArtist artist{ session, "MyArtist" }; + ScopedMediaLibrary library{ session }; + ScopedMediaLibrary otherLibrary{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + + track.get().modify()->setName("MyTrackName"); + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); + track.get().modify()->setMediaLibrary(library.get()); + } + { + auto transaction{ session.createReadTransaction() }; + auto artists{ Artist::findIds(session, Artist::FindParameters{}.setTrack(track->getId())) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } + { + auto transaction{ session.createReadTransaction() }; + auto artists{ Artist::findIds(session, Artist::FindParameters{}.setMediaLibrary(library->getId())) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } + { + auto transaction{ session.createReadTransaction() }; + auto artists{ Artist::findIds(session, Artist::FindParameters{}.setMediaLibrary(otherLibrary->getId())) }; + EXPECT_EQ(artists.results.size(), 0); + } +} + TEST_F(DatabaseFixture, Artist_singleTracktMultiRoles) { ScopedTrack track{ session, "MyTrack" }; diff --git a/src/libs/database/test/Common.hpp b/src/libs/database/test/Common.hpp index d052b2ff..d7bc32bd 100644 --- a/src/libs/database/test/Common.hpp +++ b/src/libs/database/test/Common.hpp @@ -28,6 +28,7 @@ #include "database/Cluster.hpp" #include "database/Db.hpp" #include "database/Listen.hpp" +#include "database/MediaLibrary.hpp" #include "database/Release.hpp" #include "database/ScanSettings.hpp" #include "database/Session.hpp" @@ -42,65 +43,66 @@ template class [[nodiscard]] ScopedEntity { - public: - using IdType = typename T::IdType; +public: + using IdType = typename T::IdType; - template - ScopedEntity(Database::Session& session, Args&& ...args) - : _session {session} - { - auto transaction {_session.createWriteTransaction()}; + template + ScopedEntity(Database::Session& session, Args&& ...args) + : _session{ session } + { + auto transaction{ _session.createWriteTransaction() }; - auto entity {_session.create(std::forward(args)...)}; - EXPECT_TRUE(entity); - _id = entity->getId(); - } + auto entity{ _session.create(std::forward(args)...) }; + EXPECT_TRUE(entity); + _id = entity->getId(); + } - ~ScopedEntity() - { - auto transaction {_session.createWriteTransaction()}; + ~ScopedEntity() + { + auto transaction{ _session.createWriteTransaction() }; - auto entity {T::find(_session, _id)}; - // could not be here due to "on delete cascade" constraints... - if (entity) - entity.remove(); - } + auto entity{ T::find(_session, _id) }; + // could not be here due to "on delete cascade" constraints... + if (entity) + entity.remove(); + } - ScopedEntity(const ScopedEntity&) = delete; - ScopedEntity(ScopedEntity&&) = delete; - ScopedEntity& operator=(const ScopedEntity&) = delete; - ScopedEntity& operator=(ScopedEntity&&) = delete; + ScopedEntity(const ScopedEntity&) = delete; + ScopedEntity(ScopedEntity&&) = delete; + ScopedEntity& operator=(const ScopedEntity&) = delete; + ScopedEntity& operator=(ScopedEntity&&) = delete; - typename T::pointer lockAndGet() - { - auto transaction {_session.createReadTransaction()}; - return get(); - } + typename T::pointer lockAndGet() + { + auto transaction{ _session.createReadTransaction() }; + return get(); + } - typename T::pointer get() - { - _session.checkReadTransaction(); + typename T::pointer get() + { + _session.checkReadTransaction(); - auto entity {T::find(_session, _id)}; - EXPECT_TRUE(entity); - return entity; - } + auto entity{ T::find(_session, _id) }; + EXPECT_TRUE(entity); + return entity; + } - typename T::pointer operator->() - { - return get(); - } + typename T::pointer operator->() + { + return get(); + } - IdType getId() const { return _id; } + IdType getId() const { return _id; } - private: - Database::Session& _session; - IdType _id {}; +private: + Database::Session& _session; + IdType _id{}; }; using ScopedArtist = ScopedEntity; using ScopedCluster = ScopedEntity; using ScopedClusterType = ScopedEntity; +using ScopedMediaLibrary = ScopedEntity; using ScopedRelease = ScopedEntity; using ScopedTrack = ScopedEntity; using ScopedTrackList = ScopedEntity; @@ -108,47 +110,47 @@ using ScopedUser = ScopedEntity; class ScopedFileDeleter final { - public: - ScopedFileDeleter(const std::filesystem::path& path) : _path {path} {} - ~ScopedFileDeleter() { std::filesystem::remove(_path); } +public: + ScopedFileDeleter(const std::filesystem::path& path) : _path{ path } {} + ~ScopedFileDeleter() { std::filesystem::remove(_path); } - ScopedFileDeleter(const ScopedFileDeleter&) = delete; - ScopedFileDeleter(ScopedFileDeleter&&) = delete; - ScopedFileDeleter operator=(const ScopedFileDeleter&) = delete; - ScopedFileDeleter operator=(ScopedFileDeleter&&) = delete; +private: + ScopedFileDeleter(const ScopedFileDeleter&) = delete; + ScopedFileDeleter(ScopedFileDeleter&&) = delete; + ScopedFileDeleter operator=(const ScopedFileDeleter&) = delete; + ScopedFileDeleter operator=(ScopedFileDeleter&&) = delete; - private: - const std::filesystem::path _path; + const std::filesystem::path _path; }; class TmpDatabase final { - public: - TmpDatabase (); +public: + TmpDatabase(); - Database::Db& getDb(); + Database::Db& getDb(); - private: - const std::filesystem::path _tmpFile; - ScopedFileDeleter _fileDeleter; - Database::Db _db; +private: + const std::filesystem::path _tmpFile; + ScopedFileDeleter _fileDeleter; + Database::Db _db; }; class DatabaseFixture : public ::testing::Test { public: - ~DatabaseFixture(); + ~DatabaseFixture(); public: static void SetUpTestCase(); static void TearDownTestCase(); private: - void testDatabaseEmpty(); + void testDatabaseEmpty(); - static inline std::unique_ptr _tmpDb {}; + static inline std::unique_ptr _tmpDb{}; public: - Database::Session session {_tmpDb->getDb()}; + Database::Session session{ _tmpDb->getDb() }; }; diff --git a/src/libs/database/test/Listen.cpp b/src/libs/database/test/Listen.cpp index e011b228..ecfe9541 100644 --- a/src/libs/database/test/Listen.cpp +++ b/src/libs/database/test/Listen.cpp @@ -153,7 +153,11 @@ TEST_F(DatabaseFixture, Listen_getTopArtists) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + const auto artists{ Listen::getTopArtists(session, params) }; EXPECT_EQ(artists.results.size(), 0); EXPECT_EQ(artists.moreResults, false); } @@ -171,22 +175,36 @@ TEST_F(DatabaseFixture, Listen_getTopArtists) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getTopArtists(session, params) }; ASSERT_EQ(artists.results.size(), 1); EXPECT_EQ(artists.results[0], artist1->getId()); } { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::ListenBrainz, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::ListenBrainz); + + auto artists{ Listen::getTopArtists(session, params) }; ASSERT_EQ(artists.results.size(), 0); } { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {}, TrackArtistLinkType::Producer) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::ListenBrainz); + params.setLinkType(TrackArtistLinkType::Producer); + + auto artists{ Listen::getTopArtists(session, params) }; EXPECT_EQ(artists.results.size(), 0); } + { ScopedClusterType clusterType{ session, "MyType" }; ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyCluster" }; @@ -194,7 +212,12 @@ TEST_F(DatabaseFixture, Listen_getTopArtists) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {cluster->getId()}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({cluster->getId()}); + + auto artists{ Listen::getTopArtists(session, params) }; EXPECT_EQ(artists.results.size(), 0); } } @@ -218,7 +241,11 @@ TEST_F(DatabaseFixture, Listen_getTopArtists_multi) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getTopArtists(session, params) }; EXPECT_EQ(artists.results.size(), 0); } @@ -226,16 +253,25 @@ TEST_F(DatabaseFixture, Listen_getTopArtists_multi) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getTopArtists(session, params) }; ASSERT_EQ(artists.results.size(), 1); EXPECT_EQ(artists.results[0], artist1->getId()); } + ScopedListen listen2{ session, user.lockAndGet(), track2.lockAndGet(), ScrobblingBackend::Internal, dateTime.addSecs(2) }; ScopedListen listen3{ session, user.lockAndGet(), track2.lockAndGet(), ScrobblingBackend::Internal, dateTime.addSecs(3) }; { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getTopArtists(session, params) }; ASSERT_EQ(artists.results.size(), 2); EXPECT_EQ(artists.results[0], artist2->getId()); EXPECT_EQ(artists.results[1], artist1->getId()); @@ -243,7 +279,12 @@ TEST_F(DatabaseFixture, Listen_getTopArtists_multi) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt, Range {0, 1}) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setRange(Range {0, 1}); + + auto artists{ Listen::getTopArtists(session, params) }; ASSERT_EQ(artists.results.size(), 1); EXPECT_EQ(artists.moreResults, true); EXPECT_EQ(artists.results[0], artist2->getId()); @@ -268,7 +309,12 @@ TEST_F(DatabaseFixture, Listen_getTopArtists_cluster) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto artists{ Listen::getTopArtists(session, params) }; ASSERT_EQ(artists.results.size(), 0); } { @@ -278,12 +324,83 @@ TEST_F(DatabaseFixture, Listen_getTopArtists_cluster) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getTopArtists(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto artists{ Listen::getTopArtists(session, params) }; ASSERT_EQ(artists.results.size(), 1); EXPECT_EQ(artists.results[0], artist.getId()); } } +TEST_F(DatabaseFixture, Listen_getTopArtists_mediaLibrary) +{ + ScopedTrack track{ session, "MyTrack" }; + ScopedArtist artist{ session, "MyArtist" }; + ScopedUser user{ session, "MyUser" }; + const Wt::WDateTime dateTime1{ Wt::WDate{2000, 1, 2}, Wt::WTime{12,0, 1} }; + ScopedListen listen{ session, user.lockAndGet(), track.lockAndGet(), ScrobblingBackend::Internal, dateTime1 }; + ScopedMediaLibrary library{ session }; + ScopedMediaLibrary otherLibrary{ session }; + + { + auto transaction{ session.createReadTransaction() }; + + Listen::ArtistStatsFindParameters params; + params.setUser(user.getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setMediaLibrary(library.getId()); + + const auto artists{ Listen::getTopArtists(session, params) }; + EXPECT_EQ(artists.results.size(), 0); + EXPECT_EQ(artists.moreResults, false); + } + + { + auto transaction{ session.createWriteTransaction() }; + + TrackArtistLink::create(session, track.get(), artist.get(), TrackArtistLinkType::Artist); + track.get().modify()->setMediaLibrary(library.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + + Listen::ArtistStatsFindParameters params; + params.setUser(user.getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getTopArtists(session, params) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results[0], artist->getId()); + } + { + auto transaction{ session.createReadTransaction() }; + + Listen::ArtistStatsFindParameters params; + params.setUser(user.getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setMediaLibrary(library.getId()); + + auto artists{ Listen::getTopArtists(session, params) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results[0], artist->getId()); + } + { + auto transaction{ session.createReadTransaction() }; + + Listen::ArtistStatsFindParameters params; + params.setUser(user.getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setMediaLibrary(otherLibrary.getId()); + + auto artists{ Listen::getTopArtists(session, params) }; + EXPECT_EQ(artists.results.size(), 0); + } +} + TEST_F(DatabaseFixture, Listen_getTopReleases) { ScopedTrack track{ session, "MyTrack" }; @@ -298,7 +415,11 @@ TEST_F(DatabaseFixture, Listen_getTopReleases) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getTopReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getTopReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); EXPECT_EQ(releases.results.size(), 0); } @@ -308,7 +429,11 @@ TEST_F(DatabaseFixture, Listen_getTopReleases) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getTopReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getTopReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 1); EXPECT_EQ(releases.results[0], release.getId()); @@ -316,7 +441,11 @@ TEST_F(DatabaseFixture, Listen_getTopReleases) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getTopReleases(session, user->getId(), ScrobblingBackend::ListenBrainz, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::ListenBrainz); + + auto releases{ Listen::getTopReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); EXPECT_EQ(releases.results.size(), 0); } @@ -341,7 +470,11 @@ TEST_F(DatabaseFixture, Listen_getTopReleases_multi) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getTopReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getTopReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 1); EXPECT_EQ(releases.results[0], release1.getId()); @@ -351,7 +484,11 @@ TEST_F(DatabaseFixture, Listen_getTopReleases_multi) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getTopReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getTopReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 2); EXPECT_EQ(releases.results[0], release2.getId()); @@ -362,7 +499,11 @@ TEST_F(DatabaseFixture, Listen_getTopReleases_multi) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getTopReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getTopReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 2); EXPECT_EQ(releases.results[0], release1.getId()); @@ -388,7 +529,12 @@ TEST_F(DatabaseFixture, Listen_getTopReleases_cluster) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getTopReleases(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto releases{ Listen::getTopReleases(session, params) }; EXPECT_EQ(releases.results.size(), 0); } { @@ -399,12 +545,74 @@ TEST_F(DatabaseFixture, Listen_getTopReleases_cluster) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getTopReleases(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto releases{ Listen::getTopReleases(session, params) }; EXPECT_EQ(releases.results.size(), 1); EXPECT_EQ(releases.results[0], release.getId()); } } +TEST_F(DatabaseFixture, Listen_getTopReleases_mediaLibrary) +{ + ScopedTrack track{ session, "MyTrack" }; + ScopedUser user{ session, "MyUser" }; + const Wt::WDateTime dateTime{ Wt::WDate{2000, 1, 2}, Wt::WTime{12,0, 1} }; + ScopedRelease release{ session, "MyRelease" }; + ScopedMediaLibrary library{ session }; + ScopedMediaLibrary otherLibrary{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + track.get().modify()->setRelease(release.get()); + track.get().modify()->setMediaLibrary(library.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setMediaLibrary(library.getId()); + + auto releases{ Listen::getTopReleases(session, params) }; + EXPECT_EQ(releases.moreResults, false); + EXPECT_EQ(releases.results.size(), 0); + } + + ScopedListen listen{ session, user.lockAndGet(), track.lockAndGet(), ScrobblingBackend::Internal, dateTime }; + + { + auto transaction{ session.createReadTransaction() }; + + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setMediaLibrary(library.getId()); + + auto releases{ Listen::getTopReleases(session, params) }; + EXPECT_EQ(releases.moreResults, false); + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results[0], release.getId()); + } + { + auto transaction{ session.createReadTransaction() }; + + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setMediaLibrary(otherLibrary.getId()); + + auto releases{ Listen::getTopReleases(session, params) }; + EXPECT_EQ(releases.moreResults, false); + EXPECT_EQ(releases.results.size(), 0); + } +} + TEST_F(DatabaseFixture, Listen_getTopTracks) { ScopedTrack track{ session, "MyTrack" }; @@ -414,7 +622,11 @@ TEST_F(DatabaseFixture, Listen_getTopTracks) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 0); } @@ -424,7 +636,11 @@ TEST_F(DatabaseFixture, Listen_getTopTracks) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 1); EXPECT_EQ(tracks.results[0], track.getId()); @@ -432,13 +648,16 @@ TEST_F(DatabaseFixture, Listen_getTopTracks) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), ScrobblingBackend::ListenBrainz, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::ListenBrainz); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); EXPECT_EQ(tracks.results.size(), 0); } } - TEST_F(DatabaseFixture, Listen_getTopTracks_artist) { ScopedTrack track{ session, "MyTrack" }; @@ -449,7 +668,12 @@ TEST_F(DatabaseFixture, Listen_getTopTracks_artist) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), artist->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setArtist(artist->getId()); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 0); } @@ -459,8 +683,12 @@ TEST_F(DatabaseFixture, Listen_getTopTracks_artist) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), artist->getId(), ScrobblingBackend::Internal, {}) }; - EXPECT_EQ(tracks.moreResults, false); + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setArtist(artist->getId()); + + auto tracks{ Listen::getTopTracks(session, params) }; ASSERT_EQ(tracks.results.size(), 0); } @@ -472,7 +700,12 @@ TEST_F(DatabaseFixture, Listen_getTopTracks_artist) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), artist->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setArtist(artist->getId()); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 1); EXPECT_EQ(tracks.results[0], track.getId()); @@ -490,7 +723,11 @@ TEST_F(DatabaseFixture, Listen_getTopTrack_multi) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 1); EXPECT_EQ(tracks.results[0], track1.getId()); @@ -500,7 +737,11 @@ TEST_F(DatabaseFixture, Listen_getTopTrack_multi) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 2); EXPECT_EQ(tracks.results[0], track2.getId()); @@ -511,7 +752,11 @@ TEST_F(DatabaseFixture, Listen_getTopTrack_multi) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 2); EXPECT_EQ(tracks.results[0], track1.getId()); @@ -531,7 +776,12 @@ TEST_F(DatabaseFixture, Listen_getTopTracks_cluster) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.results.size(), 0); } { @@ -542,12 +792,72 @@ TEST_F(DatabaseFixture, Listen_getTopTracks_cluster) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getTopTracks(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto tracks{ Listen::getTopTracks(session, params) }; EXPECT_EQ(tracks.results.size(), 1); EXPECT_EQ(tracks.results[0], track.getId()); } } +TEST_F(DatabaseFixture, Listen_getTopTracks_mediaLibrary) +{ + ScopedTrack track{ session, "MyTrack" }; + ScopedUser user{ session, "MyUser" }; + const Wt::WDateTime dateTime{ Wt::WDate{2000, 1, 2}, Wt::WTime{12,0, 1} }; + ScopedMediaLibrary library{ session }; + ScopedMediaLibrary otherLibrary{ session }; + + { + auto transaction{ session.createReadTransaction() }; + + Listen::StatsFindParameters params; + params.setUser(user.getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setMediaLibrary(library.getId()); + + auto tracks{ Listen::getTopTracks(session, params) }; + EXPECT_EQ(tracks.moreResults, false); + ASSERT_EQ(tracks.results.size(), 0); + } + + { + auto transaction{ session.createWriteTransaction() }; + track.get().modify()->setMediaLibrary(library.get()); + } + + ScopedListen listen{ session, user.lockAndGet(), track.lockAndGet(), ScrobblingBackend::Internal, dateTime }; + + { + auto transaction{ session.createReadTransaction() }; + + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setMediaLibrary(library.getId()); + + auto tracks{ Listen::getTopTracks(session, params) }; + EXPECT_EQ(tracks.moreResults, false); + ASSERT_EQ(tracks.results.size(), 1); + EXPECT_EQ(tracks.results[0], track.getId()); + } + { + auto transaction{ session.createReadTransaction() }; + + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setMediaLibrary(otherLibrary.getId()); + + auto tracks{ Listen::getTopTracks(session, params) }; + EXPECT_EQ(tracks.moreResults, false); + EXPECT_EQ(tracks.results.size(), 0); + } +} + TEST_F(DatabaseFixture, Listen_getRecentArtists) { ScopedTrack track{ session, "MyTrack" }; @@ -562,7 +872,11 @@ TEST_F(DatabaseFixture, Listen_getRecentArtists) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getRecentArtists(session, params) }; EXPECT_EQ(artists.results.size(), 0); EXPECT_EQ(artists.moreResults, false); } @@ -573,20 +887,33 @@ TEST_F(DatabaseFixture, Listen_getRecentArtists) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getRecentArtists(session, params) }; ASSERT_EQ(artists.results.size(), 1); EXPECT_EQ(artists.results[0], artist->getId()); } { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::ListenBrainz, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::ListenBrainz); + + auto artists{ Listen::getRecentArtists(session, params) }; ASSERT_EQ(artists.results.size(), 0); } { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {}, TrackArtistLinkType::Producer) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setLinkType(TrackArtistLinkType::Producer); + + auto artists{ Listen::getRecentArtists(session, params) }; EXPECT_EQ(artists.results.size(), 0); } { @@ -596,7 +923,12 @@ TEST_F(DatabaseFixture, Listen_getRecentArtists) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {cluster->getId()}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({cluster->getId()}); + + auto artists{ Listen::getRecentArtists(session, params) }; EXPECT_EQ(artists.results.size(), 0); } } @@ -620,7 +952,11 @@ TEST_F(DatabaseFixture, Listen_getRecentArtists_multi) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getRecentArtists(session, params) }; EXPECT_EQ(artists.results.size(), 0); } @@ -628,7 +964,11 @@ TEST_F(DatabaseFixture, Listen_getRecentArtists_multi) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getRecentArtists(session, params) }; ASSERT_EQ(artists.results.size(), 1); EXPECT_EQ(artists.results[0], artist1->getId()); } @@ -636,7 +976,11 @@ TEST_F(DatabaseFixture, Listen_getRecentArtists_multi) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto artists{ Listen::getRecentArtists(session, params) }; ASSERT_EQ(artists.results.size(), 2); EXPECT_EQ(artists.results[0], artist2->getId()); EXPECT_EQ(artists.results[1], artist1->getId()); @@ -645,7 +989,12 @@ TEST_F(DatabaseFixture, Listen_getRecentArtists_multi) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {}, std::nullopt, Range {0, 1}) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setRange(Range{ 0, 1 }); + + auto artists{ Listen::getRecentArtists(session, params) }; ASSERT_EQ(artists.results.size(), 1); EXPECT_EQ(artists.moreResults, true); EXPECT_EQ(artists.results[0], artist2->getId()); @@ -670,7 +1019,12 @@ TEST_F(DatabaseFixture, Listen_getRecentArtists_cluster) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto artists{ Listen::getRecentArtists(session, params) }; ASSERT_EQ(artists.results.size(), 0); } { @@ -680,7 +1034,12 @@ TEST_F(DatabaseFixture, Listen_getRecentArtists_cluster) { auto transaction{ session.createReadTransaction() }; - auto artists{ Listen::getRecentArtists(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}, std::nullopt) }; + Listen::ArtistStatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto artists{ Listen::getRecentArtists(session, params) }; ASSERT_EQ(artists.results.size(), 1); EXPECT_EQ(artists.results[0], artist.getId()); } @@ -700,7 +1059,11 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 0); } @@ -711,7 +1074,11 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 1); EXPECT_EQ(releases.results[0], release.getId()); @@ -719,7 +1086,11 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::ListenBrainz, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::ListenBrainz); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); EXPECT_EQ(releases.results.size(), 0); } @@ -798,7 +1169,11 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases_multi) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 1); EXPECT_EQ(releases.results[0], release2.getId()); @@ -808,7 +1183,11 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases_multi) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 2); EXPECT_EQ(releases.results[0], release1.getId()); @@ -819,7 +1198,11 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases_multi) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 2); EXPECT_EQ(releases.results[0], release2.getId()); @@ -830,7 +1213,11 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases_multi) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.moreResults, false); ASSERT_EQ(releases.results.size(), 2); EXPECT_EQ(releases.results[0], release2.getId()); @@ -853,7 +1240,12 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases_cluster) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.results.size(), 0); } @@ -863,7 +1255,12 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases_cluster) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.results.size(), 0); } @@ -874,7 +1271,12 @@ TEST_F(DatabaseFixture, Listen_getRecentReleases_cluster) { auto transaction{ session.createReadTransaction() }; - auto releases{ Listen::getRecentReleases(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto releases{ Listen::getRecentReleases(session, params) }; EXPECT_EQ(releases.results.size(), 1); EXPECT_EQ(releases.results[0], release.getId()); } @@ -888,7 +1290,11 @@ TEST_F(DatabaseFixture, Listen_getRecentTracks) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getRecentTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getRecentTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 0); } @@ -899,7 +1305,11 @@ TEST_F(DatabaseFixture, Listen_getRecentTracks) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getRecentTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getRecentTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 1); EXPECT_EQ(tracks.results[0], track.getId()); @@ -908,7 +1318,11 @@ TEST_F(DatabaseFixture, Listen_getRecentTracks) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getRecentTracks(session, user->getId(), ScrobblingBackend::ListenBrainz, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::ListenBrainz); + + auto tracks{ Listen::getRecentTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); EXPECT_EQ(tracks.results.size(), 0); } @@ -1054,7 +1468,11 @@ TEST_F(DatabaseFixture, Listen_getRecentTracks_multi) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getRecentTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getRecentTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 1); EXPECT_EQ(tracks.results[0], track2.getId()); @@ -1064,7 +1482,11 @@ TEST_F(DatabaseFixture, Listen_getRecentTracks_multi) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getRecentTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getRecentTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 2); EXPECT_EQ(tracks.results[0], track1.getId()); @@ -1075,7 +1497,11 @@ TEST_F(DatabaseFixture, Listen_getRecentTracks_multi) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getRecentTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getRecentTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 2); EXPECT_EQ(tracks.results[0], track2.getId()); @@ -1086,7 +1512,11 @@ TEST_F(DatabaseFixture, Listen_getRecentTracks_multi) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getRecentTracks(session, user->getId(), ScrobblingBackend::Internal, {}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + + auto tracks{ Listen::getRecentTracks(session, params) }; EXPECT_EQ(tracks.moreResults, false); ASSERT_EQ(tracks.results.size(), 2); EXPECT_EQ(tracks.results[0], track2.getId()); @@ -1106,7 +1536,12 @@ TEST_F(DatabaseFixture, Listen_getRecentTracks_cluster) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getRecentTracks(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto tracks{ Listen::getRecentTracks(session, params) }; EXPECT_EQ(tracks.results.size(), 0); } { @@ -1117,7 +1552,12 @@ TEST_F(DatabaseFixture, Listen_getRecentTracks_cluster) { auto transaction{ session.createReadTransaction() }; - auto tracks{ Listen::getRecentTracks(session, user->getId(), ScrobblingBackend::Internal, {cluster.getId()}) }; + Listen::StatsFindParameters params; + params.setUser(user->getId()); + params.setScrobblingBackend(ScrobblingBackend::Internal); + params.setClusters({ cluster.getId() }); + + auto tracks{ Listen::getRecentTracks(session, params) }; EXPECT_EQ(tracks.results.size(), 1); EXPECT_EQ(tracks.results[0], track.getId()); } diff --git a/src/libs/database/test/Release.cpp b/src/libs/database/test/Release.cpp index 59e84ec3..5fd781e8 100644 --- a/src/libs/database/test/Release.cpp +++ b/src/libs/database/test/Release.cpp @@ -133,6 +133,32 @@ TEST_F(DatabaseFixture, Release_singleTrack) } } +TEST_F(DatabaseFixture, Release_singleTrack_mediaLibrary) +{ + ScopedTrack track{ session, "MyTrack" }; + ScopedRelease release{ session, "MyRelease" }; + ScopedMediaLibrary library{ session }; + ScopedMediaLibrary otherLibrary{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + + track.get().modify()->setRelease(release.get()); + track.get().modify()->setMediaLibrary(library.get()); + } + { + auto transaction{ session.createReadTransaction() }; + auto releases{ Release::findIds(session, Release::FindParameters{}.setMediaLibrary(library->getId())) }; + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release.getId()); + } + { + auto transaction{ session.createReadTransaction() }; + auto releases{ Release::findIds(session, Release::FindParameters{}.setMediaLibrary(otherLibrary->getId())) }; + EXPECT_EQ(releases.results.size(), 0); + } +} + TEST_F(DatabaseFixture, Release_findByNameAndPath) { ScopedRelease release1{ session, "MyRelease" }; diff --git a/src/libs/database/test/Track.cpp b/src/libs/database/test/Track.cpp index a6b1d91b..fcbeefec 100644 --- a/src/libs/database/test/Track.cpp +++ b/src/libs/database/test/Track.cpp @@ -63,6 +63,57 @@ TEST_F(DatabaseFixture, Track) } } +TEST_F(DatabaseFixture, Track_MediaLibrary) +{ + ScopedTrack track{ session, "MyTrackFile" }; + ScopedMediaLibrary library{ session }; + ScopedMediaLibrary otherLibrary{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + track.get().modify()->setMediaLibrary(library.get()); + } + + { + auto transaction{ session.createWriteTransaction() }; + const auto tracks{ Track::findIds(session, Track::FindParameters{}.setMediaLibrary(library->getId()))}; + ASSERT_EQ(tracks.results.size(), 1); + EXPECT_EQ(tracks.results.front(), track.getId()); + } + { + auto transaction{ session.createWriteTransaction() }; + const auto tracks{ Track::findIds(session, Track::FindParameters{}.setMediaLibrary(otherLibrary->getId()))}; + EXPECT_EQ(tracks.results.size(), 0); + } +} + +TEST_F(DatabaseFixture, Track_noMediaLibrary) +{ + ScopedTrack track{ session, "MyTrackFile" }; + { + auto transaction{ session.createReadTransaction() }; + MediaLibrary::pointer mediaLibrary{ track->getMediaLibrary() }; + EXPECT_EQ(mediaLibrary, MediaLibrary::pointer{}); + EXPECT_FALSE(mediaLibrary); + EXPECT_TRUE(!mediaLibrary); + } +} + +TEST_F(DatabaseFixture, TrackNotExists) +{ + auto transaction{ session.createReadTransaction() }; + + EXPECT_FALSE(Track::exists(session, TrackId{ 42 })); + EXPECT_EQ(Track::find(session, TrackId{ 42 }), Track::pointer{}); + EXPECT_FALSE(Track::find(session, TrackId{ 42 })); + EXPECT_EQ(Track::find(session, Track::FindParameters{}).results.size(), 0); + { + auto track{ Track::find(session, TrackId{ 42 }) }; + EXPECT_TRUE(!track); + EXPECT_FALSE(track); + } +} + TEST_F(DatabaseFixture, MultipleTracks) { ScopedTrack track1{ session, "MyTrackFile1" }; diff --git a/src/libs/services/feedback/impl/FeedbackService.cpp b/src/libs/services/feedback/impl/FeedbackService.cpp index 5d10069e..b972743a 100644 --- a/src/libs/services/feedback/impl/FeedbackService.cpp +++ b/src/libs/services/feedback/impl/FeedbackService.cpp @@ -99,6 +99,7 @@ namespace Feedback searchParams.setLinkType(params.linkType); searchParams.setSortMethod(params.sortMethod); searchParams.setRange(params.range); + searchParams.setMediaLibrary(params.library); Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; @@ -137,6 +138,7 @@ namespace Feedback searchParams.setClusters(params.clusters); searchParams.setSortMethod(ReleaseSortMethod::StarredDateDesc); searchParams.setRange(params.range); + searchParams.setMediaLibrary(params.library); Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; @@ -175,6 +177,7 @@ namespace Feedback searchParams.setClusters(params.clusters); searchParams.setSortMethod(TrackSortMethod::StarredDateDesc); searchParams.setRange(params.range); + searchParams.setMediaLibrary(params.library); Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; diff --git a/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp b/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp index 1c9ac073..4abea134 100644 --- a/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp +++ b/src/libs/services/feedback/include/services/feedback/IFeedbackService.hpp @@ -27,6 +27,7 @@ #include "database/Types.hpp" #include "database/ArtistId.hpp" #include "database/ClusterId.hpp" +#include "database/MediaLibraryId.hpp" #include "database/ReleaseId.hpp" #include "database/TrackId.hpp" #include "database/UserId.hpp" @@ -53,10 +54,12 @@ namespace Feedback Database::UserId user; std::vector clusters; // if non empty, at least one artist that belongs to these clusters std::optional range; + Database::MediaLibraryId library; FindParameters& setUser(const Database::UserId _user) { user = _user; return *this; } FindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } FindParameters& setRange(std::optional _range) { range = _range; return *this; } + FindParameters& setMediaLibrary(Database::MediaLibraryId _library) { library = _library; return *this; } }; // Artists diff --git a/src/libs/services/scanner/impl/IScanStep.hpp b/src/libs/services/scanner/impl/IScanStep.hpp index 2eeb7b98..f7253002 100644 --- a/src/libs/services/scanner/impl/IScanStep.hpp +++ b/src/libs/services/scanner/impl/IScanStep.hpp @@ -20,26 +20,26 @@ #pragma once #include +#include #include "services/scanner/ScannerStats.hpp" namespace Scanner { - class IScanStep - { - public: - virtual ~IScanStep() = default; + class IScanStep + { + public: + virtual ~IScanStep() = default; - virtual ScanStep getStep() const = 0; - virtual std::string_view getStepName() const = 0; + virtual ScanStep getStep() const = 0; + virtual std::string_view getStepName() const = 0; - struct ScanContext - { - const std::filesystem::path directory; - const bool forceScan; - ScanStats stats; - ScanStepStats currentStepStats; - }; - virtual void process(ScanContext& context) = 0; - }; + struct ScanContext + { + const bool forceScan; + ScanStats stats; + ScanStepStats currentStepStats; + }; + virtual void process(ScanContext& context) = 0; + }; } diff --git a/src/libs/services/scanner/impl/ScanStepDiscoverFiles.cpp b/src/libs/services/scanner/impl/ScanStepDiscoverFiles.cpp index da2c9b51..3904399a 100644 --- a/src/libs/services/scanner/impl/ScanStepDiscoverFiles.cpp +++ b/src/libs/services/scanner/impl/ScanStepDiscoverFiles.cpp @@ -18,6 +18,7 @@ */ #include "ScanStepDiscoverFiles.hpp" + #include "utils/ILogger.hpp" #include "utils/Path.hpp" @@ -26,22 +27,30 @@ namespace Scanner void ScanStepDiscoverFiles::process(ScanContext& context) { context.stats.filesScanned = 0; - PathUtils::exploreFilesRecursive(context.directory, [&](std::error_code ec, const std::filesystem::path& path) - { - if (_abortScan) - return false; - if (!ec && PathUtils::hasFileAnyExtension(path, _settings.supportedExtensions)) + for (const ScannerSettings::MediaLibraryInfo& mediaLibrary : _settings.mediaLibraries) + { + std::size_t currentDirectoryProcessElemsCount{}; + PathUtils::exploreFilesRecursive(mediaLibrary.rootDirectory, [&](std::error_code ec, const std::filesystem::path& path) { - context.currentStepStats.processedElems++; - _progressCallback(context.currentStepStats); - } + if (_abortScan) + return false; - return true; - }, &excludeDirFileName); + if (!ec && PathUtils::hasFileAnyExtension(path, _settings.supportedExtensions)) + { + context.currentStepStats.processedElems++; + currentDirectoryProcessElemsCount++; + _progressCallback(context.currentStepStats); + } + + return true; + }, &excludeDirFileName); + + LMS_LOG(DBUPDATER, DEBUG, "Discovered " << currentDirectoryProcessElemsCount << " files in '" << mediaLibrary.rootDirectory << "'"); + } context.stats.filesScanned = context.currentStepStats.processedElems; - LMS_LOG(DBUPDATER, DEBUG, "Discovered " << context.stats.filesScanned << " files in '" << context.directory << "'"); + LMS_LOG(DBUPDATER, DEBUG, "Discovered " << context.stats.filesScanned << " files in all directories"); } } diff --git a/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.cpp b/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.cpp index b6f1f2c9..a0fa888f 100644 --- a/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.cpp +++ b/src/libs/services/scanner/impl/ScanStepRemoveOrphanDbFiles.cpp @@ -183,7 +183,11 @@ namespace Scanner return false; } - if (!PathUtils::isPathInRootPath(p, _settings.mediaDirectory, &excludeDirFileName)) + if (std::none_of(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries), + [&](const ScannerSettings::MediaLibraryInfo& libraryInfo) + { + return PathUtils::isPathInRootPath(p, libraryInfo.rootDirectory, &excludeDirFileName); + })) { LMS_LOG(DBUPDATER, INFO, "Removing '" << p.string() << "': out of media directory"); return false; diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp index 3b0abc0f..8b706840 100644 --- a/src/libs/services/scanner/impl/ScanStepScanFiles.cpp +++ b/src/libs/services/scanner/impl/ScanStepScanFiles.cpp @@ -23,6 +23,7 @@ #include "database/Artist.hpp" #include "database/Cluster.hpp" #include "database/Db.hpp" +#include "database/MediaLibrary.hpp" #include "database/Release.hpp" #include "database/Session.hpp" #include "database/Track.hpp" @@ -232,33 +233,36 @@ namespace Scanner context.currentStepStats.totalElems = context.stats.filesScanned; - PathUtils::exploreFilesRecursive(context.directory, [&](std::error_code ec, const std::filesystem::path& path) - { - if (_abortScan) - return false; - - if (ec) + for (const ScannerSettings::MediaLibraryInfo& mediaLibrary : _settings.mediaLibraries) + { + PathUtils::exploreFilesRecursive(mediaLibrary.rootDirectory, [&](std::error_code ec, const std::filesystem::path& path) { - LMS_LOG(DBUPDATER, ERROR, "Cannot process entry '" << path.string() << "': " << ec.message()); - context.stats.errors.emplace_back(ScanError{ path, ScanErrorType::CannotReadFile, ec.message() }); - } - else if (PathUtils::hasFileAnyExtension(path, _settings.supportedExtensions)) - { - scanAudioFile(path, context); + if (_abortScan) + return false; - context.currentStepStats.processedElems++; - _progressCallback(context.currentStepStats); + if (ec) + { + LMS_LOG(DBUPDATER, ERROR, "Cannot process entry '" << path.string() << "': " << ec.message()); + context.stats.errors.emplace_back(ScanError{ path, ScanErrorType::CannotReadFile, ec.message() }); + } + else if (PathUtils::hasFileAnyExtension(path, _settings.supportedExtensions)) + { + scanAudioFile(path, context, mediaLibrary); - // optimize the database during scan (if we import a very large database, it may be too late to do it once at end) - if ((context.currentStepStats.processedElems % 1'000) == 0) - _db.getTLSSession().optimize(); - } + context.currentStepStats.processedElems++; + _progressCallback(context.currentStepStats); - return true; - }, &excludeDirFileName); + // optimize the database during scan (if we import a very large database, it may be too late to do it once at end) + if ((context.currentStepStats.processedElems % 1'000) == 0) + _db.getTLSSession().optimize(); + } + + return true; + }, &excludeDirFileName); + } } - void ScanStepScanFiles::scanAudioFile(const std::filesystem::path& file, ScanContext& context) + void ScanStepScanFiles::scanAudioFile(const std::filesystem::path& file, ScanContext& context, const ScannerSettings::MediaLibraryInfo& libraryInfo) { ScanStats& stats{ context.stats }; Wt::WDateTime lastWriteTime; @@ -273,6 +277,7 @@ namespace Scanner return; } + bool needUpdateLibrary{}; if (!context.forceScan) { // Skip file if last write is the same @@ -281,14 +286,35 @@ namespace Scanner const Track::pointer track{ Track::findByPath(dbSession, file) }; - if (track && track->getLastWriteTime().toTime_t() == lastWriteTime.toTime_t() - && track->getScanVersion() == _settings.scanVersion) + if (track + && track->getLastWriteTime().toTime_t() == lastWriteTime.toTime_t() + && track->getScanVersion() == _settings.scanVersion + ) { - stats.skips++; - return; + // this file may have been moved from one library to another, then we just need to update the media library id instead of a full rescan + auto trackMediaLibrary{ track->getMediaLibrary() }; + if (trackMediaLibrary && trackMediaLibrary->getId() == libraryInfo.id) + { + stats.skips++; + return; + } + + needUpdateLibrary = true; } } + if (needUpdateLibrary) + { + Database::Session& dbSession{ _db.getTLSSession() }; + auto transaction{ _db.getTLSSession().createWriteTransaction() }; + + Track::pointer track{ Track::findByPath(dbSession, file) }; + assert(track); + track.modify()->setMediaLibrary(Database::MediaLibrary::find(dbSession, libraryInfo.id)); // may be null, will be handled in the next scan anyway + stats.updates++; + return; + } + std::optional trackInfo{ _metadataParser->parse(file) }; if (!trackInfo) { @@ -330,8 +356,14 @@ namespace Scanner continue; // Skip if duplicate files no longer in media root: as it will be removed later, we will end up with no file - if (!PathUtils::isPathInRootPath(file, _settings.mediaDirectory, &excludeDirFileName)) + if (std::none_of(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries), + [&](const ScannerSettings::MediaLibraryInfo& libraryInfo) + { + return PathUtils::isPathInRootPath(file, libraryInfo.rootDirectory, &excludeDirFileName); + })) + { continue; + } LMS_LOG(DBUPDATER, DEBUG, "Skipped '" << file.string() << "' (similar MBID in '" << otherTrack->getPath().string() << "')"); // As this MBID already exists, just remove what we just scanned @@ -389,6 +421,7 @@ namespace Scanner // Track related data assert(track); + track.modify()->setMediaLibrary(MediaLibrary::find(dbSession, libraryInfo.id)); // may be null if settings are updated in // => next scan will correct this track.modify()->clearArtistLinks(); // Do not fallback on artists with the same name but having a MBID for artist and releaseArtists, as it may be corrected by properly tagging files for (const Artist::pointer& artist : getOrCreateArtists(dbSession, trackInfo->artists, false)) @@ -450,7 +483,7 @@ namespace Scanner // If a file has an OriginalDate but no date, set it to ease filtering if (!trackInfo->date.isValid() && trackInfo->originalDate.isValid()) track.modify()->setDate(trackInfo->originalDate); - + // If a file has an OriginalYear but no Year, set it to ease filtering if (!trackInfo->year && trackInfo->originalYear) track.modify()->setYear(trackInfo->originalYear); diff --git a/src/libs/services/scanner/impl/ScanStepScanFiles.hpp b/src/libs/services/scanner/impl/ScanStepScanFiles.hpp index aeeb0775..35d69407 100644 --- a/src/libs/services/scanner/impl/ScanStepScanFiles.hpp +++ b/src/libs/services/scanner/impl/ScanStepScanFiles.hpp @@ -38,7 +38,7 @@ namespace Scanner std::string_view getStepName() const override { return "Scanning files"; } void process(ScanContext& context) override; - void scanAudioFile(const std::filesystem::path& file, ScanContext& context); + void scanAudioFile(const std::filesystem::path& file, ScanContext& context, const ScannerSettings::MediaLibraryInfo& libraryInfo); std::unique_ptr _metadataParser; const std::vector _extraTagsToParse{ "GENRE", "MOOD", "LANGUAGE", "ALBUMGROUPING" }; diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp index 773c082f..a11c05a2 100644 --- a/src/libs/services/scanner/impl/ScannerService.cpp +++ b/src/libs/services/scanner/impl/ScannerService.cpp @@ -22,7 +22,7 @@ #include #include -#include "database/Cluster.hpp" +#include "database/MediaLibrary.hpp" #include "database/TrackFeatures.hpp" #include "database/ScanSettings.hpp" #include "utils/Exception.hpp" @@ -113,6 +113,12 @@ namespace Scanner void ScannerService::abortScan() { + bool isRunning{}; + { + std::scoped_lock lock{ _statusMutex }; + isRunning = _curState == State::InProgress; + } + LMS_LOG(DBUPDATER, DEBUG, "Aborting scan..."); std::scoped_lock lock{ _controlMutex }; @@ -125,6 +131,9 @@ namespace Scanner _abortScan = false; _ioService.start(); + + if (isRunning) + _events.scanAborted.emit(); } void ScannerService::requestImmediateScan(bool force) @@ -139,6 +148,11 @@ namespace Scanner }); } + void ScannerService::requestStop() + { + abortScan(); + } + void ScannerService::requestReload() { abortScan(); @@ -261,7 +275,7 @@ namespace Scanner refreshScanSettings(); - IScanStep::ScanContext scanContext{ _settings.mediaDirectory, forceScan, ScanStats {}, ScanStepStats {} }; + IScanStep::ScanContext scanContext{ forceScan, ScanStats {}, ScanStepStats {} }; ScanStats& stats{ scanContext.stats }; stats.startTime = Wt::WDateTime::currentDateTime(); @@ -359,7 +373,11 @@ namespace Scanner std::transform(std::cbegin(fileExtensions), std::end(fileExtensions), std::back_inserter(newSettings.supportedExtensions), [](const std::filesystem::path& extension) { return std::filesystem::path{ StringUtils::stringToLower(extension.string()) }; }); } - newSettings.mediaDirectory = scanSettings->getMediaDirectory(); + + MediaLibrary::find(_dbSession, [&](const MediaLibrary::pointer& mediaLibrary) + { + newSettings.mediaLibraries.push_back(ScannerSettings::MediaLibraryInfo{ mediaLibrary->getId(), mediaLibrary->getPath().lexically_normal() }); + }); { const auto& tags{ scanSettings->getExtraTagsToScan() }; diff --git a/src/libs/services/scanner/impl/ScannerService.hpp b/src/libs/services/scanner/impl/ScannerService.hpp index 76f8fd71..6d0ff1b0 100644 --- a/src/libs/services/scanner/impl/ScannerService.hpp +++ b/src/libs/services/scanner/impl/ScannerService.hpp @@ -48,7 +48,8 @@ namespace Scanner ScannerService(const ScannerService&) = delete; ScannerService& operator=(const ScannerService&) = delete; - + private: + void requestStop() override; void requestReload() override; void requestImmediateScan(bool force) override; diff --git a/src/libs/services/scanner/impl/ScannerSettings.hpp b/src/libs/services/scanner/impl/ScannerSettings.hpp index eac1bb52..d00206ce 100644 --- a/src/libs/services/scanner/impl/ScannerSettings.hpp +++ b/src/libs/services/scanner/impl/ScannerSettings.hpp @@ -23,29 +23,38 @@ #include #include #include +#include "database/MediaLibraryId.hpp" #include "database/ScanSettings.hpp" namespace Scanner { - struct ScannerSettings - { - std::size_t scanVersion {}; - Wt::WTime startTime; - Database::ScanSettings::UpdatePeriod updatePeriod {Database::ScanSettings::UpdatePeriod::Never}; - std::vector supportedExtensions; - std::filesystem::path mediaDirectory; - bool skipDuplicateMBID {}; - std::vector extraTags; + struct ScannerSettings + { + std::size_t scanVersion{}; + Wt::WTime startTime; + Database::ScanSettings::UpdatePeriod updatePeriod{ Database::ScanSettings::UpdatePeriod::Never }; + std::vector supportedExtensions; + bool skipDuplicateMBID{}; + std::vector extraTags; - bool operator==(const ScannerSettings& rhs) const - { - return scanVersion == rhs.scanVersion - && startTime == rhs.startTime - && updatePeriod == rhs.updatePeriod - && supportedExtensions == rhs.supportedExtensions - && mediaDirectory == rhs.mediaDirectory - && skipDuplicateMBID == rhs.skipDuplicateMBID - && extraTags == rhs.extraTags; - } - }; + struct MediaLibraryInfo + { + Database::MediaLibraryId id; + std::filesystem::path rootDirectory; + + bool operator==(const MediaLibraryInfo& other) const { return id == other.id && rootDirectory == other.rootDirectory; } + }; + std::vector mediaLibraries; + + bool operator==(const ScannerSettings& rhs) const + { + return scanVersion == rhs.scanVersion + && startTime == rhs.startTime + && updatePeriod == rhs.updatePeriod + && supportedExtensions == rhs.supportedExtensions + && mediaLibraries == rhs.mediaLibraries + && skipDuplicateMBID == rhs.skipDuplicateMBID + && extraTags == rhs.extraTags; + } + }; } diff --git a/src/libs/services/scanner/include/services/scanner/IScannerService.hpp b/src/libs/services/scanner/include/services/scanner/IScannerService.hpp index 77c694f1..af248408 100644 --- a/src/libs/services/scanner/include/services/scanner/IScannerService.hpp +++ b/src/libs/services/scanner/include/services/scanner/IScannerService.hpp @@ -38,6 +38,7 @@ namespace Scanner virtual ~IScannerService() = default; // Async requests + virtual void requestStop() = 0; virtual void requestReload() = 0; virtual void requestImmediateScan(bool force) = 0; diff --git a/src/libs/services/scanner/include/services/scanner/ScannerEvents.hpp b/src/libs/services/scanner/include/services/scanner/ScannerEvents.hpp index c380bba6..6de2505a 100644 --- a/src/libs/services/scanner/include/services/scanner/ScannerEvents.hpp +++ b/src/libs/services/scanner/include/services/scanner/ScannerEvents.hpp @@ -27,20 +27,23 @@ namespace Scanner { - struct Events - { - // Called just after scan start - Wt::Signal<> scanStarted; + struct Events + { + // Called if scan was aborted + Wt::Signal<> scanAborted; - // Called just after scan complete (true if changes have been made) - Wt::Signal scanComplete; + // Called just after scan start + Wt::Signal<> scanStarted; - // Called during scan in progress - Wt::Signal scanInProgress; + // Called just after scan complete (true if changes have been made) + Wt::Signal scanComplete; - // Called after a schedule - Wt::Signal scanScheduled; - }; + // Called during scan in progress + Wt::Signal scanInProgress; + + // Called after a schedule + Wt::Signal scanScheduled; + }; } // ns Scanner diff --git a/src/libs/services/scrobbling/impl/ScrobblingService.cpp b/src/libs/services/scrobbling/impl/ScrobblingService.cpp index eb5c52eb..acbf32ff 100644 --- a/src/libs/services/scrobbling/impl/ScrobblingService.cpp +++ b/src/libs/services/scrobbling/impl/ScrobblingService.cpp @@ -35,6 +35,26 @@ namespace Scrobbling { using namespace Database; + namespace + { + Database::Listen::StatsFindParameters convertToListenFindParameters(const ScrobblingService::FindParameters& params) + { + Database::Listen::StatsFindParameters listenFindParams; + listenFindParams.setUser(params.user); + listenFindParams.setClusters(params.clusters); + listenFindParams.setRange(params.range); + listenFindParams.setMediaLibrary(params.library); + listenFindParams.setArtist(params.artist); + + return listenFindParams; + } + + Database::Listen::ArtistStatsFindParameters convertToListenFindParameters(const ScrobblingService::ArtistFindParameters& params) + { + return Database::Listen::ArtistStatsFindParameters{ convertToListenFindParameters(static_cast(params)), params.linkType }; + } + } + std::unique_ptr createScrobblingService(boost::asio::io_context& ioContext, Db& db) { return std::make_unique(ioContext, db); @@ -84,48 +104,57 @@ namespace Scrobbling return backend; } - ScrobblingService::ArtistContainer ScrobblingService::getRecentArtists(UserId userId, const std::vector& clusterIds, std::optional linkType, Range range) + ScrobblingService::ArtistContainer ScrobblingService::getRecentArtists(const ArtistFindParameters& params) { ArtistContainer res; - const auto backend{ getUserBackend(userId) }; + const auto backend{ getUserBackend(params.user) }; if (!backend) return res; + Database::Listen::ArtistStatsFindParameters listenFindParams{ convertToListenFindParameters(params) }; + listenFindParams.setScrobblingBackend(backend); + Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; - res = Database::Listen::getRecentArtists(session, userId, *backend, clusterIds, linkType, range); + res = Database::Listen::getRecentArtists(session, listenFindParams); return res; } - ScrobblingService::ReleaseContainer ScrobblingService::getRecentReleases(UserId userId, const std::vector& clusterIds, Range range) + ScrobblingService::ReleaseContainer ScrobblingService::getRecentReleases(const FindParameters& params) { ReleaseContainer res; - const auto backend{ getUserBackend(userId) }; + const auto backend{ getUserBackend(params.user) }; if (!backend) return res; + Database::Listen::StatsFindParameters listenFindParams{ convertToListenFindParameters(params) }; + listenFindParams.setScrobblingBackend(backend); + Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; - res = Database::Listen::getRecentReleases(session, userId, *backend, clusterIds, range); + res = Database::Listen::getRecentReleases(session, listenFindParams); return res; } - ScrobblingService::TrackContainer ScrobblingService::getRecentTracks(UserId userId, const std::vector& clusterIds, Range range) + ScrobblingService::TrackContainer ScrobblingService::getRecentTracks(const FindParameters& params) { TrackContainer res; - const auto backend{ getUserBackend(userId) }; + const auto backend{ getUserBackend(params.user) }; if (!backend) return res; + Database::Listen::StatsFindParameters listenFindParams{ convertToListenFindParameters(params) }; + listenFindParams.setScrobblingBackend(backend); + Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; - res = Database::Listen::getRecentTracks(session, userId, *backend, clusterIds, range); + res = Database::Listen::getRecentTracks(session, listenFindParams); return res; } @@ -170,63 +199,57 @@ namespace Scrobbling } // Top - ScrobblingService::ArtistContainer ScrobblingService::getTopArtists(UserId userId, const std::vector& clusterIds, std::optional linkType, Range range) + ScrobblingService::ArtistContainer ScrobblingService::getTopArtists(const ArtistFindParameters& params) { ArtistContainer res; - const auto backend{ getUserBackend(userId) }; + const auto backend{ getUserBackend(params.user) }; if (!backend) return res; + Database::Listen::ArtistStatsFindParameters listenFindParams{ convertToListenFindParameters(params) }; + listenFindParams.setScrobblingBackend(backend); + Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; - res = Database::Listen::getTopArtists(session, userId, *backend, clusterIds, linkType, range); + res = Database::Listen::getTopArtists(session, listenFindParams); return res; } - ScrobblingService::ReleaseContainer ScrobblingService::getTopReleases(UserId userId, const std::vector& clusterIds, Range range) + ScrobblingService::ReleaseContainer ScrobblingService::getTopReleases(const FindParameters& params) { ReleaseContainer res; - const auto backend{ getUserBackend(userId) }; + const auto backend{ getUserBackend(params.user) }; if (!backend) return res; + Database::Listen::StatsFindParameters listenFindParams{ convertToListenFindParameters(params) }; + listenFindParams.setScrobblingBackend(backend); + Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; - res = Database::Listen::getTopReleases(session, userId, *backend, clusterIds, range); + res = Database::Listen::getTopReleases(session, listenFindParams); return res; } - ScrobblingService::TrackContainer ScrobblingService::getTopTracks(UserId userId, const std::vector& clusterIds, Range range) + ScrobblingService::TrackContainer ScrobblingService::getTopTracks(const FindParameters& params) { TrackContainer res; - const auto backend{ getUserBackend(userId) }; + const auto backend{ getUserBackend(params.user) }; if (!backend) return res; + Database::Listen::StatsFindParameters listenFindParams{ convertToListenFindParameters(params) }; + listenFindParams.setScrobblingBackend(backend); + Session& session{ _db.getTLSSession() }; auto transaction{ session.createReadTransaction() }; - res = Database::Listen::getTopTracks(session, userId, *backend, clusterIds, range); - return res; - } - - ScrobblingService::TrackContainer ScrobblingService::getTopTracks(UserId userId, Database::ArtistId artistId, const std::vector& clusterIds, Range range) - { - TrackContainer res; - - const auto backend{ getUserBackend(userId) }; - if (!backend) - return res; - - Session& session{ _db.getTLSSession() }; - auto transaction{ session.createReadTransaction() }; - - res = Database::Listen::getTopTracks(session, userId, artistId, *backend, clusterIds, range); + res = Database::Listen::getTopTracks(session, listenFindParams); return res; } } // ns Scrobbling diff --git a/src/libs/services/scrobbling/impl/ScrobblingService.hpp b/src/libs/services/scrobbling/impl/ScrobblingService.hpp index c6f08508..d7c7345c 100644 --- a/src/libs/services/scrobbling/impl/ScrobblingService.hpp +++ b/src/libs/services/scrobbling/impl/ScrobblingService.hpp @@ -39,9 +39,9 @@ namespace Scrobbling void listenFinished(const Listen& listen, std::optional duration) override; void addTimedListen(const TimedListen& listen) override; - ArtistContainer getRecentArtists(Database::UserId userId, const std::vector& clusterIds, std::optional linkType,Database::Range range) override; - ReleaseContainer getRecentReleases(Database::UserId userId, const std::vector& clusterIds,Database::Range range) override; - TrackContainer getRecentTracks(Database::UserId userId, const std::vector& clusterIds, Database::Range range) override; + ArtistContainer getRecentArtists(const ArtistFindParameters& params) override; + ReleaseContainer getRecentReleases(const FindParameters& params) override; + TrackContainer getRecentTracks(const FindParameters& params) override; std::size_t getCount(Database::UserId userId, Database::ReleaseId releaseId) override; std::size_t getCount(Database::UserId userId, Database::TrackId trackId) override; @@ -49,10 +49,9 @@ namespace Scrobbling Wt::WDateTime getLastListenDateTime(Database::UserId userId, Database::ReleaseId releaseId) override; Wt::WDateTime getLastListenDateTime(Database::UserId userId, Database::TrackId trackId) override; - ArtistContainer getTopArtists(Database::UserId userId, const std::vector& clusterIds, std::optional linkType, Database::Range range) override; - ReleaseContainer getTopReleases(Database::UserId userId, const std::vector& clusterIds, Database::Range range) override; - TrackContainer getTopTracks(Database::UserId userId, const std::vector& clusterIds, Database::Range range) override; - TrackContainer getTopTracks(Database::UserId userId, Database::ArtistId artistId, const std::vector& clusterIds, Database::Range range) override; + ArtistContainer getTopArtists(const ArtistFindParameters& params) override; + ReleaseContainer getTopReleases(const FindParameters& params) override; + TrackContainer getTopTracks(const FindParameters& params) override; std::optional getUserBackend(Database::UserId userId); diff --git a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp index 35ba2459..bd8aab9e 100644 --- a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp +++ b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp @@ -28,6 +28,7 @@ #include "services/scrobbling/Listen.hpp" #include "database/ArtistId.hpp" #include "database/ClusterId.hpp" +#include "database/MediaLibraryId.hpp" #include "database/ReleaseId.hpp" #include "database/TrackId.hpp" #include "database/Types.hpp" @@ -39,7 +40,6 @@ namespace Database namespace Scrobbling { - class IScrobblingService { public: @@ -55,11 +55,36 @@ namespace Scrobbling using ArtistContainer = Database::RangeResults; using ReleaseContainer = Database::RangeResults; using TrackContainer = Database::RangeResults; - + + struct FindParameters + { + Database::UserId user; + std::vector clusters; // if non empty, at least one artist that belongs to these clusters + std::optional range; + Database::MediaLibraryId library; // if set, match this library + Database::ArtistId artist; // if set, match this artist + + FindParameters& setUser(const Database::UserId _user) { user = _user; return *this; } + FindParameters& setClusters(const std::vector& _clusters) { clusters = _clusters; return *this; } + FindParameters& setRange(std::optional _range) { range = _range; return *this; } + FindParameters& setMediaLibrary(Database::MediaLibraryId _library) { library = _library; return *this; } + FindParameters& setArtist(Database::ArtistId _artist) { artist = _artist; return *this; } + }; + + // Artists + struct ArtistFindParameters : public FindParameters + { + std::optional linkType; // if set, only artists that have produced at least one track with this link type + Database::ArtistSortMethod sortMethod{ Database::ArtistSortMethod::None }; + + ArtistFindParameters& setLinkType(std::optional _linkType) { linkType = _linkType; return *this; } + ArtistFindParameters& setSortMethod(Database::ArtistSortMethod _sortMethod) { sortMethod = _sortMethod; return *this; } + }; + // From most recent to oldest - virtual ArtistContainer getRecentArtists(Database::UserId userId, const std::vector& clusterIds, std::optional linkType, Database::Range range) = 0; - virtual ReleaseContainer getRecentReleases(Database::UserId userId, const std::vector& clusterIds, Database::Range range) = 0; - virtual TrackContainer getRecentTracks(Database::UserId userId, const std::vector& clusterIds, Database::Range range) = 0; + virtual ArtistContainer getRecentArtists(const ArtistFindParameters& params) = 0; + virtual ReleaseContainer getRecentReleases(const FindParameters& params) = 0; + virtual TrackContainer getRecentTracks(const FindParameters& params) = 0; virtual std::size_t getCount(Database::UserId userId, Database::ReleaseId releaseId) = 0; virtual std::size_t getCount(Database::UserId userId, Database::TrackId trackId) = 0; @@ -68,13 +93,10 @@ namespace Scrobbling virtual Wt::WDateTime getLastListenDateTime(Database::UserId userId, Database::TrackId trackId) = 0; // Top - virtual ArtistContainer getTopArtists(Database::UserId userId, const std::vector& clusterIds, std::optional linkType, Database::Range) = 0; - virtual ReleaseContainer getTopReleases(Database::UserId userId, const std::vector& clusterIds, Database::Range range) = 0; - virtual TrackContainer getTopTracks(Database::UserId userId, const std::vector& clusterIds, Database::Range range) = 0; - virtual TrackContainer getTopTracks(Database::UserId userId, Database::ArtistId artistId, const std::vector& clusterIds, Database::Range range) = 0; + virtual ArtistContainer getTopArtists(const ArtistFindParameters& params) = 0; + virtual ReleaseContainer getTopReleases(const FindParameters& params) = 0; + virtual TrackContainer getTopTracks(const FindParameters& params) = 0; }; std::unique_ptr createScrobblingService(boost::asio::io_service& ioService, Database::Db& db); - } // ns Scrobbling - diff --git a/src/libs/subsonic/impl/SubsonicId.cpp b/src/libs/subsonic/impl/SubsonicId.cpp index 073c961e..91efad1c 100644 --- a/src/libs/subsonic/impl/SubsonicId.cpp +++ b/src/libs/subsonic/impl/SubsonicId.cpp @@ -31,6 +31,12 @@ namespace API::Subsonic return "ar-" + id.toString(); } + std::string idToString(Database::MediaLibraryId id) + { + // No need to prefix as this is only used at well known places + return id.toString(); + } + std::string idToString(Database::ReleaseId id) { return "al-" + id.toString(); @@ -70,6 +76,15 @@ namespace StringUtils return std::nullopt; } + template<> + std::optional readAs(std::string_view str) + { + if (const auto value{ StringUtils::readAs(str) }) + return Database::MediaLibraryId{ *value }; + + return std::nullopt; + } + template<> std::optional readAs(std::string_view str) { diff --git a/src/libs/subsonic/impl/SubsonicId.hpp b/src/libs/subsonic/impl/SubsonicId.hpp index 3a68e3f2..6f356084 100644 --- a/src/libs/subsonic/impl/SubsonicId.hpp +++ b/src/libs/subsonic/impl/SubsonicId.hpp @@ -20,6 +20,7 @@ #pragma once #include "database/ArtistId.hpp" +#include "database/MediaLibraryId.hpp" #include "database/ReleaseId.hpp" #include "database/TrackId.hpp" #include "database/TrackListId.hpp" @@ -30,6 +31,7 @@ namespace API::Subsonic struct RootId {}; std::string idToString(Database::ArtistId id); + std::string idToString(Database::MediaLibraryId id); std::string idToString(Database::ReleaseId id); std::string idToString(Database::TrackId id); std::string idToString(Database::TrackListId id); @@ -45,6 +47,9 @@ namespace StringUtils template<> std::optional readAs(std::string_view str); + template<> + std::optional readAs(std::string_view str); + template<> std::optional readAs(std::string_view str); diff --git a/src/libs/subsonic/impl/entrypoints/AlbumSongLists.cpp b/src/libs/subsonic/impl/entrypoints/AlbumSongLists.cpp index b493ae72..44b2f506 100644 --- a/src/libs/subsonic/impl/entrypoints/AlbumSongLists.cpp +++ b/src/libs/subsonic/impl/entrypoints/AlbumSongLists.cpp @@ -32,6 +32,7 @@ #include "responses/Song.hpp" #include "utils/Service.hpp" #include "ParameterParsing.hpp" +#include "SubsonicId.hpp" namespace API::Subsonic { @@ -45,6 +46,7 @@ namespace API::Subsonic const std::string type{ getMandatoryParameterAs(context.parameters, "type") }; // Optional params + const MediaLibraryId mediaLibraryId{ getParameterAs(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) }; const std::size_t size{ getParameterAs(context.parameters, "size").value_or(10) }; const std::size_t offset{ getParameterAs(context.parameters, "offset").value_or(0) }; if (size > defaultMaxCountSize) @@ -67,12 +69,18 @@ namespace API::Subsonic Release::FindParameters params; params.setSortMethod(ReleaseSortMethod::Name); params.setRange(range); + params.setMediaLibrary(mediaLibraryId); releases = Release::findIds(context.dbSession, params); } else if (type == "alphabeticalByArtist") { - releases = Release::findIdsOrderedByArtist(context.dbSession, range); + Release::FindParameters params; + params.setSortMethod(ReleaseSortMethod::ArtistNameThenName); + params.setRange(range); + params.setMediaLibrary(mediaLibraryId); + + releases = Release::findIds(context.dbSession, params); } else if (type == "byGenre") { @@ -87,6 +95,7 @@ namespace API::Subsonic params.setClusters({ cluster->getId() }); params.setSortMethod(ReleaseSortMethod::Name); params.setRange(range); + params.setMediaLibrary(mediaLibraryId); releases = Release::findIds(context.dbSession, params); } @@ -101,18 +110,25 @@ namespace API::Subsonic params.setSortMethod(ReleaseSortMethod::Date); params.setRange(range); params.setDateRange(DateRange::fromYearRange(fromYear, toYear)); + params.setMediaLibrary(mediaLibraryId); releases = Release::findIds(context.dbSession, params); } else if (type == "frequent") { - releases = scrobblingService.getTopReleases(context.userId, {}, range); + Scrobbling::IScrobblingService::FindParameters params; + params.setUser(context.userId); + params.setRange(range); + params.setMediaLibrary(mediaLibraryId); + + releases = scrobblingService.getTopReleases(params); } else if (type == "newest") { Release::FindParameters params; params.setSortMethod(ReleaseSortMethod::LastWritten); params.setRange(range); + params.setMediaLibrary(mediaLibraryId); releases = Release::findIds(context.dbSession, params); } @@ -123,18 +139,26 @@ namespace API::Subsonic Release::FindParameters params; params.setSortMethod(ReleaseSortMethod::Random); params.setRange(Range{ 0, size }); + params.setMediaLibrary(mediaLibraryId); releases = Release::findIds(context.dbSession, params); } else if (type == "recent") { - releases = scrobblingService.getRecentReleases(context.userId, {}, range); + Scrobbling::IScrobblingService::FindParameters params; + params.setUser(context.userId); + params.setRange(range); + params.setMediaLibrary(mediaLibraryId); + + releases = scrobblingService.getRecentReleases(params); } else if (type == "starred") { Feedback::IFeedbackService::FindParameters params; params.setUser(context.userId); params.setRange(range); + params.setMediaLibrary(mediaLibraryId); + releases = feedbackService.findStarredReleases(params); } else @@ -156,6 +180,9 @@ namespace API::Subsonic Response handleGetStarredRequestCommon(RequestContext& context, bool id3) { + // Optional parameters + const MediaLibraryId mediaLibrary{ getParameterAs(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) }; + auto transaction{ context.dbSession.createReadTransaction() }; User::pointer user{ User::find(context.dbSession, context.userId) }; @@ -167,9 +194,6 @@ namespace API::Subsonic Feedback::IFeedbackService& feedbackService{ *Service::get() }; - Feedback::IFeedbackService::FindParameters findParameters; - findParameters.setUser(context.userId); - { Feedback::IFeedbackService::ArtistFindParameters artistFindParams; artistFindParams.setUser(context.userId); @@ -181,6 +205,10 @@ namespace API::Subsonic } } + Feedback::IFeedbackService::FindParameters findParameters; + findParameters.setUser(context.userId); + findParameters.setMediaLibrary(mediaLibrary); + for (const ReleaseId releaseId : feedbackService.findStarredReleases(findParameters).results) { if (auto release{ Release::find(context.dbSession, releaseId) }) @@ -210,6 +238,7 @@ namespace API::Subsonic Response handleGetRandomSongsRequest(RequestContext& context) { // Optional params + const MediaLibraryId mediaLibraryId{ getParameterAs(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) }; std::size_t size{ getParameterAs(context.parameters, "size").value_or(50) }; if (size > defaultMaxCountSize) throw ParameterValueTooHighGenericError{ "size", defaultMaxCountSize }; @@ -226,6 +255,7 @@ namespace API::Subsonic Track::FindParameters params; params.setSortMethod(TrackSortMethod::Random); params.setRange(Range{ 0, size }); + params.setMediaLibrary(mediaLibraryId); Track::find(context.dbSession, params, [&](const Track::pointer& track) { @@ -241,6 +271,7 @@ namespace API::Subsonic std::string genre{ getMandatoryParameterAs(context.parameters, "genre") }; // Optional params + const MediaLibraryId mediaLibrary{ getParameterAs(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) }; std::size_t count{ getParameterAs(context.parameters, "count").value_or(10) }; if (count > defaultMaxCountSize) throw ParameterValueTooHighGenericError{"count", defaultMaxCountSize}; @@ -267,6 +298,7 @@ namespace API::Subsonic Track::FindParameters params; params.setClusters({ cluster->getId() }); params.setRange(Range{ offset, count }); + params.setMediaLibrary(mediaLibrary); Track::find(context.dbSession, params, [&](const Track::pointer& track) { diff --git a/src/libs/subsonic/impl/entrypoints/Browsing.cpp b/src/libs/subsonic/impl/entrypoints/Browsing.cpp index b6cbe1a4..9d496a3d 100644 --- a/src/libs/subsonic/impl/entrypoints/Browsing.cpp +++ b/src/libs/subsonic/impl/entrypoints/Browsing.cpp @@ -21,6 +21,7 @@ #include "database/Artist.hpp" #include "database/Cluster.hpp" +#include "database/MediaLibrary.hpp" #include "database/Session.hpp" #include "database/Release.hpp" #include "database/Track.hpp" @@ -92,6 +93,9 @@ namespace API::Subsonic Response handleGetArtistsRequestCommon(RequestContext& context, bool id3) { + // Optional params + const MediaLibraryId mediaLibrary{ getParameterAs(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) }; + Response response{ Response::createOkResponse(context.serverProtocolVersion) }; Response::Node& artistsNode{ response.createNode(id3 ? "artists" : "indexes") }; @@ -119,6 +123,7 @@ namespace API::Subsonic break; } } + parameters.setMediaLibrary(mediaLibrary); // This endpoint does not scale: make sort lived transactions in order not to block the whole application @@ -283,9 +288,14 @@ namespace API::Subsonic Response response{ Response::createOkResponse(context.serverProtocolVersion) }; Response::Node& musicFoldersNode{ response.createNode("musicFolders") }; - Response::Node& musicFolderNode{ musicFoldersNode.createArrayChild("musicFolder") }; - musicFolderNode.setAttribute("id", "0"); - musicFolderNode.setAttribute("name", "Music"); + auto transaction{ context.dbSession.createReadTransaction() }; + MediaLibrary::find(context.dbSession, [&](const MediaLibrary::pointer& library) + { + Response::Node& musicFolderNode{ musicFoldersNode.createArrayChild("musicFolder") }; + + musicFolderNode.setAttribute("id", idToString(library->getId())); + musicFolderNode.setAttribute("name", library->getName()); + }); return response; } @@ -502,7 +512,12 @@ namespace API::Subsonic Response response{ Response::createOkResponse(context.serverProtocolVersion) }; Response::Node& topSongs{ response.createNode("topSongs") }; - const auto trackIds{ Service::get()->getTopTracks(context.userId, artists.front()->getId(), {}, Database::Range{ 0, count }) }; + Scrobbling::IScrobblingService::FindParameters params; + params.setUser(context.userId); + params.setRange(Database::Range{ 0, count }); + params.setArtist(artists.front()->getId()); + + const auto trackIds{ Service::get()->getTopTracks(params) }; for (const TrackId trackId : trackIds.results) { if (Track::pointer track{ Track::find(context.dbSession, trackId) }) diff --git a/src/libs/subsonic/impl/entrypoints/Searching.cpp b/src/libs/subsonic/impl/entrypoints/Searching.cpp index 56e75425..69d04fa9 100644 --- a/src/libs/subsonic/impl/entrypoints/Searching.cpp +++ b/src/libs/subsonic/impl/entrypoints/Searching.cpp @@ -28,9 +28,8 @@ #include "responses/Artist.hpp" #include "responses/Song.hpp" #include "ParameterParsing.hpp" +#include "SubsonicId.hpp" -#include "ParameterParsing.hpp" - namespace API::Subsonic { using namespace Database; @@ -43,6 +42,9 @@ namespace API::Subsonic std::string queryString{ getMandatoryParameterAs(context.parameters, "query") }; std::string_view query{ queryString }; + // Optional params + const MediaLibraryId mediaLibrary{ getParameterAs(context.parameters, "musicFolderId").value_or(MediaLibraryId{}) }; + // Symfonium adds extra "" if (context.clientInfo.name == "Symfonium") query = StringUtils::stringTrim(query, "\""); @@ -78,6 +80,7 @@ namespace API::Subsonic Artist::FindParameters params; params.setKeywords(keywords); params.setRange(Range{ artistOffset, artistCount }); + params.setMediaLibrary(mediaLibrary); Artist::find(context.dbSession, params, [&](const Artist::pointer& artist) { @@ -90,6 +93,7 @@ namespace API::Subsonic Release::FindParameters params; params.setKeywords(keywords); params.setRange(Range{ albumOffset, albumCount }); + params.setMediaLibrary(mediaLibrary); Release::find(context.dbSession, params, [&](const Release::pointer& release) { @@ -102,6 +106,7 @@ namespace API::Subsonic Track::FindParameters params; params.setKeywords(keywords); params.setRange(Range{ songOffset, songCount }); + params.setMediaLibrary(mediaLibrary); Track::find(context.dbSession, params, [&](const Track::pointer& track) { diff --git a/src/libs/utils/impl/Path.cpp b/src/libs/utils/impl/Path.cpp index c42d7a23..bc3de353 100644 --- a/src/libs/utils/impl/Path.cpp +++ b/src/libs/utils/impl/Path.cpp @@ -139,14 +139,13 @@ namespace PathUtils return (std::find(std::cbegin(supportedExtensions), std::cend(supportedExtensions), extension) != std::cend(supportedExtensions)); } - bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPath, const std::filesystem::path* excludeDirFileName) + bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPathArg, const std::filesystem::path* excludeDirFileName) { - std::filesystem::path curPath = path; + std::filesystem::path curPath{ path }; + std::filesystem::path rootPath{ rootPathArg.has_filename() ? rootPathArg : rootPathArg.parent_path() }; - while (curPath.parent_path() != curPath) + while (true) { - curPath = curPath.parent_path(); - if (excludeDirFileName && !excludeDirFileName->empty()) { assert(!excludeDirFileName->has_parent_path()); @@ -158,6 +157,11 @@ namespace PathUtils if (curPath == rootPath) return true; + + if (curPath == curPath.root_path()) + break; + + curPath = curPath.parent_path(); } return false; diff --git a/src/libs/utils/include/utils/Path.hpp b/src/libs/utils/include/utils/Path.hpp index 72f81993..418bc2e2 100644 --- a/src/libs/utils/include/utils/Path.hpp +++ b/src/libs/utils/include/utils/Path.hpp @@ -44,6 +44,7 @@ namespace PathUtils bool hasFileAnyExtension(const std::filesystem::path& file, const std::vector& 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 bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPath, const std::filesystem::path* excludeDirFileName = {}); std::filesystem::path getLongestCommonPath(const std::filesystem::path& path1, const std::filesystem::path& path2); diff --git a/src/libs/utils/test/Path.cpp b/src/libs/utils/test/Path.cpp index 10662fe4..23ecd894 100644 --- a/src/libs/utils/test/Path.cpp +++ b/src/libs/utils/test/Path.cpp @@ -76,4 +76,36 @@ TEST(Path, getLongestCommonPathIterator) { EXPECT_EQ(PathUtils::getLongestCommonPath(std::cbegin(test.paths), std::cend(test.paths)), test.expectedCommonPath); } +} + +TEST(Path, isPathInRootPath) +{ + using namespace PathUtils; + + struct TestCase + { + std::filesystem::path path; + std::filesystem::path rootPath; + bool expectedResult; + }; + + TestCase tests[] + { + {"/file.txt", "/", true}, + {"/root/folder/file.txt", "/root", true}, + {"/root/file.txt", "/root", true}, + {"/root/file.txt", "/root/", true}, + {"/root", "/root", true}, + {"/root", "/root/", true}, + {"/root/", "/root", true}, + {"/root/", "/root/", true}, + {"/folder/file.txt", "/root", false}, + {"/folder/file.txt", "/root/", false}, + {"", "/root", false}, + }; + + for (const TestCase& test : tests) + { + EXPECT_EQ(PathUtils::isPathInRootPath(test.path, test.rootPath), test.expectedResult) << "Failed: path = " << test.path << ", rootPath = " << test.rootPath; + } } \ No newline at end of file diff --git a/src/lms/CMakeLists.txt b/src/lms/CMakeLists.txt index 87adf57a..e0b813e4 100644 --- a/src/lms/CMakeLists.txt +++ b/src/lms/CMakeLists.txt @@ -11,12 +11,13 @@ add_executable(lms ui/PlayQueue.cpp ui/SettingsView.cpp ui/Utils.cpp - ui/admin/DatabaseSettingsView.cpp - ui/admin/ScannerController.cpp ui/admin/InitWizardView.cpp + ui/admin/MediaLibrariesView.cpp + ui/admin/MediaLibraryModal.cpp + ui/admin/ScannerController.cpp + ui/admin/ScanSettingsView.cpp ui/admin/UserView.cpp ui/admin/UsersView.cpp - ui/common/DirectoryValidator.cpp ui/common/DoubleValidator.cpp ui/common/InfiniteScrollingContainer.cpp ui/common/LoadingIndicator.cpp diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 174c68af..468c2c00 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -161,6 +161,15 @@ namespace }); } }; + scanner.getEvents().scanAborted.connect([&] + { + postAll(server, [] + { + LmsApp->getScannerEvents().scanAborted.emit(); + LmsApp->triggerUpdate(); + }); + }); + scanner.getEvents().scanStarted.connect([&] { postAll(server, [] diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index 69d6e510..367cc647 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -42,15 +42,16 @@ #include "utils/String.hpp" #include "admin/InitWizardView.hpp" -#include "admin/DatabaseSettingsView.hpp" +#include "admin/MediaLibrariesView.hpp" +#include "admin/ScanSettingsView.hpp" #include "admin/UserView.hpp" #include "admin/UsersView.hpp" +#include "admin/ScannerController.hpp" #include "common/Template.hpp" #include "explore/Explore.hpp" #include "explore/Filters.hpp" #include "resource/AudioFileResource.hpp" #include "resource/AudioTranscodingResource.hpp" -#include "resource/DownloadResource.hpp" #include "resource/CoverResource.hpp" #include "Auth.hpp" #include "LmsApplicationException.hpp" @@ -73,9 +74,11 @@ namespace UserInterface const std::string appRoot{ Wt::WApplication::appRoot() }; auto res{ std::make_shared() }; - res->use(appRoot + "admin-database"); res->use(appRoot + "admin-initwizard"); + res->use(appRoot + "admin-medialibraries"); + res->use(appRoot + "admin-medialibrary"); res->use(appRoot + "admin-scannercontroller"); + res->use(appRoot + "admin-scansettings"); res->use(appRoot + "admin-user"); res->use(appRoot + "admin-users"); res->use(appRoot + "artist"); @@ -111,7 +114,9 @@ namespace UserInterface IdxExplore = 0, IdxPlayQueue, IdxSettings, - IdxAdminDatabase, + IdxAdminLibraries, + IdxAdminScanSettings, + IdxAdminScanner, IdxAdminUsers, IdxAdminUser, }; @@ -126,19 +131,21 @@ namespace UserInterface std::optional title; } views[] = { - { "/artists", IdxExplore, false, Wt::WString::tr("Lms.Explore.artists") }, - { "/artist", IdxExplore, false, std::nullopt }, - { "/releases", IdxExplore, false, Wt::WString::tr("Lms.Explore.releases") }, - { "/release", IdxExplore, false, std::nullopt }, - { "/search", IdxExplore, false, Wt::WString::tr("Lms.Explore.search") }, - { "/tracks", IdxExplore, false, Wt::WString::tr("Lms.Explore.tracks") }, - { "/tracklists", IdxExplore, false, Wt::WString::tr("Lms.Explore.tracklists") }, - { "/tracklist", IdxExplore, false, std::nullopt }, - { "/playqueue", IdxPlayQueue, false, Wt::WString::tr("Lms.PlayQueue.playqueue") }, - { "/settings", IdxSettings, false, Wt::WString::tr("Lms.Settings.settings") }, - { "/admin/database", IdxAdminDatabase, true, Wt::WString::tr("Lms.Admin.Database.database") }, - { "/admin/users", IdxAdminUsers, true, Wt::WString::tr("Lms.Admin.Users.users") }, - { "/admin/user", IdxAdminUser, true, std::nullopt }, + { "/artists", IdxExplore, false, Wt::WString::tr("Lms.Explore.artists") }, + { "/artist", IdxExplore, false, std::nullopt }, + { "/releases", IdxExplore, false, Wt::WString::tr("Lms.Explore.releases") }, + { "/release", IdxExplore, false, std::nullopt }, + { "/search", IdxExplore, false, Wt::WString::tr("Lms.Explore.search") }, + { "/tracks", IdxExplore, false, Wt::WString::tr("Lms.Explore.tracks") }, + { "/tracklists", IdxExplore, false, Wt::WString::tr("Lms.Explore.tracklists") }, + { "/tracklist", IdxExplore, false, std::nullopt }, + { "/playqueue", IdxPlayQueue, false, Wt::WString::tr("Lms.PlayQueue.playqueue") }, + { "/settings", IdxSettings, false, Wt::WString::tr("Lms.Settings.settings") }, + { "/admin/libraries", IdxAdminLibraries, true, Wt::WString::tr("Lms.Admin.MediaLibraries.media-libraries") }, + { "/admin/scan-settings", IdxAdminScanSettings, true, Wt::WString::tr("Lms.Admin.Database.scan-settings") }, + { "/admin/scanner", IdxAdminScanner, true, Wt::WString::tr("Lms.Admin.ScannerController.scanner") }, + { "/admin/users", IdxAdminUsers, true, Wt::WString::tr("Lms.Admin.Users.users") }, + { "/admin/user", IdxAdminUser, true, std::nullopt }, }; LMS_LOG(UI, DEBUG, "Internal path changed to '" << wApp->internalPath() << "'"); @@ -425,8 +432,10 @@ namespace UserInterface if (LmsApp->getUserType() == Database::UserType::ADMIN) { navbar->setCondition("if-is-admin", true); - navbar->bindNew("database", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/database" }, Wt::WString::tr("Lms.Admin.Database.menu-database")); - navbar->bindNew("users", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/users" }, Wt::WString::tr("Lms.Admin.Users.menu-users")); + navbar->bindNew("media-libraries", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/libraries" }, Wt::WString::tr("Lms.Admin.menu-media-libraries")); + navbar->bindNew("scan-settings", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/scan-settings" }, Wt::WString::tr("Lms.Admin.menu-scan-settings")); + navbar->bindNew("scanner", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/scanner" }, Wt::WString::tr("Lms.Admin.menu-scanner")); + navbar->bindNew("users", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/users" }, Wt::WString::tr("Lms.Admin.menu-users")); } // Contents @@ -453,7 +462,9 @@ namespace UserInterface // Admin stuff if (getUserType() == Database::UserType::ADMIN) { - mainStack->addNew(); + mainStack->addNew(); + mainStack->addNew(); + mainStack->addNew(); mainStack->addNew(); mainStack->addNew(); } diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index f9322b4f..f902829c 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -27,96 +27,97 @@ #include "database/UserId.hpp" #include "database/Types.hpp" #include "services/scanner/ScannerEvents.hpp" +#include "admin/ScannerController.hpp" #include "Notification.hpp" namespace Database { - class Db; - class Session; - class User; + class Db; + class Session; + class User; } -namespace UserInterface { - -class CoverResource; -class LmsApplicationException; -class MediaPlayer; -class PlayQueue; -class LmsApplicationManager; -class NotificationContainer; -class ModalManager; - -class LmsApplication : public Wt::WApplication +namespace UserInterface { - public: - LmsApplication(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager, std::optional userId = std::nullopt); - ~LmsApplication(); + class CoverResource; + class LmsApplicationException; + class MediaPlayer; + class PlayQueue; + class LmsApplicationManager; + class NotificationContainer; + class ModalManager; - static std::unique_ptr create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager); - static LmsApplication* instance(); + class LmsApplication : public Wt::WApplication + { + public: + LmsApplication(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager, std::optional userId = std::nullopt); + ~LmsApplication(); - // Session application data - std::shared_ptr getCoverResource() { return _coverResource; } - Database::Db& getDb(); - Database::Session& getDbSession(); // always thread safe + static std::unique_ptr create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager); + static LmsApplication* instance(); - Database::ObjectPtr getUser(); - Database::UserId getUserId(); - bool isUserAuthStrong() const; // user must be logged in prior this call - Database::UserType getUserType(); // user must be logged in prior this call - std::string getUserLoginName(); // user must be logged in prior this call + // Session application data + std::shared_ptr getCoverResource() { return _coverResource; } + Database::Db& getDb(); + Database::Session& getDbSession(); // always thread safe - // Proxified scanner events - Scanner::Events& getScannerEvents() { return _scannerEvents; } + Database::ObjectPtr getUser(); + Database::UserId getUserId(); + bool isUserAuthStrong() const; // user must be logged in prior this call + Database::UserType getUserType(); // user must be logged in prior this call + std::string getUserLoginName(); // user must be logged in prior this call - // Utils - void post(std::function func); - void setTitle(const Wt::WString& title = ""); + // Proxified scanner events + Scanner::Events& getScannerEvents() { return _scannerEvents; } - // Used to classify the message sent to the user - void notifyMsg(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration = std::chrono::milliseconds {5000}); + // Utils + void post(std::function func); + void setTitle(const Wt::WString& title = ""); - MediaPlayer& getMediaPlayer() const { return *_mediaPlayer; } - PlayQueue& getPlayQueue() const { return *_playQueue; } - ModalManager& getModalManager() const { return *_modalManager; } + // Used to classify the message sent to the user + void notifyMsg(Notification::Type type, const Wt::WString& category, const Wt::WString& message, std::chrono::milliseconds duration = std::chrono::milliseconds{ 5000 }); - // Signal emitted just before the session ends (user may already be logged out) - Wt::Signal<>& preQuit() { return _preQuit; } + MediaPlayer& getMediaPlayer() const { return *_mediaPlayer; } + PlayQueue& getPlayQueue() const { return *_playQueue; } + ModalManager& getModalManager() const { return *_modalManager; } - private: - void init(); - void processPasswordAuth(); - void handleException(LmsApplicationException& e); - void goHomeAndQuit(); + // Signal emitted just before the session ends (user may already be logged out) + Wt::Signal<>& preQuit() { return _preQuit; } - // Signal slots - void logoutUser(); - void onUserLoggedIn(); + private: + void init(); + void processPasswordAuth(); + void handleException(LmsApplicationException& e); + void goHomeAndQuit(); - void notify(const Wt::WEvent& event) override; - void finalize() override; + // Signal slots + void logoutUser(); + void onUserLoggedIn(); - void createHome(); + void notify(const Wt::WEvent& event) override; + void finalize() override; - Database::Db& _db; - Wt::Signal<> _preQuit; - LmsApplicationManager& _appManager; - Scanner::Events _scannerEvents; - struct UserAuthInfo - { - Database::UserId userId; - bool strongAuth {}; - }; - std::optional _authenticatedUser; - std::shared_ptr _coverResource; - MediaPlayer* _mediaPlayer {}; - PlayQueue* _playQueue {}; - NotificationContainer* _notificationContainer {}; - ModalManager* _modalManager {}; -}; + void createHome(); + + Database::Db& _db; + Wt::Signal<> _preQuit; + LmsApplicationManager& _appManager; + Scanner::Events _scannerEvents; + struct UserAuthInfo + { + Database::UserId userId; + bool strongAuth{}; + }; + std::optional _authenticatedUser; + std::shared_ptr _coverResource; + MediaPlayer* _mediaPlayer{}; + PlayQueue* _playQueue{}; + NotificationContainer* _notificationContainer{}; + ModalManager* _modalManager{}; + }; -// Helper to get session instance + // Helper to get session instance #define LmsApp ::UserInterface::LmsApplication::instance() } // namespace UserInterface diff --git a/src/lms/ui/PlayQueue.cpp b/src/lms/ui/PlayQueue.cpp index cea94f9e..1a7496e4 100644 --- a/src/lms/ui/PlayQueue.cpp +++ b/src/lms/ui/PlayQueue.cpp @@ -717,7 +717,6 @@ namespace UserInterface }); } - // Create TrackList Wt::WTemplateFormView* createTrackList{ contentStack->addNew(Wt::WString::tr("Lms.PlayQueue.template.save-as-tracklist.create-tracklist")) }; auto createTrackListModel{ std::make_shared() }; diff --git a/src/lms/ui/SettingsView.cpp b/src/lms/ui/SettingsView.cpp index e811859f..18c730c0 100644 --- a/src/lms/ui/SettingsView.cpp +++ b/src/lms/ui/SettingsView.cpp @@ -551,7 +551,7 @@ namespace UserInterface scrobblingBackendRaw->activated().connect([=] { updateListenBrainzTokenField();}); // Buttons - Wt::WPushButton* saveBtn{ t->bindWidget("apply-btn", std::make_unique(Wt::WString::tr("Lms.apply"))) }; + Wt::WPushButton* saveBtn{ t->bindWidget("save-btn", std::make_unique(Wt::WString::tr("Lms.save"))) }; Wt::WPushButton* discardBtn{ t->bindWidget("discard-btn", std::make_unique(Wt::WString::tr("Lms.discard"))) }; saveBtn->clicked().connect([=] diff --git a/src/lms/ui/admin/DatabaseSettingsView.cpp b/src/lms/ui/admin/DatabaseSettingsView.cpp deleted file mode 100644 index 351722dc..00000000 --- a/src/lms/ui/admin/DatabaseSettingsView.cpp +++ /dev/null @@ -1,257 +0,0 @@ -/* - * Copyright (C) 2018 Emeric Poupon - * - * This file is part of LMS. - * - * LMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LMS. If not, see . - */ - -#include "DatabaseSettingsView.hpp" - -#include -#include -#include -#include -#include -#include - -#include "database/Cluster.hpp" -#include "database/ScanSettings.hpp" -#include "database/Session.hpp" -#include "services/recommendation/IRecommendationService.hpp" -#include "services/scanner/IScannerService.hpp" -#include "utils/ILogger.hpp" -#include "utils/Service.hpp" -#include "utils/String.hpp" - -#include "common/DirectoryValidator.hpp" -#include "common/MandatoryValidator.hpp" -#include "common/UppercaseValidator.hpp" -#include "common/ValueStringModel.hpp" -#include "ScannerController.hpp" -#include "LmsApplication.hpp" - -namespace UserInterface -{ - using namespace Database; - - class DatabaseSettingsModel : public Wt::WFormModel - { - public: - // Associate each field with a unique string literal. - static inline constexpr Field MediaDirectoryField{ "media-directory" }; - static inline constexpr Field UpdatePeriodField{ "update-period" }; - static inline constexpr Field UpdateStartTimeField{ "update-start-time" }; - static inline constexpr Field SimilarityEngineTypeField{ "similarity-engine-type" }; - static inline constexpr Field ExtraTagsField{ "extra-tags-to-scan" }; - - using UpdatePeriodModel = ValueStringModel; - - static inline constexpr std::string_view extraTagsDelimiter{ ";" }; - - DatabaseSettingsModel() - { - initializeModels(); - - addField(MediaDirectoryField); - addField(UpdatePeriodField); - addField(UpdateStartTimeField); - addField(SimilarityEngineTypeField); - addField(ExtraTagsField); - - auto dirValidator{ createDirectoryValidator() }; - dirValidator->setMandatory(true); - setValidator(MediaDirectoryField, std::move(dirValidator)); - - setValidator(UpdatePeriodField, createMandatoryValidator()); - setValidator(UpdateStartTimeField, createMandatoryValidator()); - setValidator(SimilarityEngineTypeField, createMandatoryValidator()); - setValidator(ExtraTagsField, createUppercaseValidator()); - - // populate the model with initial data - loadData(); - } - - std::shared_ptr updatePeriodModel() { return _updatePeriodModel; } - std::shared_ptr updateStartTimeModel() { return _updateStartTimeModel; } - std::shared_ptr similarityEngineTypeModel() { return _similarityEngineTypeModel; } - - void loadData() - { - auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - - const ScanSettings::pointer scanSettings{ ScanSettings::get(LmsApp->getDbSession()) }; - - setValue(MediaDirectoryField, scanSettings->getMediaDirectory().string()); - - auto periodRow{ _updatePeriodModel->getRowFromValue(scanSettings->getUpdatePeriod()) }; - if (periodRow) - setValue(UpdatePeriodField, _updatePeriodModel->getString(*periodRow)); - - auto startTimeRow{ _updateStartTimeModel->getRowFromValue(scanSettings->getUpdateStartTime()) }; - if (startTimeRow) - setValue(UpdateStartTimeField, _updateStartTimeModel->getString(*startTimeRow)); - - if (scanSettings->getUpdatePeriod() == ScanSettings::UpdatePeriod::Hourly - || scanSettings->getUpdatePeriod() == ScanSettings::UpdatePeriod::Never) - { - setReadOnly(DatabaseSettingsModel::UpdateStartTimeField, true); - } - - auto similarityEngineTypeRow{ _similarityEngineTypeModel->getRowFromValue(scanSettings->getSimilarityEngineType()) }; - if (similarityEngineTypeRow) - setValue(SimilarityEngineTypeField, _similarityEngineTypeModel->getString(*similarityEngineTypeRow)); - - auto extraTags{ scanSettings->getExtraTagsToScan() }; - setValue(ExtraTagsField, StringUtils::joinStrings(scanSettings->getExtraTagsToScan(), extraTagsDelimiter)); - } - - void saveData() - { - auto transaction{ LmsApp->getDbSession().createWriteTransaction() }; - - ScanSettings::pointer scanSettings{ ScanSettings::get(LmsApp->getDbSession()) }; - - scanSettings.modify()->setMediaDirectory(valueText(MediaDirectoryField).toUTF8()); - - auto updatePeriodRow{ _updatePeriodModel->getRowFromString(valueText(UpdatePeriodField)) }; - if (updatePeriodRow) - scanSettings.modify()->setUpdatePeriod(_updatePeriodModel->getValue(*updatePeriodRow)); - - auto startTimeRow{ _updateStartTimeModel->getRowFromString(valueText(UpdateStartTimeField)) }; - if (startTimeRow) - scanSettings.modify()->setUpdateStartTime(_updateStartTimeModel->getValue(*startTimeRow)); - - auto similarityEngineTypeRow{ _similarityEngineTypeModel->getRowFromString(valueText(SimilarityEngineTypeField)) }; - if (similarityEngineTypeRow) - scanSettings.modify()->setSimilarityEngineType(_similarityEngineTypeModel->getValue(*similarityEngineTypeRow)); - - scanSettings.modify()->setExtraTagsToScan(StringUtils::splitString(valueText(ExtraTagsField).toUTF8(), extraTagsDelimiter)); - } - - private: - void initializeModels() - { - _updatePeriodModel = std::make_shared>(); - _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.never"), ScanSettings::UpdatePeriod::Never); - _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.hourly"), ScanSettings::UpdatePeriod::Hourly); - _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.daily"), ScanSettings::UpdatePeriod::Daily); - _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.weekly"), ScanSettings::UpdatePeriod::Weekly); - _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.monthly"), ScanSettings::UpdatePeriod::Monthly); - - _updateStartTimeModel = std::make_shared>(); - for (std::size_t i = 0; i < 24; ++i) - { - Wt::WTime time{ static_cast(i), 0 }; - _updateStartTimeModel->add(time.toString(), time); - } - - _similarityEngineTypeModel = std::make_shared>(); - _similarityEngineTypeModel->add(Wt::WString::tr("Lms.Admin.Database.similarity-engine-type.clusters"), ScanSettings::SimilarityEngineType::Clusters); - _similarityEngineTypeModel->add(Wt::WString::tr("Lms.Admin.Database.similarity-engine-type.none"), ScanSettings::SimilarityEngineType::None); - } - - std::shared_ptr _updatePeriodModel; - std::shared_ptr> _updateStartTimeModel; - std::shared_ptr> _similarityEngineTypeModel; - }; - - DatabaseSettingsView::DatabaseSettingsView() - { - wApp->internalPathChanged().connect(this, [this] - { - refreshView(); - }); - - refreshView(); - } - - void DatabaseSettingsView::refreshView() - { - if (!wApp->internalPathMatches("/admin/database")) - return; - - clear(); - - auto t{ addNew(Wt::WString::tr("Lms.Admin.Database.template")) }; - auto model{ std::make_shared() }; - - // Media Directory - t->setFormWidget(DatabaseSettingsModel::MediaDirectoryField, std::make_unique()); - - // Update Period - auto updatePeriod{ std::make_unique() }; - updatePeriod->setModel(model->updatePeriodModel()); - updatePeriod->activated().connect([=](int row) - { - const ScanSettings::UpdatePeriod period{ model->updatePeriodModel()->getValue(row) }; - model->setReadOnly(DatabaseSettingsModel::UpdateStartTimeField, period == ScanSettings::UpdatePeriod::Hourly || period == ScanSettings::UpdatePeriod::Never); - t->updateModel(model.get()); - t->updateView(model.get()); - }); - t->setFormWidget(DatabaseSettingsModel::UpdatePeriodField, std::move(updatePeriod)); - - // Update Start Time - auto updateStartTime{ std::make_unique() }; - updateStartTime->setModel(model->updateStartTimeModel()); - t->setFormWidget(DatabaseSettingsModel::UpdateStartTimeField, std::move(updateStartTime)); - - // Similarity engine type - auto similarityEngineType{ std::make_unique() }; - similarityEngineType->setModel(model->similarityEngineTypeModel()); - t->setFormWidget(DatabaseSettingsModel::SimilarityEngineTypeField, std::move(similarityEngineType)); - - // Clusters - t->setFormWidget(DatabaseSettingsModel::ExtraTagsField, std::make_unique()); - - // Buttons - Wt::WPushButton* saveBtn = t->bindWidget("apply-btn", std::make_unique(Wt::WString::tr("Lms.apply"))); - Wt::WPushButton* discardBtn = t->bindWidget("discard-btn", std::make_unique(Wt::WString::tr("Lms.discard"))); - Wt::WPushButton* immScanBtn = t->bindWidget("immediate-scan-btn", std::make_unique(Wt::WString::tr("Lms.Admin.Database.immediate-scan"))); - - t->bindNew("scanner-controller"); - - saveBtn->clicked().connect([=] - { - t->updateModel(model.get()); - - if (model->validate()) - { - model->saveData(); - - Service::get()->load(); - Service::get()->requestImmediateScan(false); - LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.Admin.Database.settings-saved")); - } - - // Udate the view: Delete any validation message in the view, etc. - t->updateView(model.get()); - }); - - discardBtn->clicked().connect([=] - { - model->loadData(); - model->validate(); - t->updateView(model.get()); - }); - - immScanBtn->clicked().connect([=] - { - Service::get()->requestImmediateScan(false); - }); - - t->updateView(model.get()); - } - -} // namespace UserInterface diff --git a/src/lms/ui/admin/MediaLibrariesView.cpp b/src/lms/ui/admin/MediaLibrariesView.cpp new file mode 100644 index 00000000..99cbb8a5 --- /dev/null +++ b/src/lms/ui/admin/MediaLibrariesView.cpp @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2024 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "MediaLibrariesView.hpp" + +#include + +#include "database/MediaLibrary.hpp" +#include "database/Session.hpp" +#include "services/scanner/IScannerService.hpp" +#include "utils/Service.hpp" + +#include "MediaLibraryModal.hpp" +#include "LmsApplication.hpp" +#include "ModalManager.hpp" + +namespace UserInterface +{ + MediaLibrariesView::MediaLibrariesView() + : Wt::WTemplate{ Wt::WString::tr("Lms.Admin.MediaLibraries.template") } + { + addFunction("tr", &Wt::WTemplate::Functions::tr); + + _libraries = bindNew("libraries"); + Wt::WPushButton* addBtn{ bindNew("add-btn", Wt::WString::tr("Lms.add")) }; + addBtn->clicked().connect(this, [this] + { + auto mediaLibraryModal{ std::make_unique(Database::MediaLibraryId{}) }; + MediaLibraryModal* mediaLibraryModalPtr{ mediaLibraryModal.get() }; + + mediaLibraryModalPtr->saved().connect(this, [=](Database::MediaLibraryId newMediaLibraryId) + { + Wt::WTemplate* entry{ addEntry() }; + updateEntry(newMediaLibraryId, entry); + // No need to stop the current scan if we add stuff + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.MediaLibraries.media-libraries"), Wt::WString::tr("Lms.Admin.MediaLibrary.library-created")); + LmsApp->getModalManager().dispose(mediaLibraryModalPtr); + }); + + mediaLibraryModalPtr->cancelled().connect(this, [=] + { + LmsApp->getModalManager().dispose(mediaLibraryModalPtr); + }); + + LmsApp->getModalManager().show(std::move(mediaLibraryModal)); + }); + + wApp->internalPathChanged().connect(this, [this] + { + refreshView(); + }); + + refreshView(); + + } + + void MediaLibrariesView::refreshView() + { + if (!wApp->internalPathMatches("/admin/libraries")) + return; + + _libraries->clear(); + + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; + + Database::MediaLibrary::find(LmsApp->getDbSession(), [&](const Database::MediaLibrary::pointer& mediaLibrary) + { + const Database::MediaLibraryId mediaLibraryId{ mediaLibrary->getId() }; + Wt::WTemplate* entry{ addEntry() }; + updateEntry(mediaLibraryId, entry); + }); + } + + void MediaLibrariesView::showDeleteLibraryModal(Database::MediaLibraryId mediaLibraryId, Wt::WTemplate* libraryEntry) + { + using namespace Database; + + auto modal{ std::make_unique(Wt::WString::tr("Lms.Admin.MediaLibraries.template.delete-library")) }; + modal->addFunction("tr", &Wt::WTemplate::Functions::tr); + Wt::WWidget* modalPtr{ modal.get() }; + + auto* delBtn{ modal->bindNew("del-btn", Wt::WString::tr("Lms.delete")) }; + delBtn->clicked().connect([=] + { + { + auto transaction{ LmsApp->getDbSession().createWriteTransaction() }; + + Database::MediaLibrary::pointer mediaLibrary{ MediaLibrary::find(LmsApp->getDbSession(), mediaLibraryId) }; + if (mediaLibrary) + mediaLibrary.remove(); + } + + // Don't want the scanner to go on with wrong settings + Service::get()->requestStop(); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.MediaLibraries.media-libraries"), Wt::WString::tr("Lms.Admin.MediaLibrary.library-deleted")); + + _libraries->removeWidget(libraryEntry); + + LmsApp->getModalManager().dispose(modalPtr); + }); + + auto* cancelBtn{ modal->bindNew("cancel-btn", Wt::WString::tr("Lms.cancel")) }; + cancelBtn->clicked().connect([=] + { + LmsApp->getModalManager().dispose(modalPtr); + }); + + LmsApp->getModalManager().show(std::move(modal)); + } + + Wt::WTemplate* MediaLibrariesView::addEntry() + { + return _libraries->addNew(Wt::WString::tr("Lms.Admin.MediaLibraries.template.entry")); + } + + void MediaLibrariesView::updateEntry(Database::MediaLibraryId mediaLibraryId, Wt::WTemplate* entry) + { + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; + Database::MediaLibrary::pointer mediaLibrary{ Database::MediaLibrary::find(LmsApp->getDbSession(), mediaLibraryId) }; + + entry->bindString("name", std::string{ mediaLibrary->getName() }, Wt::TextFormat::Plain); + entry->bindString("path", std::string{ mediaLibrary->getPath() }, Wt::TextFormat::Plain); + + Wt::WPushButton* editBtn{ entry->bindNew("edit-btn", Wt::WString::tr("Lms.template.edit-btn"), Wt::TextFormat::XHTML) }; + editBtn->setToolTip(Wt::WString::tr("Lms.edit")); + editBtn->clicked().connect([=] + { + auto mediaLibraryModal{ std::make_unique(mediaLibraryId) }; + MediaLibraryModal* mediaLibraryModalPtr{ mediaLibraryModal.get() }; + + mediaLibraryModalPtr->saved().connect(this, [=](Database::MediaLibraryId newMediaLibraryId) + { + updateEntry(newMediaLibraryId, entry); + + // Don't want the scanner to go on with wrong settings + Service::get()->requestStop(); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.MediaLibraries.media-libraries"), Wt::WString::tr("Lms.settings-saved")); + + LmsApp->getModalManager().dispose(mediaLibraryModalPtr); + }); + + mediaLibraryModalPtr->cancelled().connect(this, [=] + { + LmsApp->getModalManager().dispose(mediaLibraryModalPtr); + }); + + LmsApp->getModalManager().show(std::move(mediaLibraryModal)); + }); + + Wt::WPushButton* delBtn{ entry->bindNew("del-btn", Wt::WString::tr("Lms.template.trash-btn"), Wt::TextFormat::XHTML) }; + delBtn->setToolTip(Wt::WString::tr("Lms.delete")); + delBtn->clicked().connect([=] + { + showDeleteLibraryModal(mediaLibraryId, entry); + }); + } +} \ No newline at end of file diff --git a/src/lms/ui/admin/MediaLibrariesView.hpp b/src/lms/ui/admin/MediaLibrariesView.hpp new file mode 100644 index 00000000..11ef14f2 --- /dev/null +++ b/src/lms/ui/admin/MediaLibrariesView.hpp @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +#include "database/MediaLibraryId.hpp" + +namespace UserInterface +{ + class MediaLibrariesView : public Wt::WTemplate + { + public: + MediaLibrariesView(); + + private: + void refreshView(); + void showDeleteLibraryModal(Database::MediaLibraryId library, Wt::WTemplate* libraryEntry); + void updateEntry(Database::MediaLibraryId library, Wt::WTemplate* libraryEntry); + Wt::WTemplate* addEntry(); + + Wt::WContainerWidget* _libraries{}; + }; +} \ No newline at end of file diff --git a/src/lms/ui/admin/MediaLibraryModal.cpp b/src/lms/ui/admin/MediaLibraryModal.cpp new file mode 100644 index 00000000..e80d73e7 --- /dev/null +++ b/src/lms/ui/admin/MediaLibraryModal.cpp @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2024 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "MediaLibraryModal.hpp" + +#include +#include +#include +#include + +#include "database/MediaLibrary.hpp" +#include "database/Session.hpp" +#include "utils/Path.hpp" +#include "utils/String.hpp" +#include "LmsApplication.hpp" + +namespace UserInterface +{ + using namespace Database; + + namespace + { + class LibraryNameValidator : public Wt::WValidator + { + public: + LibraryNameValidator(MediaLibraryId libraryId) : _libraryId{ libraryId } {} + + private: + Wt::WValidator::Result validate(const Wt::WString& input) const override + { + if (input.empty()) + return Wt::WValidator::validate(input); + + Wt::WValidator::Result result{ Wt::ValidationState::Valid }; + const std::string name{ input.toUTF8() }; + + auto& session{ LmsApp->getDbSession() }; + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; + + Database::MediaLibrary::find(session, [&](const Database::MediaLibrary::pointer library) + { + if (library->getId() == _libraryId) + return; + + if (StringUtils::stringCaseInsensitiveEqual(name, library->getName())) + result = Wt::WValidator::Result{ Wt::ValidationState::Invalid, Wt::WString::tr("Lms.Admin.MediaLibrary.name-already-exists") }; + }); + + return result; + } + + const MediaLibraryId _libraryId; + }; + + class LibraryRootPathValidator : public Wt::WValidator + { + public: + LibraryRootPathValidator(MediaLibraryId libraryId) : _libraryId{ libraryId } {} + + private: + Wt::WValidator::Result validate(const Wt::WString& input) const override + { + if (input.empty()) + return Wt::WValidator::validate(input); + + const std::filesystem::path p{ input.toUTF8() }; + std::error_code ec; + + if (p.is_relative()) + return Wt::WValidator::Result(Wt::ValidationState::Invalid, Wt::WString::tr("Lms.Admin.MediaLibrary.path-must-be-absolute")); + + // TODO check and translate rights issues + bool res{ std::filesystem::is_directory(p, ec) }; + if (ec) + return Wt::WValidator::Result(Wt::ValidationState::Invalid, ec.message()); // TODO translate common errors + else if (!res) + return Wt::WValidator::Result(Wt::ValidationState::Invalid, Wt::WString::tr("Lms.Admin.MediaLibrary.path-must-be-existing-directory")); + + auto& session{ LmsApp->getDbSession() }; + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; + + Wt::WValidator::Result result{ Wt::ValidationState::Valid }; + const std::filesystem::path rootPath{ std::filesystem::path{input.toUTF8()}.lexically_normal() }; + Database::MediaLibrary::find(session, [&](const Database::MediaLibrary::pointer library) + { + if (library->getId() == _libraryId) + return; + + const std::filesystem::path libraryRootPath{ library->getPath().lexically_normal() }; + + if (PathUtils::isPathInRootPath(rootPath, libraryRootPath) + || PathUtils::isPathInRootPath(libraryRootPath, rootPath)) + { + result = Wt::WValidator::Result{ Wt::ValidationState::Invalid, Wt::WString::tr("Lms.Admin.MediaLibrary.path-must-not-overlap") }; + } + }); + + return result; + } + + const MediaLibraryId _libraryId; + }; + + class MediaLibraryModel : public Wt::WFormModel + { + public: + static inline constexpr Field NameField{ "name" }; + static inline constexpr Field DirectoryField{ "directory" }; + + MediaLibraryModel(MediaLibraryId libraryId) + : _libraryId{ libraryId } + { + addField(NameField); + addField(DirectoryField); + + { + auto nameValidator{ std::make_shared(libraryId) }; + nameValidator->setMandatory(true); + setValidator(NameField, std::move(nameValidator)); + } + + { + auto directoryValidator{ std::make_shared(libraryId) }; + directoryValidator->setMandatory(true); + setValidator(DirectoryField, std::move(directoryValidator)); + } + + if (libraryId.isValid()) + loadData(); + } + + MediaLibraryId saveData() + { + auto& session{ LmsApp->getDbSession() }; + auto transaction{ LmsApp->getDbSession().createWriteTransaction() }; + + MediaLibrary::pointer library; + if (_libraryId.isValid()) + library = MediaLibrary::find(session, _libraryId); + else + library = session.create(); + + library.modify()->setName(valueText(NameField).toUTF8()); + library.modify()->setPath(valueText(DirectoryField).toUTF8()); + + return library->getId(); + } + + private: + void loadData() + { + auto& session{ LmsApp->getDbSession() }; + auto transaction{ session.createReadTransaction() }; + + const MediaLibrary::pointer library{ MediaLibrary::find(session, _libraryId) }; + + setValue(NameField, std::string{ library->getName() }); + setValue(DirectoryField, library->getPath().string()); + } + + const MediaLibraryId _libraryId; + }; + } + + MediaLibraryModal::MediaLibraryModal(MediaLibraryId mediaLibraryId) + : Wt::WTemplateFormView{ Wt::WString::tr("Lms.Admin.MediaLibrary.template") } + { + auto model{ std::make_shared(mediaLibraryId) }; + + bindString("title", Wt::WString::tr(mediaLibraryId.isValid() ? "Lms.Admin.MediaLibrary.edit-library" : "Lms.Admin.MediaLibrary.create-library")); + + setFormWidget(MediaLibraryModel::NameField, std::make_unique()); + setFormWidget(MediaLibraryModel::DirectoryField, std::make_unique()); + + Wt::WPushButton* saveBtn{ bindNew("save-btn", Wt::WString::tr(mediaLibraryId.isValid() ? "Lms.save" : "Lms.create")) }; + saveBtn->clicked().connect(this, [=] + { + updateModel(model.get()); + + if (model->validate()) + { + Database::MediaLibraryId mediaLibraryId{ model->saveData() }; + saved().emit(mediaLibraryId); + } + else + { + updateView(model.get()); + } + }); + + Wt::WPushButton* cancelBtn{ bindNew("cancel-btn", Wt::WString::tr("Lms.cancel")) }; + cancelBtn->clicked().connect(this, [=] {cancelled().emit();}); + + updateView(model.get()); + } +} diff --git a/src/lms/ui/admin/MediaLibraryModal.hpp b/src/lms/ui/admin/MediaLibraryModal.hpp new file mode 100644 index 00000000..4bd3a8f6 --- /dev/null +++ b/src/lms/ui/admin/MediaLibraryModal.hpp @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include "database/MediaLibraryId.hpp" + +namespace UserInterface +{ + class MediaLibraryModal : public Wt::WTemplateFormView + { + public: + MediaLibraryModal(Database::MediaLibraryId mediaLibaryId); + + Wt::Signal& saved() { return _saved; }; + Wt::Signal<>& cancelled() { return _cancelled; } + + private: + Wt::Signal _saved; + Wt::Signal<> _cancelled; + }; +} \ No newline at end of file diff --git a/src/lms/ui/admin/ScanSettingsView.cpp b/src/lms/ui/admin/ScanSettingsView.cpp new file mode 100644 index 00000000..8e050f45 --- /dev/null +++ b/src/lms/ui/admin/ScanSettingsView.cpp @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2018 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "ScanSettingsView.hpp" + +#include +#include +#include +#include +#include +#include + +#include "database/ScanSettings.hpp" +#include "database/Session.hpp" +#include "services/recommendation/IRecommendationService.hpp" +#include "services/scanner/IScannerService.hpp" +#include "utils/ILogger.hpp" +#include "utils/Service.hpp" +#include "utils/String.hpp" + +#include "common/MandatoryValidator.hpp" +#include "common/UppercaseValidator.hpp" +#include "common/ValueStringModel.hpp" +#include "LmsApplication.hpp" + +namespace UserInterface +{ + using namespace Database; + + namespace + { + class DatabaseSettingsModel : public Wt::WFormModel + { + public: + static inline constexpr Field UpdatePeriodField{ "update-period" }; + static inline constexpr Field UpdateStartTimeField{ "update-start-time" }; + static inline constexpr Field SimilarityEngineTypeField{ "similarity-engine-type" }; + static inline constexpr Field ExtraTagsField{ "extra-tags-to-scan" }; + + using UpdatePeriodModel = ValueStringModel; + + static inline constexpr std::string_view extraTagsDelimiter{ ";" }; + + DatabaseSettingsModel() + { + initializeModels(); + + addField(UpdatePeriodField); + addField(UpdateStartTimeField); + addField(SimilarityEngineTypeField); + addField(ExtraTagsField); + + setValidator(UpdatePeriodField, createMandatoryValidator()); + setValidator(UpdateStartTimeField, createMandatoryValidator()); + setValidator(SimilarityEngineTypeField, createMandatoryValidator()); + setValidator(ExtraTagsField, createUppercaseValidator()); + + // populate the model with initial data + loadData(); + } + + std::shared_ptr updatePeriodModel() { return _updatePeriodModel; } + std::shared_ptr updateStartTimeModel() { return _updateStartTimeModel; } + std::shared_ptr similarityEngineTypeModel() { return _similarityEngineTypeModel; } + + void loadData() + { + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; + + const ScanSettings::pointer scanSettings{ ScanSettings::get(LmsApp->getDbSession()) }; + + auto periodRow{ _updatePeriodModel->getRowFromValue(scanSettings->getUpdatePeriod()) }; + if (periodRow) + setValue(UpdatePeriodField, _updatePeriodModel->getString(*periodRow)); + + auto startTimeRow{ _updateStartTimeModel->getRowFromValue(scanSettings->getUpdateStartTime()) }; + if (startTimeRow) + setValue(UpdateStartTimeField, _updateStartTimeModel->getString(*startTimeRow)); + + if (scanSettings->getUpdatePeriod() == ScanSettings::UpdatePeriod::Hourly + || scanSettings->getUpdatePeriod() == ScanSettings::UpdatePeriod::Never) + { + setReadOnly(DatabaseSettingsModel::UpdateStartTimeField, true); + } + + auto similarityEngineTypeRow{ _similarityEngineTypeModel->getRowFromValue(scanSettings->getSimilarityEngineType()) }; + if (similarityEngineTypeRow) + setValue(SimilarityEngineTypeField, _similarityEngineTypeModel->getString(*similarityEngineTypeRow)); + + auto extraTags{ scanSettings->getExtraTagsToScan() }; + setValue(ExtraTagsField, StringUtils::joinStrings(scanSettings->getExtraTagsToScan(), extraTagsDelimiter)); + } + + void saveData() + { + auto transaction{ LmsApp->getDbSession().createWriteTransaction() }; + + ScanSettings::pointer scanSettings{ ScanSettings::get(LmsApp->getDbSession()) }; + + auto updatePeriodRow{ _updatePeriodModel->getRowFromString(valueText(UpdatePeriodField)) }; + if (updatePeriodRow) + scanSettings.modify()->setUpdatePeriod(_updatePeriodModel->getValue(*updatePeriodRow)); + + auto startTimeRow{ _updateStartTimeModel->getRowFromString(valueText(UpdateStartTimeField)) }; + if (startTimeRow) + scanSettings.modify()->setUpdateStartTime(_updateStartTimeModel->getValue(*startTimeRow)); + + auto similarityEngineTypeRow{ _similarityEngineTypeModel->getRowFromString(valueText(SimilarityEngineTypeField)) }; + if (similarityEngineTypeRow) + scanSettings.modify()->setSimilarityEngineType(_similarityEngineTypeModel->getValue(*similarityEngineTypeRow)); + + scanSettings.modify()->setExtraTagsToScan(StringUtils::splitString(valueText(ExtraTagsField).toUTF8(), extraTagsDelimiter)); + } + + private: + void initializeModels() + { + _updatePeriodModel = std::make_shared>(); + _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.never"), ScanSettings::UpdatePeriod::Never); + _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.hourly"), ScanSettings::UpdatePeriod::Hourly); + _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.daily"), ScanSettings::UpdatePeriod::Daily); + _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.weekly"), ScanSettings::UpdatePeriod::Weekly); + _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.monthly"), ScanSettings::UpdatePeriod::Monthly); + + _updateStartTimeModel = std::make_shared>(); + for (std::size_t i = 0; i < 24; ++i) + { + Wt::WTime time{ static_cast(i), 0 }; + _updateStartTimeModel->add(time.toString(), time); + } + + _similarityEngineTypeModel = std::make_shared>(); + _similarityEngineTypeModel->add(Wt::WString::tr("Lms.Admin.Database.similarity-engine-type.clusters"), ScanSettings::SimilarityEngineType::Clusters); + _similarityEngineTypeModel->add(Wt::WString::tr("Lms.Admin.Database.similarity-engine-type.none"), ScanSettings::SimilarityEngineType::None); + } + + std::shared_ptr _updatePeriodModel; + std::shared_ptr> _updateStartTimeModel; + std::shared_ptr> _similarityEngineTypeModel; + }; + } + + ScanSettingsView::ScanSettingsView() + { + wApp->internalPathChanged().connect(this, [this]() + { + refreshView(); + }); + + refreshView(); + } + + void ScanSettingsView::refreshView() + { + if (!wApp->internalPathMatches("/admin/scan-settings")) + return; + + clear(); + + auto t{ addNew(Wt::WString::tr("Lms.Admin.Database.template")) }; + auto model{ std::make_shared() }; + + // Update Period + auto updatePeriod{ std::make_unique() }; + updatePeriod->setModel(model->updatePeriodModel()); + updatePeriod->activated().connect([=](int row) + { + const ScanSettings::UpdatePeriod period{ model->updatePeriodModel()->getValue(row) }; + model->setReadOnly(DatabaseSettingsModel::UpdateStartTimeField, period == ScanSettings::UpdatePeriod::Hourly || period == ScanSettings::UpdatePeriod::Never); + t->updateModel(model.get()); + t->updateView(model.get()); + }); + t->setFormWidget(DatabaseSettingsModel::UpdatePeriodField, std::move(updatePeriod)); + + // Update Start Time + auto updateStartTime{ std::make_unique() }; + updateStartTime->setModel(model->updateStartTimeModel()); + t->setFormWidget(DatabaseSettingsModel::UpdateStartTimeField, std::move(updateStartTime)); + + // Similarity engine type + auto similarityEngineType{ std::make_unique() }; + similarityEngineType->setModel(model->similarityEngineTypeModel()); + t->setFormWidget(DatabaseSettingsModel::SimilarityEngineTypeField, std::move(similarityEngineType)); + + // Extra tags + t->setFormWidget(DatabaseSettingsModel::ExtraTagsField, std::make_unique()); + + // Buttons + Wt::WPushButton* saveBtn = t->bindWidget("save-btn", std::make_unique(Wt::WString::tr("Lms.save"))); + Wt::WPushButton* discardBtn = t->bindWidget("discard-btn", std::make_unique(Wt::WString::tr("Lms.discard"))); + + saveBtn->clicked().connect([=] + { + t->updateModel(model.get()); + + if (model->validate()) + { + model->saveData(); + + Service::get()->load(); + // Don't want the scanner to go on with wrong settings + Service::get()->requestStop(); + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.settings-saved")); + } + + // Udate the view: Delete any validation message in the view, etc. + t->updateView(model.get()); + }); + + discardBtn->clicked().connect([=] + { + model->loadData(); + model->validate(); + t->updateView(model.get()); + }); + + t->updateView(model.get()); + } + +} // namespace UserInterface diff --git a/src/lms/ui/admin/DatabaseSettingsView.hpp b/src/lms/ui/admin/ScanSettingsView.hpp similarity index 81% rename from src/lms/ui/admin/DatabaseSettingsView.hpp rename to src/lms/ui/admin/ScanSettingsView.hpp index 116309aa..f3cae452 100644 --- a/src/lms/ui/admin/DatabaseSettingsView.hpp +++ b/src/lms/ui/admin/ScanSettingsView.hpp @@ -21,17 +21,14 @@ #include -namespace UserInterface { - -class DatabaseSettingsView : public Wt::WContainerWidget +namespace UserInterface { - public: - DatabaseSettingsView(); - - private: - void refreshView(); -}; - + class ScanSettingsView : public Wt::WContainerWidget + { + public: + ScanSettingsView(); + private: + void refreshView(); + }; } // namespace UserInterface - diff --git a/src/lms/ui/admin/ScannerController.cpp b/src/lms/ui/admin/ScannerController.cpp index c3a62715..f6b491fb 100644 --- a/src/lms/ui/admin/ScannerController.cpp +++ b/src/lms/ui/admin/ScannerController.cpp @@ -32,252 +32,250 @@ #include "utils/Service.hpp" #include "LmsApplication.hpp" -namespace UserInterface { - -static -std::string -durationToString(const Wt::WDateTime& begin, const Wt::WDateTime& end) +namespace UserInterface { - const auto secs {std::chrono::duration_cast(end.toTimePoint() - begin.toTimePoint()).count()}; + namespace + { + std::string durationToString(const Wt::WDateTime& begin, const Wt::WDateTime& end) + { + const auto secs{ std::chrono::duration_cast(end.toTimePoint() - begin.toTimePoint()).count() }; - std::ostringstream oss; + std::ostringstream oss; - if (secs >= 3600) - oss << secs/3600 << "h"; - if (secs >= 60) - oss << std::setw(2) << std::setfill('0') << (secs % 3600) / 60 << "m"; - oss << std::setw(2) << std::setfill('0') << (secs % 60) << "s"; + if (secs >= 3600) + oss << secs / 3600 << "h"; + if (secs >= 60) + oss << std::setw(2) << std::setfill('0') << (secs % 3600) / 60 << "m"; + oss << std::setw(2) << std::setfill('0') << (secs % 60) << "s"; - return oss.str(); -} + return oss.str(); + } + } + class ReportResource : public Wt::WResource + { + public: + ReportResource() + { + suggestFileName("report.txt"); + } -class ReportResource : public Wt::WResource -{ - public: - ReportResource() - { - suggestFileName("report.txt"); - } + ~ReportResource() + { + beingDeleted(); + } - ~ReportResource() - { - beingDeleted(); - } + void setScanStats(const Scanner::ScanStats& stats) + { + if (!_stats) + _stats = std::make_unique(); - void setScanStats(const Scanner::ScanStats& stats) - { - if (!_stats) - _stats = std::make_unique(); + *_stats = stats; + } - *_stats = stats; - } + void handleRequest(const Wt::Http::Request&, Wt::Http::Response& response) + { + if (!_stats) + return; - void handleRequest(const Wt::Http::Request&, Wt::Http::Response& response) - { - if (!_stats) - return; + response.out() << Wt::WString::tr("Lms.Admin.ScannerController.errors-header").arg(_stats->errors.size()).toUTF8() << std::endl; - response.out() << Wt::WString::tr("Lms.Admin.ScannerController.errors-header").arg(_stats->errors.size()).toUTF8() << std::endl; + for (const auto& error : _stats->errors) + { + response.out() << error.file.string() << " - " << errorTypeToWString(error.error).toUTF8(); + if (!error.systemError.empty()) + response.out() << ": " << error.systemError; + response.out() << std::endl; + } - for (const auto& error : _stats->errors) - { - response.out() << error.file.string() << " - " << errorTypeToWString(error.error).toUTF8(); - if (!error.systemError.empty()) - response.out() << ": " << error.systemError; - response.out() << std::endl; - } + response.out() << std::endl; - response.out() << std::endl; + response.out() << Wt::WString::tr("Lms.Admin.ScannerController.duplicates-header").arg(_stats->duplicates.size()).toUTF8() << std::endl; - response.out() << Wt::WString::tr("Lms.Admin.ScannerController.duplicates-header").arg(_stats->duplicates.size()).toUTF8() << std::endl; + { + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - { - auto transaction {LmsApp->getDbSession().createReadTransaction()}; + for (const auto& duplicate : _stats->duplicates) + { + const auto& track{ Database::Track::find(LmsApp->getDbSession(), duplicate.trackId) }; + if (!track) + continue; - for (const auto& duplicate : _stats->duplicates) - { - const auto& track {Database::Track::find(LmsApp->getDbSession(), duplicate.trackId)}; - if (!track) - continue; + response.out() << track->getPath().string(); + if (auto mbid{ track->getTrackMBID() }) + response.out() << " (Track MBID " << mbid->getAsString() << ")"; - response.out() << track->getPath().string(); - if (auto mbid {track->getTrackMBID()}) - response.out() << " (Track MBID " << mbid->getAsString() << ")"; + response.out() << " - " << duplicateReasonToWString(duplicate.reason).toUTF8() << '\n'; + } + } + } - response.out() << " - " << duplicateReasonToWString(duplicate.reason).toUTF8() << '\n'; - } - } - } + private: + static Wt::WString errorTypeToWString(Scanner::ScanErrorType error) + { + switch (error) + { + case Scanner::ScanErrorType::CannotReadFile: return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-file"); + case Scanner::ScanErrorType::CannotParseFile: return Wt::WString::tr("Lms.Admin.ScannerController.cannot-parse-file"); + case Scanner::ScanErrorType::NoAudioTrack: return Wt::WString::tr("Lms.Admin.ScannerController.no-audio-track"); + case Scanner::ScanErrorType::BadDuration: return Wt::WString::tr("Lms.Admin.ScannerController.bad-duration"); + } + return "?"; + } - private: + static Wt::WString duplicateReasonToWString(Scanner::DuplicateReason reason) + { + switch (reason) + { + case Scanner::DuplicateReason::SameHash: return Wt::WString::tr("Lms.Admin.ScannerController.same-hash"); + case Scanner::DuplicateReason::SameTrackMBID: return Wt::WString::tr("Lms.Admin.ScannerController.same-mbid"); + } + return "?"; + } - static Wt::WString errorTypeToWString(Scanner::ScanErrorType error) - { - switch (error) - { - case Scanner::ScanErrorType::CannotReadFile: return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-file"); - case Scanner::ScanErrorType::CannotParseFile: return Wt::WString::tr("Lms.Admin.ScannerController.cannot-parse-file"); - case Scanner::ScanErrorType::NoAudioTrack: return Wt::WString::tr("Lms.Admin.ScannerController.no-audio-track"); - case Scanner::ScanErrorType::BadDuration: return Wt::WString::tr("Lms.Admin.ScannerController.bad-duration"); - } - return "?"; - } + std::unique_ptr _stats; + }; - static Wt::WString duplicateReasonToWString(Scanner::DuplicateReason reason) - { - switch (reason) - { - case Scanner::DuplicateReason::SameHash: return Wt::WString::tr("Lms.Admin.ScannerController.same-hash"); - case Scanner::DuplicateReason::SameTrackMBID: return Wt::WString::tr("Lms.Admin.ScannerController.same-mbid"); - } - return "?"; - } + ScannerController::ScannerController() + : WTemplate{ Wt::WString::tr("Lms.Admin.ScannerController.template") } + { + addFunction("tr", &Wt::WTemplate::Functions::tr); + addFunction("id", &Wt::WTemplate::Functions::id); - std::unique_ptr _stats; -}; + using namespace Scanner; + { + _reportBtn = bindNew("report-btn", Wt::WString::tr("Lms.Admin.ScannerController.get-report")); -ScannerController::ScannerController() -: WTemplate {Wt::WString::tr("Lms.Admin.ScannerController.template")} -{ - addFunction("tr", &Wt::WTemplate::Functions::tr); - addFunction("id", &Wt::WTemplate::Functions::id); + auto reportResource{ std::make_shared() }; + reportResource->setTakesUpdateLock(true); + _reportResource = reportResource.get(); - using namespace Scanner; + Wt::WLink link{ reportResource }; + link.setTarget(Wt::LinkTarget::NewWindow); + _reportBtn->setLink(link); + } - { - _reportBtn = bindNew("report-btn", Wt::WString::tr("Lms.Admin.ScannerController.get-report")); + Wt::WPushButton* scanBtn{ bindNew("scan-btn", Wt::WString::tr("Lms.Admin.ScannerController.scan-now")) }; + scanBtn->clicked().connect([] + { + Service::get()->requestImmediateScan(false); + }); - auto reportResource {std::make_shared()}; - reportResource->setTakesUpdateLock(true); - _reportResource = reportResource.get(); + Wt::WPushButton* fullScanBtn{ bindNew("full-scan-btn", Wt::WString::tr("Lms.Admin.ScannerController.force-scan-now")) }; + fullScanBtn->clicked().connect([] + { + Service::get()->requestImmediateScan(true); + }); - Wt::WLink link {reportResource}; - link.setTarget(Wt::LinkTarget::NewWindow); - _reportBtn->setLink(link); - } + _lastScanStatus = bindNew("last-scan"); + _lastScanStatus->setReadOnly(true); - Wt::WPushButton* scanBtn {bindNew("scan-btn", Wt::WString::tr("Lms.Admin.ScannerController.scan-now"))}; - scanBtn->clicked().connect([] - { - Service::get()->requestImmediateScan(false); - }); + _status = bindNew("status"); + _status->setReadOnly(true); - Wt::WPushButton* fullScanBtn {bindNew("full-scan-btn", Wt::WString::tr("Lms.Admin.ScannerController.force-scan-now"))}; - fullScanBtn->clicked().connect([] - { - Service::get()->requestImmediateScan(true); - }); + _stepStatus = bindNew("step-status"); + _stepStatus->setReadOnly(true); - _lastScanStatus = bindNew("last-scan"); - _lastScanStatus->setReadOnly(true); + auto onDbEvent{ [&]() { refreshContents(); } }; - _status = bindNew("status"); - _status->setReadOnly(true); + LmsApp->getScannerEvents().scanAborted.connect(this, [] + { + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.Admin.Database.scan-aborted")); + }); + LmsApp->getScannerEvents().scanStarted.connect(this, [] + { + LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.Admin.Database.scan-launched")); + }); + LmsApp->getScannerEvents().scanComplete.connect(this, onDbEvent); + LmsApp->getScannerEvents().scanInProgress.connect(this, onDbEvent); + LmsApp->getScannerEvents().scanScheduled.connect(this, onDbEvent); - _stepStatus = bindNew("step-status"); - _stepStatus->setReadOnly(true); + refreshContents(); + } - auto onDbEvent {[&]() { refreshContents(); }}; + void ScannerController::refreshContents() + { + using namespace Scanner; - LmsApp->getScannerEvents().scanStarted.connect(this, [] - { - LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.Database.database"), Wt::WString::tr("Lms.Admin.Database.scan-launched")); - }); - LmsApp->getScannerEvents().scanComplete.connect(this, onDbEvent); - LmsApp->getScannerEvents().scanInProgress.connect(this, onDbEvent); - LmsApp->getScannerEvents().scanScheduled.connect(this, onDbEvent); + const IScannerService::Status status{ Service::get()->getStatus() }; + if (status.lastCompleteScanStats) + { + _lastScanStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.last-scan-status") + .arg(status.lastCompleteScanStats->nbFiles()) + .arg(durationToString(status.lastCompleteScanStats->startTime, status.lastCompleteScanStats->stopTime)) + .arg(status.lastCompleteScanStats->stopTime.toString()) + .arg(status.lastCompleteScanStats->errors.size()) + .arg(status.lastCompleteScanStats->duplicates.size()) + ); - refreshContents(); -} + _reportResource->setScanStats(*status.lastCompleteScanStats); + _reportBtn->setEnabled(true); -void -ScannerController::refreshContents() -{ - using namespace Scanner; + } + else + { + _lastScanStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.last-scan-not-available")); + _reportBtn->setEnabled(false); + } - const IScannerService::Status status {Service::get()->getStatus()}; - if (status.lastCompleteScanStats) - { - _lastScanStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.last-scan-status") - .arg(status.lastCompleteScanStats->nbFiles()) - .arg(durationToString(status.lastCompleteScanStats->startTime, status.lastCompleteScanStats->stopTime)) - .arg(status.lastCompleteScanStats->stopTime.toString()) - .arg(status.lastCompleteScanStats->errors.size()) - .arg(status.lastCompleteScanStats->duplicates.size()) - ); + switch (status.currentState) + { + case IScannerService::State::NotScheduled: + _status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-not-scheduled")); + _stepStatus->setText(""); + break; + case IScannerService::State::Scheduled: + _status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled") + .arg(status.nextScheduledScan.toString())); + _stepStatus->setText(""); + break; + case IScannerService::State::InProgress: + _status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-in-progress") + .arg(static_cast(status.currentScanStepStats->currentStep) + 1) + .arg(Scanner::ScanProgressStepCount)); - _reportResource->setScanStats(*status.lastCompleteScanStats); - _reportBtn->setEnabled(true); + switch (status.currentScanStepStats->currentStep) + { + case Scanner::ScanStep::CheckingForDuplicateFiles: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-checking-for-duplicate-files") + .arg(status.currentScanStepStats->processedElems)); + break; - } - else - { - _lastScanStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.last-scan-not-available")); - _reportBtn->setEnabled(false); - } + case Scanner::ScanStep::ChekingForMissingFiles: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-checking-for-missing-files") + .arg(status.currentScanStepStats->progress())); + break; + case Scanner::ScanStep::DiscoveringFiles: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-discovering-files") + .arg(status.currentScanStepStats->processedElems)); + break; - switch (status.currentState) - { - case IScannerService::State::NotScheduled: - _status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-not-scheduled")); - _stepStatus->setText(""); - break; - case IScannerService::State::Scheduled: - _status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled") - .arg(status.nextScheduledScan.toString())); - _stepStatus->setText(""); - break; - case IScannerService::State::InProgress: - _status->setText(Wt::WString::tr("Lms.Admin.ScannerController.status-in-progress") - .arg(static_cast(status.currentScanStepStats->currentStep) + 1) - .arg(Scanner::ScanProgressStepCount)); + case Scanner::ScanStep::ScanningFiles: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-scanning-files") + .arg(status.currentScanStepStats->processedElems) + .arg(status.currentScanStepStats->totalElems) + .arg(status.currentScanStepStats->progress())); + break; - switch (status.currentScanStepStats->currentStep) - { - case Scanner::ScanStep::CheckingForDuplicateFiles: - _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-checking-for-duplicate-files") - .arg(status.currentScanStepStats->processedElems)); - break; + case Scanner::ScanStep::FetchingTrackFeatures: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-fetching-track-features") + .arg(status.currentScanStepStats->processedElems) + .arg(status.currentScanStepStats->totalElems) + .arg(status.currentScanStepStats->progress())); + break; - case Scanner::ScanStep::ChekingForMissingFiles: - _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-checking-for-missing-files") - .arg(status.currentScanStepStats->progress())); - break; - - case Scanner::ScanStep::DiscoveringFiles: - _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-discovering-files") - .arg(status.currentScanStepStats->processedElems)); - break; - - case Scanner::ScanStep::ScanningFiles: - _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-scanning-files") - .arg(status.currentScanStepStats->processedElems) - .arg(status.currentScanStepStats->totalElems) - .arg(status.currentScanStepStats->progress())); - break; - - case Scanner::ScanStep::FetchingTrackFeatures: - _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-fetching-track-features") - .arg(status.currentScanStepStats->processedElems) - .arg(status.currentScanStepStats->totalElems) - .arg(status.currentScanStepStats->progress())); - break; - - case Scanner::ScanStep::ReloadingSimilarityEngine: - _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-reloading-similarity-engine") - .arg(status.currentScanStepStats->progress())); - break; - - case Scanner::ScanStep::ComputeClusterStats: - _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-compute-cluster-stats") - .arg(status.currentScanStepStats->progress())); - } - break; - } -} + case Scanner::ScanStep::ReloadingSimilarityEngine: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-reloading-similarity-engine") + .arg(status.currentScanStepStats->progress())); + break; + case Scanner::ScanStep::ComputeClusterStats: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-compute-cluster-stats") + .arg(status.currentScanStepStats->progress())); + } + break; + } + } } // namespace UserInterface - diff --git a/src/lms/ui/admin/ScannerController.hpp b/src/lms/ui/admin/ScannerController.hpp index 7b1418fe..e378ba89 100644 --- a/src/lms/ui/admin/ScannerController.hpp +++ b/src/lms/ui/admin/ScannerController.hpp @@ -25,21 +25,18 @@ namespace UserInterface { + class ScannerController : public Wt::WTemplate + { + public: + ScannerController(); - class ScannerController : public Wt::WTemplate - { - public: - ScannerController(); - - private: - void refreshContents(); - - Wt::WPushButton* _reportBtn; - Wt::WLineEdit* _lastScanStatus; - Wt::WLineEdit* _status; - Wt::WLineEdit* _stepStatus; - class ReportResource* _reportResource; - }; + private: + void refreshContents(); + Wt::WPushButton* _reportBtn; + Wt::WLineEdit* _lastScanStatus; + Wt::WLineEdit* _status; + Wt::WLineEdit* _stepStatus; + class ReportResource* _reportResource; + }; } // namespace DatabaseStatus - diff --git a/src/lms/ui/admin/UserView.cpp b/src/lms/ui/admin/UserView.cpp index a8fbdbc8..cf65b08d 100644 --- a/src/lms/ui/admin/UserView.cpp +++ b/src/lms/ui/admin/UserView.cpp @@ -41,236 +41,233 @@ #include "LmsApplication.hpp" #include "LmsApplicationException.hpp" -namespace UserInterface { - -using namespace Database; - -class UserModel : public Wt::WFormModel +namespace UserInterface { - public: - static inline const Field LoginField {"login"}; - static inline const Field PasswordField {"password"}; - static inline const Field DemoField {"demo"}; + using namespace Database; - UserModel(std::optional userId, ::Auth::IPasswordService* authPasswordService) - : _userId {userId} - , _authPasswordService {authPasswordService} - { - if (!_userId) - { - addField(LoginField); - setValidator(LoginField, createLoginNameValidator()); - } + class UserModel : public Wt::WFormModel + { + public: + static inline const Field LoginField{ "login" }; + static inline const Field PasswordField{ "password" }; + static inline const Field DemoField{ "demo" }; - if (authPasswordService) - { - addField(PasswordField); - setValidator(PasswordField, createPasswordStrengthValidator([this] { return ::Auth::PasswordValidationContext {getLoginName(), getUserType()}; })); - if (!userId) - validator(PasswordField)->setMandatory(true); - } - addField(DemoField); + UserModel(std::optional userId, ::Auth::IPasswordService* authPasswordService) + : _userId{ userId } + , _authPasswordService{ authPasswordService } + { + if (!_userId) + { + addField(LoginField); + setValidator(LoginField, createLoginNameValidator()); + } - loadData(); - } + if (authPasswordService) + { + addField(PasswordField); + setValidator(PasswordField, createPasswordStrengthValidator([this] { return ::Auth::PasswordValidationContext{ getLoginName(), getUserType() }; })); + if (!userId) + validator(PasswordField)->setMandatory(true); + } + addField(DemoField); - void saveData() - { - auto transaction {LmsApp->getDbSession().createWriteTransaction()}; + loadData(); + } - if (_userId) - { - // Update user - User::pointer user {User::find(LmsApp->getDbSession(), *_userId)}; - if (!user) - throw UserNotFoundException {}; + void saveData() + { + auto transaction{ LmsApp->getDbSession().createWriteTransaction() }; - if (_authPasswordService && !valueText(PasswordField).empty()) - _authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8()); - } - else - { - // Check races with other endpoints (subsonic API...) - User::pointer user {User::find(LmsApp->getDbSession(), valueText(LoginField).toUTF8())}; - if (user) - throw UserNotAllowedException {}; + if (_userId) + { + // Update user + User::pointer user{ User::find(LmsApp->getDbSession(), *_userId) }; + if (!user) + throw UserNotFoundException{}; - // Create user - user = LmsApp->getDbSession().create(valueText(LoginField).toUTF8()); + if (_authPasswordService && !valueText(PasswordField).empty()) + _authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8()); + } + else + { + // Check races with other endpoints (subsonic API...) + User::pointer user{ User::find(LmsApp->getDbSession(), valueText(LoginField).toUTF8()) }; + if (user) + throw UserNotAllowedException{}; - if (Wt::asNumber(value(DemoField))) - user.modify()->setType(UserType::DEMO); + // Create user + user = LmsApp->getDbSession().create(valueText(LoginField).toUTF8()); - if (_authPasswordService) - _authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8()); - } - } + if (Wt::asNumber(value(DemoField))) + user.modify()->setType(UserType::DEMO); - private: - void loadData() - { - if (!_userId) - return; + if (_authPasswordService) + _authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8()); + } + } - auto transaction {LmsApp->getDbSession().createReadTransaction()}; + private: + void loadData() + { + if (!_userId) + return; - const User::pointer user {User::find(LmsApp->getDbSession(), *_userId)}; - if (!user) - throw UserNotFoundException {}; - else if (user == LmsApp->getUser()) - throw UserNotAllowedException {}; - } + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - UserType getUserType() const - { - if (_userId) - { - auto transaction {LmsApp->getDbSession().createReadTransaction()}; + const User::pointer user{ User::find(LmsApp->getDbSession(), *_userId) }; + if (!user) + throw UserNotFoundException{}; + else if (user == LmsApp->getUser()) + throw UserNotAllowedException{}; + } - const User::pointer user {User::find(LmsApp->getDbSession(), *_userId)}; - return user->getType(); - } + UserType getUserType() const + { + if (_userId) + { + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - return Wt::asNumber(value(DemoField)) ? UserType::DEMO : UserType::REGULAR; - } + const User::pointer user{ User::find(LmsApp->getDbSession(), *_userId) }; + return user->getType(); + } - std::string getLoginName() const - { - if (_userId) - { - auto transaction {LmsApp->getDbSession().createReadTransaction()}; + return Wt::asNumber(value(DemoField)) ? UserType::DEMO : UserType::REGULAR; + } - const User::pointer user {User::find(LmsApp->getDbSession(), *_userId)}; - return user->getLoginName(); - } + std::string getLoginName() const + { + if (_userId) + { + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - return valueText(LoginField).toUTF8(); - } + const User::pointer user{ User::find(LmsApp->getDbSession(), *_userId) }; + return user->getLoginName(); + } - bool validateField(Field field) - { - Wt::WString error; + return valueText(LoginField).toUTF8(); + } - if (field == LoginField) - { - auto transaction {LmsApp->getDbSession().createReadTransaction()}; + bool validateField(Field field) + { + Wt::WString error; - const User::pointer user {User::find(LmsApp->getDbSession(), valueText(LoginField).toUTF8())}; - if (user) - error = Wt::WString::tr("Lms.Admin.User.user-already-exists"); - } - else if (field == DemoField) - { - auto transaction {LmsApp->getDbSession().createReadTransaction()}; + if (field == LoginField) + { + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - if (Wt::asNumber(value(DemoField)) && User::findDemoUser(LmsApp->getDbSession())) - error = Wt::WString::tr("Lms.Admin.User.demo-account-already-exists"); - } + const User::pointer user{ User::find(LmsApp->getDbSession(), valueText(LoginField).toUTF8()) }; + if (user) + error = Wt::WString::tr("Lms.Admin.User.user-already-exists"); + } + else if (field == DemoField) + { + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - if (error.empty()) - return Wt::WFormModel::validateField(field); + if (Wt::asNumber(value(DemoField)) && User::findDemoUser(LmsApp->getDbSession())) + error = Wt::WString::tr("Lms.Admin.User.demo-account-already-exists"); + } - setValidation(field, Wt::WValidator::Result {Wt::ValidationState::Invalid, error}); + if (error.empty()) + return Wt::WFormModel::validateField(field); - return false; - } + setValidation(field, Wt::WValidator::Result{ Wt::ValidationState::Invalid, error }); - std::optional _userId; - ::Auth::IPasswordService* _authPasswordService {}; -}; + return false; + } -UserView::UserView() -{ - wApp->internalPathChanged().connect(this, [this]() - { - refreshView(); - }); + std::optional _userId; + ::Auth::IPasswordService* _authPasswordService{}; + }; - refreshView(); -} + UserView::UserView() + { + wApp->internalPathChanged().connect(this, [this]() + { + refreshView(); + }); -void -UserView::refreshView() -{ - if (!wApp->internalPathMatches("/admin/user")) - return; + refreshView(); + } - const std::optional userId {StringUtils::readAs(wApp->internalPathNextPart("/admin/user/"))}; + void UserView::refreshView() + { + if (!wApp->internalPathMatches("/admin/user")) + return; - clear(); + const std::optional userId{ StringUtils::readAs(wApp->internalPathNextPart("/admin/user/")) }; - Wt::WTemplateFormView* t {addNew(Wt::WString::tr("Lms.Admin.User.template"))}; + clear(); - auto* authPasswordService {Service<::Auth::IPasswordService>::get()}; - if (authPasswordService && !authPasswordService->canSetPasswords()) - authPasswordService = nullptr; + Wt::WTemplateFormView* t{ addNew(Wt::WString::tr("Lms.Admin.User.template")) }; - auto model {std::make_shared(userId, authPasswordService)}; + auto* authPasswordService{ Service<::Auth::IPasswordService>::get() }; + if (authPasswordService && !authPasswordService->canSetPasswords()) + authPasswordService = nullptr; - if (userId) - { - auto transaction {LmsApp->getDbSession().createReadTransaction()}; + auto model{ std::make_shared(userId, authPasswordService) }; - const User::pointer user {User::find(LmsApp->getDbSession(), *userId)}; - if (!user) - throw UserNotFoundException {}; + if (userId) + { + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - const Wt::WString title {Wt::WString::tr("Lms.Admin.User.user-edit").arg(user->getLoginName())}; - LmsApp->setTitle(title); + const User::pointer user{ User::find(LmsApp->getDbSession(), *userId) }; + if (!user) + throw UserNotFoundException{}; - t->bindString("title", title, Wt::TextFormat::Plain); - t->setCondition("if-has-last-login", true); - t->bindString("last-login", user->getLastLogin().toString(), Wt::TextFormat::Plain); - } - else - { - const Wt::WString title {Wt::WString::tr("Lms.Admin.User.user-create")}; - LmsApp->setTitle(title); + const Wt::WString title{ Wt::WString::tr("Lms.Admin.User.user-edit").arg(user->getLoginName()) }; + LmsApp->setTitle(title); - // Login - t->setCondition("if-has-login", true); - t->setFormWidget(UserModel::LoginField, std::make_unique()); - t->bindString("title", title); - } + t->bindString("title", title, Wt::TextFormat::Plain); + t->setCondition("if-has-last-login", true); + t->bindString("last-login", user->getLastLogin().toString(), Wt::TextFormat::Plain); + } + else + { + const Wt::WString title{ Wt::WString::tr("Lms.Admin.User.user-create") }; + LmsApp->setTitle(title); - if (authPasswordService) - { - t->setCondition("if-has-password", true); + // Login + t->setCondition("if-has-login", true); + t->setFormWidget(UserModel::LoginField, std::make_unique()); + t->bindString("title", title); + } - // Password - auto passwordEdit = std::make_unique(); - passwordEdit->setEchoMode(Wt::EchoMode::Password); - passwordEdit->setAttributeValue("autocomplete", "off"); - t->setFormWidget(UserModel::PasswordField, std::move(passwordEdit)); - } + if (authPasswordService) + { + t->setCondition("if-has-password", true); - // Demo account - t->setFormWidget(UserModel::DemoField, std::make_unique()); - if (!userId && Service::get()->getBool("demo", false)) - t->setCondition("if-demo", true); + // Password + auto passwordEdit = std::make_unique(); + passwordEdit->setEchoMode(Wt::EchoMode::Password); + passwordEdit->setAttributeValue("autocomplete", "off"); + t->setFormWidget(UserModel::PasswordField, std::move(passwordEdit)); + } - Wt::WPushButton* saveBtn {t->bindNew("save-btn", Wt::WString::tr(userId ? "Lms.save" : "Lms.create"))}; - saveBtn->clicked().connect([=]() - { - t->updateModel(model.get()); + // Demo account + t->setFormWidget(UserModel::DemoField, std::make_unique()); + if (!userId && Service::get()->getBool("demo", false)) + t->setCondition("if-demo", true); - if (model->validate()) - { - model->saveData(); - LmsApp->notifyMsg(Notification::Type::Info, - Wt::WString::tr("Lms.Admin.Users.users"), - Wt::WString::tr(userId ? "Lms.Admin.User.user-updated" : "Lms.Admin.User.user-created")); - LmsApp->setInternalPath("/admin/users", true); - } - else - { - t->updateView(model.get()); - } - }); + Wt::WPushButton* saveBtn{ t->bindNew("save-btn", Wt::WString::tr(userId ? "Lms.save" : "Lms.create")) }; + saveBtn->clicked().connect([=]() + { + t->updateModel(model.get()); - t->updateView(model.get()); -} + if (model->validate()) + { + model->saveData(); + LmsApp->notifyMsg(Notification::Type::Info, + Wt::WString::tr("Lms.Admin.Users.users"), + Wt::WString::tr(userId ? "Lms.Admin.User.user-updated" : "Lms.Admin.User.user-created")); + LmsApp->setInternalPath("/admin/users", true); + } + else + { + t->updateView(model.get()); + } + }); + + t->updateView(model.get()); + } } // namespace UserInterface - - diff --git a/src/lms/ui/admin/UsersView.cpp b/src/lms/ui/admin/UsersView.cpp index e1bd9b53..b6753701 100644 --- a/src/lms/ui/admin/UsersView.cpp +++ b/src/lms/ui/admin/UsersView.cpp @@ -32,109 +32,105 @@ #include "LmsApplication.hpp" #include "ModalManager.hpp" -namespace UserInterface { - -using namespace Database; - -UsersView::UsersView() - : Wt::WTemplate {Wt::WString::tr("Lms.Admin.Users.template")} +namespace UserInterface { - addFunction("tr", &Wt::WTemplate::Functions::tr); + using namespace Database; - _container = bindNew("users"); + UsersView::UsersView() + : Wt::WTemplate{ Wt::WString::tr("Lms.Admin.Users.template") } + { + addFunction("tr", &Wt::WTemplate::Functions::tr); - if (Service<::Auth::IPasswordService>::get() && Service<::Auth::IPasswordService>::get()->canSetPasswords()) - { - setCondition("if-can-create-user", true); + _container = bindNew("users"); - Wt::WPushButton* addBtn = bindNew("add-btn", Wt::WString::tr("Lms.Admin.Users.add")); - addBtn->clicked().connect([] - { - LmsApp->setInternalPath("/admin/user", true); - }); - } + if (Service<::Auth::IPasswordService>::get() && Service<::Auth::IPasswordService>::get()->canSetPasswords()) + { + setCondition("if-can-create-user", true); - wApp->internalPathChanged().connect(this, [this]() - { - refreshView(); - }); + Wt::WPushButton* addBtn = bindNew("add-btn", Wt::WString::tr("Lms.Admin.Users.add")); + addBtn->clicked().connect([] + { + LmsApp->setInternalPath("/admin/user", true); + }); + } - refreshView(); -} + wApp->internalPathChanged().connect(this, [this]() + { + refreshView(); + }); -void -UsersView::refreshView() -{ - if (!wApp->internalPathMatches("/admin/users")) - return; + refreshView(); + } - _container->clear(); + void UsersView::refreshView() + { + if (!wApp->internalPathMatches("/admin/users")) + return; - auto transaction {LmsApp->getDbSession().createReadTransaction()}; + _container->clear(); - const User::IdType currentUserId {LmsApp->getUser()}; - for (const UserId userId : User::find(LmsApp->getDbSession(), User::FindParameters {}).results) - { - const User::pointer user {User::find(LmsApp->getDbSession(), userId)}; + auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - Wt::WTemplate* entry {_container->addNew(Wt::WString::tr("Lms.Admin.Users.template.entry"))}; + const User::IdType currentUserId{ LmsApp->getUser() }; + for (const UserId userId : User::find(LmsApp->getDbSession(), User::FindParameters{}).results) + { + const User::pointer user{ User::find(LmsApp->getDbSession(), userId) }; - entry->bindString("name", user->getLoginName(), Wt::TextFormat::Plain); + Wt::WTemplate* entry{ _container->addNew(Wt::WString::tr("Lms.Admin.Users.template.entry")) }; - // Create tag - if (user->isAdmin() || user->isDemo()) - { - entry->setCondition("if-tag", true); - entry->bindString("tag", Wt::WString::tr(user->isAdmin() ? "Lms.Admin.Users.admin" : "Lms.Admin.Users.demo")); - } + entry->bindString("name", user->getLoginName(), Wt::TextFormat::Plain); - // Don't edit ourself this way - if (user->getId() == currentUserId) - continue; + // Create tag + if (user->isAdmin() || user->isDemo()) + { + entry->setCondition("if-tag", true); + entry->bindString("tag", Wt::WString::tr(user->isAdmin() ? "Lms.Admin.Users.admin" : "Lms.Admin.Users.demo")); + } - entry->setCondition("if-edit", true); - Wt::WPushButton* editBtn = entry->bindNew("edit-btn", Wt::WString::tr("Lms.template.edit-btn"), Wt::TextFormat::XHTML); - editBtn->setToolTip(Wt::WString::tr("Lms.edit")); - editBtn->clicked().connect([=]() - { - LmsApp->setInternalPath("/admin/user/" + userId.toString(), true); - }); + // Don't edit ourself this way + if (user->getId() == currentUserId) + continue; - Wt::WPushButton* delBtn = entry->bindNew("del-btn", Wt::WString::tr("Lms.template.delete-btn"), Wt::TextFormat::XHTML); - delBtn->setToolTip(Wt::WString::tr("Lms.delete")); - delBtn->clicked().connect([=] - { - auto modal {std::make_unique(Wt::WString::tr("Lms.Admin.Users.template.delete-user"))}; - modal->addFunction("tr", &Wt::WTemplate::Functions::tr); - Wt::WWidget* modalPtr {modal.get()}; + entry->setCondition("if-edit", true); + Wt::WPushButton* editBtn = entry->bindNew("edit-btn", Wt::WString::tr("Lms.template.edit-btn"), Wt::TextFormat::XHTML); + editBtn->setToolTip(Wt::WString::tr("Lms.edit")); + editBtn->clicked().connect([=]() + { + LmsApp->setInternalPath("/admin/user/" + userId.toString(), true); + }); - auto* delBtn {modal->bindNew("del-btn", Wt::WString::tr("Lms.delete"))}; - delBtn->clicked().connect([=] - { - { - auto transaction {LmsApp->getDbSession().createWriteTransaction()}; + Wt::WPushButton* delBtn = entry->bindNew("del-btn", Wt::WString::tr("Lms.template.trash-btn"), Wt::TextFormat::XHTML); + delBtn->setToolTip(Wt::WString::tr("Lms.delete")); + delBtn->clicked().connect([=] + { + auto modal{ std::make_unique(Wt::WString::tr("Lms.Admin.Users.template.delete-user")) }; + modal->addFunction("tr", &Wt::WTemplate::Functions::tr); + Wt::WWidget* modalPtr{ modal.get() }; - User::pointer user {User::find(LmsApp->getDbSession(), userId)}; - if (user) - user.remove(); - } + auto* delBtn{ modal->bindNew("del-btn", Wt::WString::tr("Lms.delete")) }; + delBtn->clicked().connect([=] + { + { + auto transaction{ LmsApp->getDbSession().createWriteTransaction() }; - _container->removeWidget(entry); + User::pointer user{ User::find(LmsApp->getDbSession(), userId) }; + if (user) + user.remove(); + } - LmsApp->getModalManager().dispose(modalPtr); - }); + _container->removeWidget(entry); - auto* cancelBtn {modal->bindNew("cancel-btn", Wt::WString::tr("Lms.cancel"))}; - cancelBtn->clicked().connect([=] - { - LmsApp->getModalManager().dispose(modalPtr); - }); + LmsApp->getModalManager().dispose(modalPtr); + }); - LmsApp->getModalManager().show(std::move(modal)); - }); - } -} + auto* cancelBtn{ modal->bindNew("cancel-btn", Wt::WString::tr("Lms.cancel")) }; + cancelBtn->clicked().connect([=] + { + LmsApp->getModalManager().dispose(modalPtr); + }); + LmsApp->getModalManager().show(std::move(modal)); + }); + } + } } // namespace UserInterface - - diff --git a/src/lms/ui/common/DirectoryValidator.cpp b/src/lms/ui/common/DirectoryValidator.cpp deleted file mode 100644 index f79d57cb..00000000 --- a/src/lms/ui/common/DirectoryValidator.cpp +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2021 Emeric Poupon - * - * This file is part of LMS. - * - * LMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LMS. If not, see . - */ - -#include "DirectoryValidator.hpp" - -#include - -namespace UserInterface -{ - class DirectoryValidator : public Wt::WValidator - { - private: - Wt::WValidator::Result validate(const Wt::WString& input) const override; - std::string javaScriptValidate() const override { return {}; } - }; - - Wt::WValidator::Result - DirectoryValidator::validate(const Wt::WString& input) const - { - if (input.empty()) - return Wt::WValidator::validate(input); - - const std::filesystem::path p {input.toUTF8()}; - std::error_code ec; - - // TODO check rights - bool res = std::filesystem::is_directory(p, ec); - if (ec) - return Wt::WValidator::Result(Wt::ValidationState::Invalid, ec.message()); // TODO translate common errors - else if (res) - return Wt::WValidator::Result(Wt::ValidationState::Valid); - else - return Wt::WValidator::Result(Wt::ValidationState::Invalid, Wt::WString::tr("Lms.not-a-directory")); - } - - std::unique_ptr - createDirectoryValidator() - { - return std::make_unique(); - } -} // namespace UserInterface diff --git a/src/lms/ui/common/UppercaseValidator.cpp b/src/lms/ui/common/UppercaseValidator.cpp index a2332990..7bb3dbf6 100644 --- a/src/lms/ui/common/UppercaseValidator.cpp +++ b/src/lms/ui/common/UppercaseValidator.cpp @@ -17,7 +17,7 @@ * along with LMS. If not, see . */ -#include "DirectoryValidator.hpp" +#include "UppercaseValidator.hpp" #include #include diff --git a/src/lms/ui/explore/ArtistCollector.cpp b/src/lms/ui/explore/ArtistCollector.cpp index a59a2692..53824fb8 100644 --- a/src/lms/ui/explore/ArtistCollector.cpp +++ b/src/lms/ui/explore/ArtistCollector.cpp @@ -61,12 +61,28 @@ namespace UserInterface } case Mode::RecentlyPlayed: - artists = scrobblingService.getRecentArtists(LmsApp->getUserId(), getFilters().getClusterIds(), _linkType, range); + { + Scrobbling::IScrobblingService::ArtistFindParameters params; + params.setUser(LmsApp->getUserId()); + params.setClusters(getFilters().getClusterIds()); + params.setLinkType(_linkType); + params.setRange(range); + + artists = scrobblingService.getRecentArtists(params); break; + } case Mode::MostPlayed: - artists = scrobblingService.getTopArtists(LmsApp->getUserId(), getFilters().getClusterIds(), _linkType, range); + { + Scrobbling::IScrobblingService::ArtistFindParameters params; + params.setUser(LmsApp->getUserId()); + params.setClusters(getFilters().getClusterIds()); + params.setLinkType(_linkType); + params.setRange(range); + + artists = scrobblingService.getTopArtists(params); break; + } case Mode::RecentlyAdded: { diff --git a/src/lms/ui/explore/ReleaseCollector.cpp b/src/lms/ui/explore/ReleaseCollector.cpp index dc9f1096..9e1d909a 100644 --- a/src/lms/ui/explore/ReleaseCollector.cpp +++ b/src/lms/ui/explore/ReleaseCollector.cpp @@ -58,12 +58,26 @@ namespace UserInterface } case ReleaseCollector::Mode::RecentlyPlayed: - releases = scrobblingService.getRecentReleases(LmsApp->getUserId(), getFilters().getClusterIds(), range); + { + Scrobbling::IScrobblingService::FindParameters params; + params.setUser(LmsApp->getUserId()); + params.setClusters(getFilters().getClusterIds()); + params.setRange(range); + + releases = scrobblingService.getRecentReleases(params); break; + } case Mode::MostPlayed: - releases = scrobblingService.getTopReleases(LmsApp->getUserId(), getFilters().getClusterIds(), range); + { + Scrobbling::IScrobblingService::FindParameters params; + params.setUser(LmsApp->getUserId()); + params.setClusters(getFilters().getClusterIds()); + params.setRange(range); + + releases = scrobblingService.getTopReleases(params); break; + } case Mode::RecentlyAdded: { diff --git a/src/lms/ui/explore/TrackCollector.cpp b/src/lms/ui/explore/TrackCollector.cpp index 61edab0d..d8352074 100644 --- a/src/lms/ui/explore/TrackCollector.cpp +++ b/src/lms/ui/explore/TrackCollector.cpp @@ -61,12 +61,26 @@ namespace UserInterface } case TrackCollector::Mode::RecentlyPlayed: - tracks = scrobblingService.getRecentTracks(LmsApp->getUserId(), getFilters().getClusterIds(), range); + { + Scrobbling::IScrobblingService::FindParameters params; + params.setUser(LmsApp->getUserId()); + params.setClusters(getFilters().getClusterIds()); + params.setRange(range); + + tracks = scrobblingService.getRecentTracks(params); break; + } case Mode::MostPlayed: - tracks = scrobblingService.getTopTracks(LmsApp->getUserId(), getFilters().getClusterIds(), range); + { + Scrobbling::IScrobblingService::FindParameters params; + params.setUser(LmsApp->getUserId()); + params.setClusters(getFilters().getClusterIds()); + params.setRange(range); + + tracks = scrobblingService.getTopTracks(params); break; + } case Mode::RecentlyAdded: {