diff --git a/approot/release.xml b/approot/release.xml index a542b941..fdcf8568 100644 --- a/approot/release.xml +++ b/approot/release.xml @@ -55,36 +55,43 @@ ${artist class="text-decoration-none link-success"} - -

- ${} -
${artwork class="Lms-cursor-pointer"}
- ${
} -
${disc-title}
-
- ${play-btn class="d-none d-sm-block btn btn-sm btn-outline-secondary border-0"} - + + ${} +
${artwork class="Lms-cursor-pointer"}
+ ${
} +
${title}
+
+ ${play-btn class="d-none d-sm-block btn btn-sm btn-outline-secondary border-0"} + -

+ +
+ + +

${header class="d-flex align-items-center"}

+ ${tracks class="d-grid gap-1"} +
+ + +
${header class="d-flex align-items-center"}
${tracks class="d-grid gap-1 Lms-row-container"}
- + ${tracks class="d-grid gap-1 Lms-row-container"} - +
- ${}${track-number}${} + ${}${position}${}
diff --git a/src/libs/audio/impl/ffmpeg/TagReader.cpp b/src/libs/audio/impl/ffmpeg/TagReader.cpp index dcf648aa..97095f7e 100644 --- a/src/libs/audio/impl/ffmpeg/TagReader.cpp +++ b/src/libs/audio/impl/ffmpeg/TagReader.cpp @@ -83,9 +83,9 @@ namespace lms::audio::ffmpeg { TagType::Mixers, { "MIXERS" } }, { TagType::MixersSortOrder, { "MIXERSSORT" } }, { TagType::Mood, { "MOOD" } }, - { TagType::Movement, { "MOVEMENT", "MOVEMENTNAME" } }, - { TagType::MovementCount, { "MOVEMENTCOUNT" } }, - { TagType::MovementNumber, { "MOVEMENTNUMBER" } }, + { TagType::Movement, { "MOVEMENTNAME" } }, + { TagType::MovementCount, { "MOVEMENTCOUNT", "MOVEMENTTOTAL" } }, + { TagType::MovementNumber, { "MOVEMENTNUMBER", "MOVEMENT" } }, { TagType::MusicBrainzArtistID, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" } }, { TagType::MusicBrainzArrangerID, { "MUSICBRAINZ_ARRANGERID", "MUSICBRAINZ ARRANGER ID", "MUSICBRAINZ/ARRANGER ID" } }, { TagType::MusicBrainzComposerID, { "MUSICBRAINZ_COMPOSERID", "MUSICBRAINZ COMPOSER ID", "MUSICBRAINZ/COMPOSER ID" } }, diff --git a/src/libs/audio/impl/taglib/TagReader.cpp b/src/libs/audio/impl/taglib/TagReader.cpp index a905f7b5..cd3f594b 100644 --- a/src/libs/audio/impl/taglib/TagReader.cpp +++ b/src/libs/audio/impl/taglib/TagReader.cpp @@ -113,9 +113,9 @@ namespace lms::audio::taglib { TagType::Mixers, { "MIXERS" } }, { TagType::MixersSortOrder, { "MIXERSSORT" } }, { TagType::Mood, { "MOOD" } }, - { TagType::Movement, { "MOVEMENT", "MOVEMENTNAME" } }, - { TagType::MovementCount, { "MOVEMENTCOUNT" } }, - { TagType::MovementNumber, { "MOVEMENTNUMBER" } }, + { TagType::Movement, { "MOVEMENTNAME" } }, + { TagType::MovementCount, { "MOVEMENTCOUNT", "MOVEMENTTOTAL" } }, + { TagType::MovementNumber, { "MOVEMENTNUMBER", "MOVEMENT" } }, { TagType::MusicBrainzArtistID, { "MUSICBRAINZ_ARTISTID", "MUSICBRAINZ ARTIST ID", "MUSICBRAINZ/ARTIST ID" } }, { TagType::MusicBrainzArrangerID, { "MUSICBRAINZ_ARRANGERID", "MUSICBRAINZ ARRANGER ID", "MUSICBRAINZ/ARRANGER ID" } }, { TagType::MusicBrainzComposerID, { "MUSICBRAINZ_COMPOSERID", "MUSICBRAINZ COMPOSER ID", "MUSICBRAINZ/COMPOSER ID" } }, diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp index 8fec0efb..04edadb5 100644 --- a/src/libs/core/impl/String.cpp +++ b/src/libs/core/impl/String.cpp @@ -418,6 +418,44 @@ namespace lms::core::stringUtils } } + std::string toRomanNumeral(std::size_t n) + { + if (n == 0 || n > 3999) + return {}; + + static constexpr struct + { + std::size_t val; + const char* sym; + } table[]{ + { 1000, "m" }, + { 900, "cm" }, + { 500, "d" }, + { 400, "cd" }, + { 100, "c" }, + { 90, "xc" }, + { 50, "l" }, + { 40, "xl" }, + { 10, "x" }, + { 9, "ix" }, + { 5, "v" }, + { 4, "iv" }, + { 1, "i" } + }; + + std::string res; + for (const auto& [val, sym] : table) + { + while (n >= val) + { + res += sym; + n -= val; + } + } + + return res; + } + std::string replaceInString(std::string_view str, std::string_view from, std::string_view to) { std::string res{ str }; diff --git a/src/libs/core/include/core/String.hpp b/src/libs/core/include/core/String.hpp index 2dd2652c..8ac81d8a 100644 --- a/src/libs/core/include/core/String.hpp +++ b/src/libs/core/include/core/String.hpp @@ -65,6 +65,9 @@ namespace lms::core::stringUtils void capitalize(std::string& str); + // returns empty string if invalid input + [[nodiscard]] std::string toRomanNumeral(std::size_t n); + template [[nodiscard]] std::optional readAs(std::string_view str) { diff --git a/src/libs/core/test/String.cpp b/src/libs/core/test/String.cpp index 29b1d259..bbc7ee58 100644 --- a/src/libs/core/test/String.cpp +++ b/src/libs/core/test/String.cpp @@ -17,6 +17,8 @@ * along with LMS. If not, see . */ +#include + #include #include @@ -412,4 +414,32 @@ namespace lms::core::stringUtils::tests EXPECT_EQ(stringFromHex("3132333435"), "12345"); EXPECT_EQ(stringFromHex("54657374"), "Test"); } + + TEST(StringUtils, toRomanNumeral) + { + EXPECT_EQ(toRomanNumeral(1), "i"); + EXPECT_EQ(toRomanNumeral(2), "ii"); + EXPECT_EQ(toRomanNumeral(3), "iii"); + EXPECT_EQ(toRomanNumeral(4), "iv"); + EXPECT_EQ(toRomanNumeral(5), "v"); + EXPECT_EQ(toRomanNumeral(6), "vi"); + EXPECT_EQ(toRomanNumeral(7), "vii"); + EXPECT_EQ(toRomanNumeral(8), "viii"); + EXPECT_EQ(toRomanNumeral(9), "ix"); + EXPECT_EQ(toRomanNumeral(10), "x"); + EXPECT_EQ(toRomanNumeral(11), "xi"); + EXPECT_EQ(toRomanNumeral(14), "xiv"); + EXPECT_EQ(toRomanNumeral(16), "xvi"); + EXPECT_EQ(toRomanNumeral(40), "xl"); + EXPECT_EQ(toRomanNumeral(50), "l"); + EXPECT_EQ(toRomanNumeral(90), "xc"); + EXPECT_EQ(toRomanNumeral(99), "xcix"); + EXPECT_EQ(toRomanNumeral(444), "cdxliv"); + EXPECT_EQ(toRomanNumeral(1994), "mcmxciv"); + EXPECT_EQ(toRomanNumeral(3999), "mmmcmxcix"); + EXPECT_EQ(toRomanNumeral(0), ""); + EXPECT_EQ(toRomanNumeral(4000), ""); + EXPECT_EQ(toRomanNumeral(static_cast(-1)), ""); // underflows to SIZE_MAX + EXPECT_EQ(toRomanNumeral(std::numeric_limits::max()), ""); + } } // namespace lms::core::stringUtils::tests \ No newline at end of file diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index bc164a03..ca023ffb 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -27,8 +27,10 @@ add_library(lmsdatabase STATIC impl/objects/StarredArtist.cpp impl/objects/StarredRelease.cpp impl/objects/StarredTrack.cpp + impl/objects/Movement.cpp impl/objects/Track.cpp impl/objects/TrackArtistLink.cpp + impl/objects/Work.cpp impl/objects/TrackBookmark.cpp impl/objects/TrackEmbeddedImage.cpp impl/objects/TrackEmbeddedImageLink.cpp diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index dc65b75d..2611dac0 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -36,7 +36,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 108 }; + static constexpr Version LMS_DATABASE_VERSION{ 109 }; } VersionInfo::VersionInfo() @@ -1922,6 +1922,40 @@ WHERE ct.name = 'GROUPING')"); utils::executeCommand(*session.getDboSession(), R"(DELETE FROM cluster_type WHERE name IN ('GENRE', 'MOOD', 'LANGUAGE', 'GROUPING'))"); } + void migrateFromV108(Session& session) + { + utils::executeCommand(*session.getDboSession(), R"( +CREATE TABLE IF NOT EXISTS "work" ( + "id" integer primary key autoincrement, + "version" integer not null, + "name" text not null, + "mbid" blob +))"); + utils::executeCommand(*session.getDboSession(), R"( +CREATE TABLE IF NOT EXISTS "track_work" ( + "work_id" bigint, + "track_id" bigint, + primary key ("work_id", "track_id"), + constraint "fk_track_work_key1" foreign key ("work_id") references "work" ("id") on delete cascade deferrable initially deferred, + constraint "fk_track_work_key2" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred +))"); + utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "track_work_work" on "track_work" ("work_id"))"); + utils::executeCommand(*session.getDboSession(), R"(CREATE INDEX "track_work_track" on "track_work" ("track_id"))"); + utils::executeCommand(*session.getDboSession(), R"( +CREATE TABLE IF NOT EXISTS "track_movement" ( + "id" integer primary key autoincrement, + "version" integer not null, + "name" text not null, + "number" integer, + "count" integer, + "track_id" bigint, + constraint "fk_track_movement_track" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred +))"); + + // Just increment the scan version of the settings to make the next scan rescan all audio files + utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1"); + } + bool doDbMigration(Session& session) { constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -2006,6 +2040,7 @@ WHERE ct.name = 'GROUPING')"); { 105, migrateFromV105 }, { 106, migrateFromV106 }, { 107, migrateFromV107 }, + { 108, migrateFromV108 }, }; LMS_SCOPED_TRACE_OVERVIEW("Database", "Migration"); diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index 01c49b2b..a5d7530c 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -38,6 +38,7 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/PlayListFile.hpp" #include "database/objects/PlayQueue.hpp" #include "database/objects/Podcast.hpp" @@ -61,6 +62,7 @@ #include "database/objects/TrackMusicNNEmbeddings.hpp" #include "database/objects/UIState.hpp" #include "database/objects/User.hpp" +#include "database/objects/Work.hpp" #include "Db.hpp" #include "Migration.hpp" @@ -111,6 +113,7 @@ namespace lms::db _session.mapClass("starred_release"); _session.mapClass("starred_track"); _session.mapClass("track"); + _session.mapClass("track_movement"); _session.mapClass("track_bookmark"); _session.mapClass("track_artist_link"); _session.mapClass("track_embedded_image"); @@ -120,6 +123,7 @@ namespace lms::db _session.mapClass("tracklist_entry"); _session.mapClass("track_lyrics"); _session.mapClass("ui_state"); + _session.mapClass("work"); _session.mapClass("user"); _session.mapClass("version_info"); } @@ -322,6 +326,10 @@ namespace lms::db "CREATE INDEX IF NOT EXISTS track_lyrics_directory_idx ON track_lyrics(directory_id)", "CREATE INDEX IF NOT EXISTS track_lyrics_track_idx ON track_lyrics(track_id)", + "CREATE INDEX IF NOT EXISTS track_movement_track_idx ON track_movement(track_id)", + + "CREATE INDEX IF NOT EXISTS work_mbid_idx ON work(mbid)", + "CREATE INDEX IF NOT EXISTS track_bookmark_user_idx ON track_bookmark(user_id)", "CREATE INDEX IF NOT EXISTS track_bookmark_user_track_idx ON track_bookmark(user_id,track_id)", diff --git a/src/libs/database/impl/objects/Cluster.cpp b/src/libs/database/impl/objects/Cluster.cpp index 2d57d9a4..996c8bf8 100644 --- a/src/libs/database/impl/objects/Cluster.cpp +++ b/src/libs/database/impl/objects/Cluster.cpp @@ -31,12 +31,14 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" +#include "database/objects/Work.hpp" #include "Utils.hpp" #include "traits/IdTypeTraits.hpp" diff --git a/src/libs/database/impl/objects/Genre.cpp b/src/libs/database/impl/objects/Genre.cpp index 8f546257..aa74a78c 100644 --- a/src/libs/database/impl/objects/Genre.cpp +++ b/src/libs/database/impl/objects/Genre.cpp @@ -33,12 +33,14 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" +#include "database/objects/Work.hpp" #include "Utils.hpp" #include "traits/IdTypeTraits.hpp" diff --git a/src/libs/database/impl/objects/Grouping.cpp b/src/libs/database/impl/objects/Grouping.cpp index 80590c5f..22612765 100644 --- a/src/libs/database/impl/objects/Grouping.cpp +++ b/src/libs/database/impl/objects/Grouping.cpp @@ -33,12 +33,14 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" +#include "database/objects/Work.hpp" #include "Utils.hpp" #include "traits/IdTypeTraits.hpp" diff --git a/src/libs/database/impl/objects/Language.cpp b/src/libs/database/impl/objects/Language.cpp index 00230538..6a5c4ad9 100644 --- a/src/libs/database/impl/objects/Language.cpp +++ b/src/libs/database/impl/objects/Language.cpp @@ -33,12 +33,14 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" +#include "database/objects/Work.hpp" #include "Utils.hpp" #include "traits/IdTypeTraits.hpp" diff --git a/src/libs/database/impl/objects/Medium.cpp b/src/libs/database/impl/objects/Medium.cpp index 4c2cd5ac..d50970e7 100644 --- a/src/libs/database/impl/objects/Medium.cpp +++ b/src/libs/database/impl/objects/Medium.cpp @@ -31,12 +31,14 @@ #include "database/objects/Language.hpp" #include "database/objects/MediaLibrary.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" +#include "database/objects/Work.hpp" #include "Utils.hpp" #include "traits/IdTypeTraits.hpp" diff --git a/src/libs/database/impl/objects/Mood.cpp b/src/libs/database/impl/objects/Mood.cpp index c2e5d478..cfd9bd37 100644 --- a/src/libs/database/impl/objects/Mood.cpp +++ b/src/libs/database/impl/objects/Mood.cpp @@ -33,12 +33,14 @@ #include "database/objects/Language.hpp" #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" +#include "database/objects/Work.hpp" #include "Utils.hpp" #include "traits/IdTypeTraits.hpp" diff --git a/src/libs/database/impl/objects/Movement.cpp b/src/libs/database/impl/objects/Movement.cpp new file mode 100644 index 00000000..ab96550e --- /dev/null +++ b/src/libs/database/impl/objects/Movement.cpp @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2025 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/objects/Movement.hpp" + +#include + +#include "core/ILogger.hpp" + +#include "database/Session.hpp" +#include "database/objects/Artist.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Cluster.hpp" +#include "database/objects/Directory.hpp" +#include "database/objects/Genre.hpp" +#include "database/objects/Grouping.hpp" +#include "database/objects/Language.hpp" +#include "database/objects/MediaLibrary.hpp" +#include "database/objects/Medium.hpp" +#include "database/objects/Mood.hpp" +#include "database/objects/Release.hpp" +#include "database/objects/Track.hpp" +#include "database/objects/TrackArtistLink.hpp" +#include "database/objects/TrackEmbeddedImage.hpp" +#include "database/objects/TrackEmbeddedImageLink.hpp" +#include "database/objects/TrackLyrics.hpp" + +#include "traits/IdTypeTraits.hpp" + +DBO_INSTANTIATE_TEMPLATES(lms::db::Movement) + +namespace lms::db +{ + Movement::Movement(std::string_view name, std::optional number, std::optional count, const ObjectPtr& track) + : _name{ name.substr(0, maxNameLength) } + , _number{ number } + , _count{ count } + , _track{ getDboPtr(track) } + { + LMS_LOG_IF(DB, WARNING, name.size() > maxNameLength, "Movement name too long, truncated to '" << _name << "'"); + } + + Movement::pointer Movement::create(Session& session, std::string_view name, std::optional number, std::optional count, const ObjectPtr& track) + { + return session.getDboSession()->add(std::unique_ptr{ new Movement{ name, number, count, track } }); + } + +} // namespace lms::db diff --git a/src/libs/database/impl/objects/PlayQueue.cpp b/src/libs/database/impl/objects/PlayQueue.cpp index ffa824ab..25145a85 100644 --- a/src/libs/database/impl/objects/PlayQueue.cpp +++ b/src/libs/database/impl/objects/PlayQueue.cpp @@ -33,6 +33,7 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" @@ -40,6 +41,7 @@ #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" #include "database/objects/User.hpp" +#include "database/objects/Work.hpp" #include "Utils.hpp" #include "traits/IdTypeTraits.hpp" diff --git a/src/libs/database/impl/objects/Release.cpp b/src/libs/database/impl/objects/Release.cpp index 62202405..9b7be1b9 100644 --- a/src/libs/database/impl/objects/Release.cpp +++ b/src/libs/database/impl/objects/Release.cpp @@ -35,6 +35,7 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/ReleaseArtistLink.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" @@ -42,6 +43,7 @@ #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" #include "database/objects/User.hpp" +#include "database/objects/Work.hpp" #include "SqlQuery.hpp" #include "Utils.hpp" diff --git a/src/libs/database/impl/objects/Track.cpp b/src/libs/database/impl/objects/Track.cpp index 1b01fd72..a3d23394 100644 --- a/src/libs/database/impl/objects/Track.cpp +++ b/src/libs/database/impl/objects/Track.cpp @@ -36,12 +36,14 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" #include "database/objects/User.hpp" +#include "database/objects/Work.hpp" #include "SqlQuery.hpp" #include "Utils.hpp" @@ -673,6 +675,40 @@ namespace lms::db _moods.insert(getDboPtr(mood)); } + void Track::setWorks(std::span> works) + { + _works.clear(); + for (const ObjectPtr& work : works) + _works.insert(getDboPtr(work)); + } + + std::vector Track::getWorks() const + { + // deterministic order, callers rely on the first entry + return utils::fetchQueryResults(_works.find().orderBy("id")); + } + + bool Track::hasWork() const + { + return !_works.empty(); + } + + void Track::clearMovements() + { + _movements.clear(); + } + + std::vector Track::getMovements() const + { + // deterministic order, callers rely on the first entry + return utils::fetchQueryResults(_movements.find().orderBy("id")); + } + + bool Track::hasMovement() const + { + return !_movements.empty(); + } + void Track::clearLyrics() { _trackLyrics.clear(); diff --git a/src/libs/database/impl/objects/TrackEmbeddedImage.cpp b/src/libs/database/impl/objects/TrackEmbeddedImage.cpp index c800afdc..cb971bd6 100644 --- a/src/libs/database/impl/objects/TrackEmbeddedImage.cpp +++ b/src/libs/database/impl/objects/TrackEmbeddedImage.cpp @@ -27,7 +27,9 @@ #include "database/objects/Grouping.hpp" #include "database/objects/Language.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Track.hpp" +#include "database/objects/Work.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" diff --git a/src/libs/database/impl/objects/TrackList.cpp b/src/libs/database/impl/objects/TrackList.cpp index 486aa5b9..018fd1e2 100644 --- a/src/libs/database/impl/objects/TrackList.cpp +++ b/src/libs/database/impl/objects/TrackList.cpp @@ -29,9 +29,11 @@ #include "database/objects/Grouping.hpp" #include "database/objects/Language.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/PlayListFile.hpp" #include "database/objects/Track.hpp" #include "database/objects/User.hpp" +#include "database/objects/Work.hpp" #include "SqlQuery.hpp" #include "Utils.hpp" diff --git a/src/libs/database/impl/objects/Work.cpp b/src/libs/database/impl/objects/Work.cpp new file mode 100644 index 00000000..e032c890 --- /dev/null +++ b/src/libs/database/impl/objects/Work.cpp @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2025 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/objects/Work.hpp" + +#include + +#include "core/ILogger.hpp" + +#include "database/Session.hpp" +#include "database/objects/Artist.hpp" +#include "database/objects/Artwork.hpp" +#include "database/objects/Cluster.hpp" +#include "database/objects/Directory.hpp" +#include "database/objects/Genre.hpp" +#include "database/objects/Grouping.hpp" +#include "database/objects/Language.hpp" +#include "database/objects/MediaLibrary.hpp" +#include "database/objects/Medium.hpp" +#include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" +#include "database/objects/Release.hpp" +#include "database/objects/Track.hpp" +#include "database/objects/TrackArtistLink.hpp" +#include "database/objects/TrackEmbeddedImage.hpp" +#include "database/objects/TrackEmbeddedImageLink.hpp" +#include "database/objects/TrackLyrics.hpp" + +#include "Utils.hpp" +#include "traits/IdTypeTraits.hpp" +#include "traits/UUIDTraits.hpp" + +DBO_INSTANTIATE_TEMPLATES(lms::db::Work) + +namespace lms::db +{ + Work::Work(std::string_view name, const std::optional& mbid) + : _mbid{ mbid } + { + setName(name); + } + + void Work::setName(std::string_view name) + { + _name = name.substr(0, maxNameLength); + LMS_LOG_IF(DB, WARNING, name.size() > maxNameLength, "Work name too long, truncated to '" << _name << "'"); + } + + Work::pointer Work::create(Session& session, std::string_view name, const std::optional& mbid) + { + return session.getDboSession()->add(std::unique_ptr{ new Work{ name, mbid } }); + } + + Work::pointer Work::find(Session& session, WorkId id) + { + session.checkReadTransaction(); + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("id = ?").bind(id)); + } + + Work::pointer Work::find(Session& session, const core::UUID& mbid) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("mbid = ?").bind(mbid)); + } + + Work::pointer Work::find(Session& session, ReleaseId releaseId, std::string_view name) + { + session.checkReadTransaction(); + + if (name.size() > maxNameLength) + name = name.substr(0, maxNameLength); + + auto query{ + session.getDboSession()->query>("SELECT w FROM work w") + // clang-format off + .join("track_work t_w ON t_w.work_id = w.id") + .join("track t ON t.id = t_w.track_id") + .where("t.release_id = ?").bind(releaseId) + .where("w.name = ?").bind(std::string{ name }) + .where("w.mbid IS NULL") + .groupBy("w.id") + // clang-format on + }; + + return utils::fetchQuerySingleResult(query); + } + + std::vector Work::findOrphanIds(Session& session, std::optional range) + { + session.checkReadTransaction(); + auto query{ session.getDboSession()->query("SELECT w.id FROM work w WHERE NOT EXISTS (SELECT 1 FROM track_work t_w WHERE t_w.work_id = w.id)") }; + return utils::execRangeQuery(query, range); + } + +} // namespace lms::db diff --git a/src/libs/database/include/database/objects/Movement.hpp b/src/libs/database/include/database/objects/Movement.hpp new file mode 100644 index 00000000..fb33a9ba --- /dev/null +++ b/src/libs/database/include/database/objects/Movement.hpp @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2025 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include + +#include "database/Object.hpp" +#include "database/objects/MovementId.hpp" +#include "database/objects/TrackId.hpp" + +namespace lms::db +{ + class Session; + class Track; + + class Movement final : public Object + { + public: + static constexpr std::size_t maxNameLength{ 512 }; + + Movement() = default; + + static pointer create(Session& session, std::string_view name, std::optional number, std::optional count, const ObjectPtr& track); + + std::string_view getName() const { return _name; } + std::optional getNumber() const { return _number; } + std::optional getCount() const { return _count; } + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _name, "name"); + Wt::Dbo::field(a, _number, "number"); + Wt::Dbo::field(a, _count, "count"); + Wt::Dbo::belongsTo(a, _track, "track", Wt::Dbo::OnDeleteCascade); + } + + private: + friend class Session; + Movement(std::string_view name, std::optional number, std::optional count, const ObjectPtr& track); + + std::string _name; + std::optional _number; + std::optional _count; + Wt::Dbo::ptr _track; + }; + +} // namespace lms::db diff --git a/src/libs/database/include/database/objects/MovementId.hpp b/src/libs/database/include/database/objects/MovementId.hpp new file mode 100644 index 00000000..264f3789 --- /dev/null +++ b/src/libs/database/include/database/objects/MovementId.hpp @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "database/IdType.hpp" + +LMS_DECLARE_IDTYPE(MovementId) diff --git a/src/libs/database/include/database/objects/Track.hpp b/src/libs/database/include/database/objects/Track.hpp index 5c84a60c..ca743af6 100644 --- a/src/libs/database/include/database/objects/Track.hpp +++ b/src/libs/database/include/database/objects/Track.hpp @@ -51,12 +51,14 @@ #include "database/objects/MediaLibraryId.hpp" #include "database/objects/MediumId.hpp" #include "database/objects/MoodId.hpp" +#include "database/objects/MovementId.hpp" #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackEmbeddedImageId.hpp" #include "database/objects/TrackId.hpp" #include "database/objects/TrackListId.hpp" #include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" +#include "database/objects/WorkId.hpp" #include "database/objects/detail/Types.hpp" namespace lms::db @@ -69,16 +71,18 @@ namespace lms::db class Genre; class Grouping; class Language; + class Medium; + class Movement; class Mood; class TrackEmbeddedImageLink; class MediaLibrary; - class Medium; class Release; class Session; class TrackArtistLink; class TrackLyrics; class TrackStats; class User; + class Work; class Track final : public Object { @@ -279,6 +283,8 @@ namespace lms::db void setGroupings(std::span> groupings); void setLanguages(std::span> languages); void setMoods(std::span> moods); + void setWorks(std::span> works); + void clearMovements(); void clearLyrics(); void clearEmbeddedLyrics(); void addLyrics(const ObjectPtr& lyrics); @@ -310,7 +316,7 @@ namespace lms::db // Metadata std::optional getTrackNumber() const { return _trackNumber; } - std::string getName() const { return _name; } + std::string_view getName() const { return _name; } const core::PartialDateTime& getDate() const { return _date; } std::optional getYear() const; const core::PartialDateTime& getOriginalDate() const { return _originalDate; } @@ -347,6 +353,10 @@ namespace lms::db std::vector getLanguageIds() const; std::vector> getMoods() const; std::vector getMoodIds() const; + std::vector> getWorks() const; + bool hasWork() const; + std::vector> getMovements() const; + bool hasMovement() const; ObjectPtr getMediaLibrary() const; ObjectPtr getDirectory() const; ObjectPtr getPreferredArtwork() const; @@ -393,12 +403,14 @@ namespace lms::db Wt::Dbo::belongsTo(a, _directory, "directory", Wt::Dbo::OnDeleteCascade); Wt::Dbo::belongsTo(a, _preferredArtwork, "preferred_artwork", Wt::Dbo::OnDeleteSetNull); Wt::Dbo::belongsTo(a, _preferredMediaArtwork, "preferred_media_artwork", Wt::Dbo::OnDeleteSetNull); + Wt::Dbo::hasMany(a, _movements, Wt::Dbo::ManyToOne, "track"); Wt::Dbo::hasMany(a, _trackArtistLinks, Wt::Dbo::ManyToOne, "track"); Wt::Dbo::hasMany(a, _clusters, Wt::Dbo::ManyToMany, "track_cluster", "", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasMany(a, _genres, Wt::Dbo::ManyToMany, "track_genre", "", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasMany(a, _groupings, Wt::Dbo::ManyToMany, "track_grouping", "", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasMany(a, _languages, Wt::Dbo::ManyToMany, "track_language", "", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasMany(a, _moods, Wt::Dbo::ManyToMany, "track_mood", "", Wt::Dbo::OnDeleteCascade); + Wt::Dbo::hasMany(a, _works, Wt::Dbo::ManyToMany, "track_work", "", Wt::Dbo::OnDeleteCascade); Wt::Dbo::hasMany(a, _trackLyrics, Wt::Dbo::ManyToOne, "track"); Wt::Dbo::hasMany(a, _embeddedImageLinks, Wt::Dbo::ManyToOne, "track"); } @@ -441,7 +453,6 @@ namespace lms::db std::string _artistDisplayName; std::string _comment; Advisory _advisory{ Advisory::UnSet }; - Wt::Dbo::ptr _medium; Wt::Dbo::ptr _release; Wt::Dbo::ptr _mediaLibrary; @@ -454,6 +465,8 @@ namespace lms::db Wt::Dbo::collection> _groupings; Wt::Dbo::collection> _languages; Wt::Dbo::collection> _moods; + Wt::Dbo::collection> _works; + Wt::Dbo::collection> _movements; Wt::Dbo::collection> _trackLyrics; Wt::Dbo::collection> _embeddedImageLinks; }; diff --git a/src/libs/database/include/database/objects/Work.hpp b/src/libs/database/include/database/objects/Work.hpp new file mode 100644 index 00000000..cc7a3d00 --- /dev/null +++ b/src/libs/database/include/database/objects/Work.hpp @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2025 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include "core/UUID.hpp" + +#include "database/Object.hpp" +#include "database/Types.hpp" +#include "database/objects/ReleaseId.hpp" +#include "database/objects/TrackId.hpp" +#include "database/objects/WorkId.hpp" + +namespace lms::db +{ + class Session; + class Track; + + class Work final : public Object + { + public: + static constexpr std::size_t maxNameLength{ 512 }; + + Work() = default; + + static pointer find(Session& session, WorkId id); + // Global lookup: MusicBrainz Work Id is a strong, unambiguous identity shared across the whole library + static pointer find(Session& session, const core::UUID& mbid); + // Name-only lookup, scoped to works already linked to a track of the given release: work titles are + // often generic (e.g. "Symphony No. 5") and collide across unrelated works, so without an mbid we only + // ever match within the same release instead of matching globally by name + static pointer find(Session& session, ReleaseId releaseId, std::string_view name); + static std::vector findOrphanIds(Session& session, std::optional range = std::nullopt); + + void setName(std::string_view name); + + std::string_view getName() const { return _name; } + std::optional getMBID() const { return _mbid; } + + template + void persist(Action& a) + { + Wt::Dbo::field(a, _name, "name"); + Wt::Dbo::field(a, _mbid, "mbid"); + Wt::Dbo::hasMany(a, _tracks, Wt::Dbo::ManyToMany, "track_work", "", Wt::Dbo::OnDeleteCascade); + } + + private: + friend class Session; + Work(std::string_view name, const std::optional& mbid); + static pointer create(Session& session, std::string_view name, const std::optional& mbid); + + std::string _name; + std::optional _mbid; + Wt::Dbo::collection> _tracks; + }; + +} // namespace lms::db diff --git a/src/libs/database/include/database/objects/WorkId.hpp b/src/libs/database/include/database/objects/WorkId.hpp new file mode 100644 index 00000000..35e1ca3d --- /dev/null +++ b/src/libs/database/include/database/objects/WorkId.hpp @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "database/IdType.hpp" + +LMS_DECLARE_IDTYPE(WorkId) diff --git a/src/libs/database/test/CMakeLists.txt b/src/libs/database/test/CMakeLists.txt index 90a204f1..0858c8b6 100644 --- a/src/libs/database/test/CMakeLists.txt +++ b/src/libs/database/test/CMakeLists.txt @@ -16,6 +16,7 @@ add_executable(test-database Medium.cpp Migration.cpp Mood.cpp + Movement.cpp PlayListFile.cpp Podcast.cpp RatedArtist.cpp @@ -29,6 +30,7 @@ add_executable(test-database StarredTrack.cpp Track.cpp TrackArtistLink.cpp + Work.cpp TrackBookmark.cpp TrackEmbeddedImage.cpp TrackList.cpp diff --git a/src/libs/database/test/Movement.cpp b/src/libs/database/test/Movement.cpp new file mode 100644 index 00000000..84ec1269 --- /dev/null +++ b/src/libs/database/test/Movement.cpp @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2025 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "Common.hpp" + +#include "database/objects/Movement.hpp" +#include "database/objects/Track.hpp" + +namespace lms::db::tests +{ + TEST_F(DatabaseFixture, Movement_create) + { + ScopedTrack track{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + Movement::create(session, "Allegro con brio", std::size_t{ 1 }, std::size_t{ 4 }, track.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + const auto movements{ track->getMovements() }; + ASSERT_EQ(movements.size(), 1); + ASSERT_FALSE(movements[0]->getName().empty()); + EXPECT_EQ(movements[0]->getName(), "Allegro con brio"); + ASSERT_TRUE(movements[0]->getNumber()); + EXPECT_EQ(*movements[0]->getNumber(), std::size_t{ 1 }); + ASSERT_TRUE(movements[0]->getCount()); + EXPECT_EQ(*movements[0]->getCount(), std::size_t{ 4 }); + } + } + + TEST_F(DatabaseFixture, Movement_createWithNullFields) + { + ScopedTrack track{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + Movement::create(session, "", std::nullopt, std::nullopt, track.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + const auto movements{ track->getMovements() }; + ASSERT_EQ(movements.size(), 1); + EXPECT_TRUE(movements[0]->getName().empty()); + EXPECT_FALSE(movements[0]->getNumber()); + EXPECT_FALSE(movements[0]->getCount()); + } + } + + TEST_F(DatabaseFixture, Movement_multipleMovementsOnTrack) + { + ScopedTrack track{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + Movement::create(session, "Allegro con brio", std::size_t{ 1 }, std::size_t{ 4 }, track.get()); + Movement::create(session, "Andante con moto", std::size_t{ 2 }, std::size_t{ 4 }, track.get()); + Movement::create(session, "Scherzo. Allegro", std::size_t{ 3 }, std::size_t{ 4 }, track.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + const auto movements{ track->getMovements() }; + EXPECT_EQ(movements.size(), 3); + } + } + + TEST_F(DatabaseFixture, Movement_cascadeDeleteWithTrack) + { + ScopedTrack track{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + Movement::create(session, "Allegro", std::size_t{ 1 }, std::nullopt, track.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(track->getMovements().size(), 1); + } + + // track goes out of scope here — movements are cascade deleted + } + + TEST_F(DatabaseFixture, Movement_clearMovements) + { + ScopedTrack track{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + Movement::create(session, "Allegro", std::size_t{ 1 }, std::size_t{ 2 }, track.get()); + Movement::create(session, "Andante", std::size_t{ 2 }, std::size_t{ 2 }, track.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(track->getMovements().size(), 2); + } + + { + auto transaction{ session.createWriteTransaction() }; + track.get().modify()->clearMovements(); + } + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(track->getMovements().size(), 0); + } + } + + TEST_F(DatabaseFixture, Movement_replacingMovements) + { + ScopedTrack track{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + Movement::create(session, "OldMovement", std::size_t{ 1 }, std::nullopt, track.get()); + } + + { + auto transaction{ session.createWriteTransaction() }; + track.get().modify()->clearMovements(); + Movement::create(session, "NewMovement", std::size_t{ 1 }, std::nullopt, track.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + const auto movements{ track->getMovements() }; + ASSERT_EQ(movements.size(), 1); + ASSERT_FALSE(movements[0]->getName().empty()); + EXPECT_EQ(movements[0]->getName(), "NewMovement"); + } + } + + TEST_F(DatabaseFixture, Movement_getNumberReturnsCorrectSizeT) + { + ScopedTrack track{ session }; + + { + auto transaction{ session.createWriteTransaction() }; + Movement::create(session, "", std::size_t{ 42 }, std::size_t{ 100 }, track.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + const auto movements{ track->getMovements() }; + ASSERT_EQ(movements.size(), 1); + ASSERT_TRUE(movements[0]->getNumber()); + EXPECT_EQ(*movements[0]->getNumber(), std::size_t{ 42 }); + ASSERT_TRUE(movements[0]->getCount()); + EXPECT_EQ(*movements[0]->getCount(), std::size_t{ 100 }); + } + } +} // namespace lms::db::tests diff --git a/src/libs/database/test/Work.cpp b/src/libs/database/test/Work.cpp new file mode 100644 index 00000000..846a949a --- /dev/null +++ b/src/libs/database/test/Work.cpp @@ -0,0 +1,213 @@ +/* + * Copyright (C) 2025 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "Common.hpp" + +#include "database/objects/Work.hpp" + +namespace lms::db::tests +{ + using ScopedWork = ScopedEntity; + + TEST_F(DatabaseFixture, Work_create) + { + ScopedWork work{ session, "Symphony No. 5", std::optional{} }; + + { + auto transaction{ session.createReadTransaction() }; + + const Work::pointer found{ Work::find(session, work.getId()) }; + ASSERT_TRUE(found); + EXPECT_EQ(found->getName(), "Symphony No. 5"); + EXPECT_FALSE(found->getMBID()); + } + } + + TEST_F(DatabaseFixture, Work_createWithMBID) + { + const auto mbid{ core::UUID::fromString("8f3471b3-7e93-4de1-a9c7-3b843c21b84e") }; + ASSERT_TRUE(mbid); + + ScopedWork work{ session, "Symphony No. 9", mbid }; + + { + auto transaction{ session.createReadTransaction() }; + + const Work::pointer found{ Work::find(session, work.getId()) }; + ASSERT_TRUE(found); + EXPECT_EQ(found->getName(), "Symphony No. 9"); + ASSERT_TRUE(found->getMBID()); + EXPECT_EQ(found->getMBID(), mbid); + } + } + + TEST_F(DatabaseFixture, Work_findByMBID) + { + const auto mbid{ core::UUID::fromString("8f3471b3-7e93-4de1-a9c7-3b843c21b84e") }; + ASSERT_TRUE(mbid); + + ScopedWork workWithMbid{ session, "Piano Sonata", mbid }; + + { + auto transaction{ session.createReadTransaction() }; + + const Work::pointer byMbid{ Work::find(session, *mbid) }; + ASSERT_TRUE(byMbid); + EXPECT_EQ(byMbid->getId(), workWithMbid.getId()); + } + } + + TEST_F(DatabaseFixture, Work_findByNameScopedToRelease) + { + // Work titles are often generic (e.g. "Piano Sonata"): two unrelated releases can each have + // their own work with the exact same name, and lookup must not merge them + ScopedRelease release1{ session, "Release1" }; + ScopedRelease release2{ session, "Release2" }; + ScopedTrack track1{ session }; + ScopedTrack track2{ session }; + ScopedWork work1{ session, "Piano Sonata", std::optional{} }; + ScopedWork work2{ session, "Piano Sonata", std::optional{} }; + + { + auto transaction{ session.createWriteTransaction() }; + track1.get().modify()->setRelease(release1.get()); + track1.get().modify()->setWorks(std::array{ work1.get() }); + track2.get().modify()->setRelease(release2.get()); + track2.get().modify()->setWorks(std::array{ work2.get() }); + } + + { + auto transaction{ session.createReadTransaction() }; + + // Same name, but each release resolves to its own work + const Work::pointer foundInRelease1{ Work::find(session, release1.getId(), "Piano Sonata") }; + ASSERT_TRUE(foundInRelease1); + EXPECT_EQ(foundInRelease1->getId(), work1.getId()); + + const Work::pointer foundInRelease2{ Work::find(session, release2.getId(), "Piano Sonata") }; + ASSERT_TRUE(foundInRelease2); + EXPECT_EQ(foundInRelease2->getId(), work2.getId()); + + // Unknown name in a known release returns null + EXPECT_FALSE(Work::find(session, release1.getId(), "Unknown Work")); + } + } + + TEST_F(DatabaseFixture, Work_orphan) + { + ScopedWork work{ session, "Unlinked Work", std::optional{} }; + + { + auto transaction{ session.createReadTransaction() }; + const auto orphans{ Work::findOrphanIds(session) }; + ASSERT_EQ(orphans.size(), 1); + EXPECT_EQ(orphans.front(), work.getId()); + } + } + + TEST_F(DatabaseFixture, Work_singleTrack) + { + ScopedTrack track{ session }; + ScopedWork work1{ session, "Requiem", std::optional{} }; + ScopedWork work2{ session, "Missa Solemnis", std::optional{} }; + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(Work::findOrphanIds(session).size(), 2); + EXPECT_EQ(track->getWorks().size(), 0); + } + + { + auto transaction{ session.createWriteTransaction() }; + track.get().modify()->setWorks(std::array{ work1.get() }); + } + + { + auto transaction{ session.createReadTransaction() }; + + const auto orphans{ Work::findOrphanIds(session) }; + ASSERT_EQ(orphans.size(), 1); + EXPECT_EQ(orphans.front(), work2.getId()); + + const auto trackWorks{ track->getWorks() }; + ASSERT_EQ(trackWorks.size(), 1); + EXPECT_EQ(trackWorks.front()->getId(), work1.getId()); + } + } + + TEST_F(DatabaseFixture, Work_multipleWorksOnTrack) + { + ScopedTrack track{ session }; + ScopedWork work1{ session, "Symphony No. 5", std::optional{} }; + ScopedWork work2{ session, "Symphony No. 6", std::optional{} }; + + { + auto transaction{ session.createWriteTransaction() }; + track.get().modify()->setWorks(std::array{ work1.get(), work2.get() }); + } + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(Work::findOrphanIds(session).size(), 0); + + const auto trackWorks{ track->getWorks() }; + EXPECT_EQ(trackWorks.size(), 2); + } + } + + TEST_F(DatabaseFixture, Work_multipleTracksOnWork) + { + ScopedTrack track1{ session }; + ScopedTrack track2{ session }; + ScopedTrack track3{ session }; + ScopedWork work{ session, "The Four Seasons", std::optional{} }; + + { + auto transaction{ session.createWriteTransaction() }; + track1.get().modify()->setWorks(std::array{ work.get() }); + track2.get().modify()->setWorks(std::array{ work.get() }); + track3.get().modify()->setWorks(std::array{ work.get() }); + } + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(Work::findOrphanIds(session).size(), 0); + + EXPECT_EQ(track1->getWorks().size(), 1); + EXPECT_EQ(track2->getWorks().size(), 1); + EXPECT_EQ(track3->getWorks().size(), 1); + } + } + + TEST_F(DatabaseFixture, Work_nameTruncation) + { + const std::string longName(Work::maxNameLength + 100, 'x'); + const std::string expectedName(Work::maxNameLength, 'x'); + + { + auto transaction{ session.createWriteTransaction() }; + Work::pointer work{ session.create(longName, std::optional{}) }; + ASSERT_TRUE(work); + EXPECT_EQ(work->getName().size(), Work::maxNameLength); + EXPECT_EQ(work->getName(), expectedName); + work.remove(); + } + } + +} // namespace lms::db::tests diff --git a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp index 9f7b5918..7e68b47a 100644 --- a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp +++ b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp @@ -41,6 +41,7 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/ReleaseArtistLink.hpp" #include "database/objects/Track.hpp" @@ -49,6 +50,7 @@ #include "database/objects/TrackEmbeddedImageLink.hpp" #include "database/objects/TrackLyrics.hpp" #include "database/objects/TrackMusicNNEmbeddings.hpp" +#include "database/objects/Work.hpp" #include "image/Exception.hpp" #include "image/Image.hpp" @@ -368,6 +370,24 @@ namespace lms::scanner return moods; } + std::vector getOrCreateWorks(db::Session& session, db::ReleaseId releaseId, std::span works) + { + std::vector dbWorks; + dbWorks.reserve(works.size()); + for (const Work& work : works) + { + // Work titles are often generic and collide across unrelated works, so + // without an mbid we only ever match a work already used on the same release, not globally by name + db::Work::pointer dbWork{ work.mbid ? db::Work::find(session, *work.mbid) : (releaseId.isValid() ? db::Work::find(session, releaseId, work.name) : db::Work::pointer{}) }; + if (!dbWork) + dbWork = session.create(work.name, work.mbid); + else if (dbWork->getName() != work.name) + dbWork.modify()->setName(work.name); + dbWorks.push_back(dbWork); + } + return dbWorks; + } + std::vector getOrCreateClusters(db::Session& session, const Track& track) { std::vector clusters; @@ -836,6 +856,12 @@ namespace lms::scanner track.modify()->setGroupings(getOrCreateGroupings(dbSession, _file->track.groupings)); track.modify()->setLanguages(getOrCreateLanguages(dbSession, _file->track.languages)); track.modify()->setMoods(getOrCreateMoods(dbSession, _file->track.moods)); + track.modify()->setWorks(getOrCreateWorks(dbSession, track->getReleaseId(), _file->track.works)); + + track.modify()->clearMovements(); + for (const auto& movement : _file->track.movements) + db::Movement::create(dbSession, movement.name, movement.number, movement.count, track); + track.modify()->setName(title); track.modify()->setTrackNumber(_file->track.position); track.modify()->setDate(_file->track.date); diff --git a/src/libs/services/scanner/impl/scanners/audiofile/TrackMetadataParser.cpp b/src/libs/services/scanner/impl/scanners/audiofile/TrackMetadataParser.cpp index 230d2e30..c248a6f5 100644 --- a/src/libs/services/scanner/impl/scanners/audiofile/TrackMetadataParser.cpp +++ b/src/libs/services/scanner/impl/scanners/audiofile/TrackMetadataParser.cpp @@ -184,6 +184,38 @@ namespace lms::scanner return res; } + std::vector getWorks(const audio::ITagReader& tagReader) + { + const std::vector titles{ getTagValuesAs(tagReader, audio::TagType::WorkTitle, {}) }; + const std::vector mbids{ getTagValuesAs(tagReader, audio::TagType::MusicBrainzWorkID, {}) }; + + const bool mbidsMatch{ mbids.size() == titles.size() }; + + std::vector works; + works.reserve(titles.size()); + for (std::size_t i{}; i < titles.size(); ++i) + works.push_back({ mbidsMatch ? std::optional{ mbids[i] } : std::nullopt, titles[i] }); + + return works; + } + + std::vector getMovements(const audio::ITagReader& tagReader) + { + const std::vector names{ getTagValuesAs(tagReader, audio::TagType::Movement, {}) }; + const std::vector numbers{ getTagValuesAs(tagReader, audio::TagType::MovementNumber, {}) }; + const std::vector counts{ getTagValuesAs(tagReader, audio::TagType::MovementCount, {}) }; + + const bool numbersMatch{ numbers.size() == names.size() }; + const bool countsMatch{ counts.size() == names.size() }; + + std::vector movements; + movements.reserve(names.size()); + for (std::size_t i{}; i < names.size(); ++i) + movements.push_back({ names[i], numbersMatch ? std::optional{ numbers[i] } : std::nullopt, countsMatch ? std::optional{ counts[i] } : std::nullopt }); + + return movements; + } + std::vector getArtists(const audio::ITagReader& tagReader, std::initializer_list artistTagNames, std::initializer_list artistSortTagNames, @@ -328,6 +360,8 @@ namespace lms::scanner track.title = getTagValueAs(tagReader, TagType::TrackTitle).value_or(""); track.mbid = getTagValueAs(tagReader, TagType::MusicBrainzTrackID); track.recordingMBID = getTagValueAs(tagReader, TagType::MusicBrainzRecordingID); + track.works = getWorks(tagReader); + track.movements = getMovements(tagReader); track.acoustID = getTagValueAs(tagReader, TagType::AcoustID); track.position = getTagValueAs(tagReader, TagType::TrackNumber); // May parse 'Number/Total', that's fine if (const auto dateStr{ getTagValueAs(tagReader, TagType::Date) }) diff --git a/src/libs/services/scanner/impl/steps/ScanStepRemoveOrphanedDbEntries.cpp b/src/libs/services/scanner/impl/steps/ScanStepRemoveOrphanedDbEntries.cpp index 3c371fe1..df484b4c 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepRemoveOrphanedDbEntries.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepRemoveOrphanedDbEntries.cpp @@ -33,6 +33,7 @@ #include "database/objects/Release.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackEmbeddedImage.hpp" +#include "database/objects/Work.hpp" #include "ScanContext.hpp" @@ -52,6 +53,7 @@ namespace lms::scanner removeOrphanedGroupings(context); removeOrphanedLanguages(context); removeOrphanedMoods(context); + removeOrphanedWorks(context); removeOrphanedArtists(context); removeOrphanedReleases(context); removeOrphanedMediums(context); // after release so that most entries are removed using the medium foreign key @@ -98,6 +100,12 @@ namespace lms::scanner removeOrphanedEntries(context); } + void ScanStepRemoveOrphanedDbEntries::removeOrphanedWorks(ScanContext& context) + { + LMS_LOG(DBUPDATER, DEBUG, "Checking orphaned works..."); + removeOrphanedEntries(context); + } + void ScanStepRemoveOrphanedDbEntries::removeOrphanedArtists(ScanContext& context) { LMS_LOG(DBUPDATER, DEBUG, "Checking orphaned artists..."); diff --git a/src/libs/services/scanner/impl/steps/ScanStepRemoveOrphanedDbEntries.hpp b/src/libs/services/scanner/impl/steps/ScanStepRemoveOrphanedDbEntries.hpp index 3173414e..27f59955 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepRemoveOrphanedDbEntries.hpp +++ b/src/libs/services/scanner/impl/steps/ScanStepRemoveOrphanedDbEntries.hpp @@ -40,6 +40,7 @@ namespace lms::scanner void removeOrphanedGroupings(ScanContext& context); void removeOrphanedLanguages(ScanContext& context); void removeOrphanedMoods(ScanContext& context); + void removeOrphanedWorks(ScanContext& context); void removeOrphanedArtists(ScanContext& context); void removeOrphanedMediums(ScanContext& context); void removeOrphanedReleases(ScanContext& context); diff --git a/src/libs/services/scanner/impl/types/TrackMetadata.hpp b/src/libs/services/scanner/impl/types/TrackMetadata.hpp index 319c5fd8..ee247f68 100644 --- a/src/libs/services/scanner/impl/types/TrackMetadata.hpp +++ b/src/libs/services/scanner/impl/types/TrackMetadata.hpp @@ -54,6 +54,14 @@ namespace lms::scanner using PerformerContainer = std::map>; + struct Work + { + std::optional mbid; + std::string name; + + auto operator<=>(const Work&) const = default; + }; + struct Release { std::optional mbid; @@ -100,8 +108,17 @@ namespace lms::scanner Clean, }; + struct MovementData + { + std::string name; + std::optional number; + std::optional count; + }; + std::optional mbid; std::optional recordingMBID; + std::vector works; + std::vector movements; std::string title; std::optional medium; std::optional position; // in medium diff --git a/src/libs/services/scanner/test/TestTagReader.hpp b/src/libs/services/scanner/test/TestTagReader.hpp index 3ba949a0..d5ed5e1b 100644 --- a/src/libs/services/scanner/test/TestTagReader.hpp +++ b/src/libs/services/scanner/test/TestTagReader.hpp @@ -137,6 +137,11 @@ namespace lms::scanner::tests { TagType::MusicBrainzReleaseID, { "3fa39992-b786-4585-a70e-85d5cc15ef69" } }, { TagType::MusicBrainzReleaseGroupID, { "5b1a5a44-8420-4426-9b86-d25dc8d04838" } }, { TagType::MusicBrainzRecordingID, { "bd3fc666-89de-4ac8-93f6-2dbf028ad8d5" } }, + { TagType::MusicBrainzWorkID, { "11112222-3333-4444-5555-666677778888", "aaaabbbb-cccc-dddd-eeee-ffff00001111" } }, + { TagType::WorkTitle, { "MyWork1", "MyWork2" } }, + { TagType::Movement, { "Allegro con brio", "Andante" } }, + { TagType::MovementNumber, { "1", "2" } }, + { TagType::MovementCount, { "4", "4" } }, { TagType::Producer, { "MyProducer1", "MyProducer2" } }, { TagType::Remixer, { "MyRemixer1", "MyRemixer2" } }, { TagType::RecordLabel, { "Label1", "Label2" } }, diff --git a/src/libs/services/scanner/test/TrackMetadataParser.cpp b/src/libs/services/scanner/test/TrackMetadataParser.cpp index 6f94bf4b..7ea182a0 100644 --- a/src/libs/services/scanner/test/TrackMetadataParser.cpp +++ b/src/libs/services/scanner/test/TrackMetadataParser.cpp @@ -114,6 +114,24 @@ namespace lms::scanner::tests EXPECT_EQ(track.producerArtists[1].name, "MyProducer2"); ASSERT_TRUE(track.recordingMBID.has_value()); EXPECT_EQ(track.recordingMBID.value(), core::UUID::fromString("bd3fc666-89de-4ac8-93f6-2dbf028ad8d5")); + ASSERT_EQ(track.works.size(), 2); + EXPECT_EQ(track.works[0].name, "MyWork1"); + EXPECT_EQ(track.works[1].name, "MyWork2"); + ASSERT_TRUE(track.works[0].mbid.has_value()); + EXPECT_EQ(track.works[0].mbid.value(), core::UUID::fromString("11112222-3333-4444-5555-666677778888")); + ASSERT_TRUE(track.works[1].mbid.has_value()); + EXPECT_EQ(track.works[1].mbid.value(), core::UUID::fromString("aaaabbbb-cccc-dddd-eeee-ffff00001111")); + ASSERT_EQ(track.movements.size(), 2); + EXPECT_EQ(track.movements[0].name, "Allegro con brio"); + ASSERT_TRUE(track.movements[0].number.has_value()); + EXPECT_EQ(track.movements[0].number.value(), 1); + ASSERT_TRUE(track.movements[0].count.has_value()); + EXPECT_EQ(track.movements[0].count.value(), 4); + EXPECT_EQ(track.movements[1].name, "Andante"); + ASSERT_TRUE(track.movements[1].number.has_value()); + EXPECT_EQ(track.movements[1].number.value(), 2); + ASSERT_TRUE(track.movements[1].count.has_value()); + EXPECT_EQ(track.movements[1].count.value(), 4); ASSERT_TRUE(track.replayGain.has_value()); EXPECT_FLOAT_EQ(track.replayGain.value(), -0.33); ASSERT_EQ(track.remixerArtists.size(), 2); @@ -720,6 +738,60 @@ namespace lms::scanner::tests EXPECT_EQ(track.artistDisplayName, "Artist1, Artist2"); // reconstruct the artist display name } + TEST(TrackMetadataParser, works_allMbidsPresent) + { + const TestTagReader testTags{ + { + { audio::TagType::WorkTitle, { "MyWork1", "MyWork2" } }, + { audio::TagType::MusicBrainzWorkID, { "11112222-3333-4444-5555-666677778888", "aaaabbbb-cccc-dddd-eeee-ffff00001111" } }, + } + }; + + const Track track{ TrackMetadataParser{}.parseTrackMetaData(testTags) }; + + ASSERT_EQ(track.works.size(), 2); + EXPECT_EQ(track.works[0].name, "MyWork1"); + EXPECT_EQ(track.works[0].mbid, core::UUID::fromString("11112222-3333-4444-5555-666677778888")); + EXPECT_EQ(track.works[1].name, "MyWork2"); + EXPECT_EQ(track.works[1].mbid, core::UUID::fromString("aaaabbbb-cccc-dddd-eeee-ffff00001111")); + } + + TEST(TrackMetadataParser, works_noMbids) + { + const TestTagReader testTags{ + { + { audio::TagType::WorkTitle, { "MyWork1", "MyWork2" } }, + } + }; + + const Track track{ TrackMetadataParser{}.parseTrackMetaData(testTags) }; + + ASSERT_EQ(track.works.size(), 2); + EXPECT_EQ(track.works[0].name, "MyWork1"); + EXPECT_EQ(track.works[0].mbid, std::nullopt); + EXPECT_EQ(track.works[1].name, "MyWork2"); + EXPECT_EQ(track.works[1].mbid, std::nullopt); + } + + TEST(TrackMetadataParser, works_mismatchedMbidCount) + { + // mbid count does not match work title count => mbids are all discarded (all or nothing) + const TestTagReader testTags{ + { + { audio::TagType::WorkTitle, { "MyWork1", "MyWork2" } }, + { audio::TagType::MusicBrainzWorkID, { "11112222-3333-4444-5555-666677778888" } }, + } + }; + + const Track track{ TrackMetadataParser{}.parseTrackMetaData(testTags) }; + + ASSERT_EQ(track.works.size(), 2); + EXPECT_EQ(track.works[0].name, "MyWork1"); + EXPECT_EQ(track.works[0].mbid, std::nullopt); + EXPECT_EQ(track.works[1].name, "MyWork2"); + EXPECT_EQ(track.works[1].mbid, std::nullopt); + } + TEST(TrackMetadataParser, heterogeneousArtistMbids) { { diff --git a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp index cc481e45..0f36d7e2 100644 --- a/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp +++ b/src/libs/services/scrobbling/impl/listenbrainz/ListensSynchronizer.cpp @@ -118,7 +118,7 @@ namespace lms::scrobbling::listenBrainz Wt::Json::Object trackMetadata; trackMetadata["additional_info"] = std::move(additionalInfo); trackMetadata["artist_name"] = Wt::Json::Value{ std::string{ track->getArtistDisplayName() } }; - trackMetadata["track_name"] = Wt::Json::Value{ track->getName() }; + trackMetadata["track_name"] = Wt::Json::Value{ std::string{ track->getName() } }; if (track->getRelease()) trackMetadata["release_name"] = Wt::Json::Value{ std::string{ track->getRelease()->getName() } }; diff --git a/src/libs/subsonic/impl/responses/Song.cpp b/src/libs/subsonic/impl/responses/Song.cpp index 5289a7fc..b0192118 100644 --- a/src/libs/subsonic/impl/responses/Song.cpp +++ b/src/libs/subsonic/impl/responses/Song.cpp @@ -36,11 +36,13 @@ #include "database/objects/MediaLibrary.hpp" #include "database/objects/Medium.hpp" #include "database/objects/Mood.hpp" +#include "database/objects/Movement.hpp" #include "database/objects/Release.hpp" #include "database/objects/ReleaseArtistLink.hpp" #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" #include "database/objects/User.hpp" +#include "database/objects/Work.hpp" #include "services/feedback/IFeedbackService.hpp" #include "services/scrobbling/IScrobblingService.hpp" @@ -250,6 +252,28 @@ namespace lms::api::subsonic trackResponse.addChild("replayGain", createReplayGainNode(track, medium)); + trackResponse.createEmptyArrayChild("works"); + for (const auto& work : track->getWorks()) + { + Response::Node workNode; + workNode.setAttribute("name", work->getName()); + if (const auto mbid{ work->getMBID() }) + workNode.setAttribute("musicBrainzId", mbid->toString()); + trackResponse.addArrayChild("works", std::move(workNode)); + } + + trackResponse.createEmptyArrayChild("movements"); + for (const auto& movement : track->getMovements()) + { + Response::Node movementNode; + movementNode.setAttribute("name", movement->getName()); + if (const auto n{ movement->getNumber() }) + movementNode.setAttribute("number", *n); + if (const auto c{ movement->getCount() }) + movementNode.setAttribute("count", *c); + trackResponse.addArrayChild("movements", std::move(movementNode)); + } + return trackResponse; } } // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/lms/ui/MediaPlayer.cpp b/src/lms/ui/MediaPlayer.cpp index 1cbd635c..5e33226e 100644 --- a/src/lms/ui/MediaPlayer.cpp +++ b/src/lms/ui/MediaPlayer.cpp @@ -244,6 +244,7 @@ namespace lms::ui const std::string nativeResource{ _audioFileResource->getUrl(trackId) }; const auto artistDisplayInfo{ utils::computeArtistDisplayInfo(track, db::TrackArtistLinkType::Artist) }; + const std::string displayTitle{ utils::computeTrackDisplayTitle(track) }; oss << "var params = {" @@ -252,7 +253,7 @@ namespace lms::ui << " transcodingResource: \"" << transcodingResource << "\"," << " duration: " << std::chrono::duration_cast>(track->getDuration()).count() << "," << " replayGain: " << replayGain << "," - << " title: \"" << core::stringUtils::jsEscape(track->getName()) << "\"," + << " title: \"" << core::stringUtils::jsEscape(displayTitle) << "\"," << " artist: \"" << (!artistDisplayInfo.displayName.empty() ? core::stringUtils::jsEscape(track->getArtistDisplayName()) : "") << "\"," << " release: \"" << (release ? core::stringUtils::jsEscape(release->getName()) : "") << "\","; @@ -282,7 +283,7 @@ namespace lms::ui oss << jsRef() + ".mediaplayer.loadTrack(params, " << (play ? "true" : "false") << ")"; // true to autoplay _title->setTextFormat(Wt::TextFormat::Plain); - _title->setText(Wt::WString::fromUTF8(track->getName())); + _title->setText(Wt::WString::fromUTF8(displayTitle)); _artists->clear(); _artists->addWidget(utils::createArtistsAnchors(artistDisplayInfo)); diff --git a/src/lms/ui/PlayQueue.cpp b/src/lms/ui/PlayQueue.cpp index cf353658..73cf2057 100644 --- a/src/lms/ui/PlayQueue.cpp +++ b/src/lms/ui/PlayQueue.cpp @@ -368,7 +368,7 @@ namespace lms::ui entry->toggleStyleClass("Lms-entry-playing", selected); } - void PlayQueue::enqueueTracks(const std::vector& trackIds) + void PlayQueue::enqueueTracks(std::span trackIds) { { auto transaction{ LmsApp->getDbSession().createWriteTransaction() }; @@ -425,35 +425,35 @@ namespace lms::ui return tracks; } - void PlayQueue::play(const std::vector& trackIds) + void PlayQueue::play(std::span trackIds) { playAtIndex(trackIds, 0); } - void PlayQueue::playNext(const std::vector& trackIds) + void PlayQueue::playNext(std::span trackIds) { std::vector nextTracks{ getAndClearNextTracks() }; nextTracks.insert(std::cbegin(nextTracks), std::cbegin(trackIds), std::cend(trackIds)); playOrAddLast(nextTracks); } - void PlayQueue::playShuffled(const std::vector& trackIds) + void PlayQueue::playShuffled(std::span trackIds) { clearTracks(); - std::vector shuffledTrackIds{ trackIds }; + std::vector shuffledTrackIds{ std::cbegin(trackIds), std::cend(trackIds) }; core::random::shuffleContainer(shuffledTrackIds); enqueueTracks(shuffledTrackIds); loadTrack(0, true); } - void PlayQueue::playOrAddLast(const std::vector& trackIds) + void PlayQueue::playOrAddLast(std::span trackIds) { enqueueTracks(trackIds); if (!_isTrackSelected) loadTrack(0, true); } - void PlayQueue::playAtIndex(const std::vector& trackIds, std::size_t index) + void PlayQueue::playAtIndex(std::span trackIds, std::size_t index) { clearTracks(); enqueueTracks(trackIds); @@ -478,10 +478,12 @@ namespace lms::ui const auto track{ tracklistEntry->getTrack() }; const db::TrackId trackId{ track->getId() }; + const std::string displayTitle{ utils::computeTrackDisplayTitle(track) }; + Template* entry{ _entriesContainer->addNew