Added support for work/movements, fixes #834

This commit is contained in:
emeric
2026-07-11 21:50:45 +02:00
parent 2fe4296fa5
commit 1c52972c12
50 changed files with 1630 additions and 296 deletions
+3 -3
View File
@@ -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" } },
+3 -3
View File
@@ -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" } },
+38
View File
@@ -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 };
+3
View File
@@ -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<typename T>
[[nodiscard]] std::optional<T> readAs(std::string_view str)
{
+30
View File
@@ -17,6 +17,8 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include <limits>
#include <gtest/gtest.h>
#include <Wt/WDate.h>
@@ -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<std::size_t>(-1)), ""); // underflows to SIZE_MAX
EXPECT_EQ(toRomanNumeral(std::numeric_limits<std::size_t>::max()), "");
}
} // namespace lms::core::stringUtils::tests
+2
View File
@@ -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
+36 -1
View File
@@ -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");
+8
View File
@@ -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<StarredRelease>("starred_release");
_session.mapClass<StarredTrack>("starred_track");
_session.mapClass<Track>("track");
_session.mapClass<Movement>("track_movement");
_session.mapClass<TrackBookmark>("track_bookmark");
_session.mapClass<TrackArtistLink>("track_artist_link");
_session.mapClass<TrackEmbeddedImage>("track_embedded_image");
@@ -120,6 +123,7 @@ namespace lms::db
_session.mapClass<TrackListEntry>("tracklist_entry");
_session.mapClass<TrackLyrics>("track_lyrics");
_session.mapClass<UIState>("ui_state");
_session.mapClass<Work>("work");
_session.mapClass<User>("user");
_session.mapClass<VersionInfo>("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)",
@@ -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"
+2
View File
@@ -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"
@@ -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"
@@ -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"
@@ -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"
+2
View File
@@ -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"
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "database/objects/Movement.hpp"
#include <Wt/Dbo/Impl.h>
#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<std::size_t> number, std::optional<std::size_t> count, const ObjectPtr<Track>& 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<std::size_t> number, std::optional<std::size_t> count, const ObjectPtr<Track>& track)
{
return session.getDboSession()->add(std::unique_ptr<Movement>{ new Movement{ name, number, count, track } });
}
} // namespace lms::db
@@ -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"
@@ -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"
+36
View File
@@ -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<const ObjectPtr<Work>> works)
{
_works.clear();
for (const ObjectPtr<Work>& work : works)
_works.insert(getDboPtr(work));
}
std::vector<Work::pointer> Track::getWorks() const
{
// deterministic order, callers rely on the first entry
return utils::fetchQueryResults<Work::pointer>(_works.find().orderBy("id"));
}
bool Track::hasWork() const
{
return !_works.empty();
}
void Track::clearMovements()
{
_movements.clear();
}
std::vector<Movement::pointer> Track::getMovements() const
{
// deterministic order, callers rely on the first entry
return utils::fetchQueryResults<Movement::pointer>(_movements.find().orderBy("id"));
}
bool Track::hasMovement() const
{
return !_movements.empty();
}
void Track::clearLyrics()
{
_trackLyrics.clear();
@@ -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"
@@ -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"
+112
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "database/objects/Work.hpp"
#include <Wt/Dbo/Impl.h>
#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<core::UUID>& 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<core::UUID>& mbid)
{
return session.getDboSession()->add(std::unique_ptr<Work>{ new Work{ name, mbid } });
}
Work::pointer Work::find(Session& session, WorkId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<Work>().where("id = ?").bind(id));
}
Work::pointer Work::find(Session& session, const core::UUID& mbid)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<Work>().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<Wt::Dbo::ptr<Work>>("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<WorkId> Work::findOrphanIds(Session& session, std::optional<Range> range)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<WorkId>("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<WorkId>(query, range);
}
} // namespace lms::db
@@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <optional>
#include <string>
#include <string_view>
#include <Wt/Dbo/Field.h>
#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<Movement, MovementId>
{
public:
static constexpr std::size_t maxNameLength{ 512 };
Movement() = default;
static pointer create(Session& session, std::string_view name, std::optional<std::size_t> number, std::optional<std::size_t> count, const ObjectPtr<Track>& track);
std::string_view getName() const { return _name; }
std::optional<std::size_t> getNumber() const { return _number; }
std::optional<std::size_t> getCount() const { return _count; }
template<class Action>
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<std::size_t> number, std::optional<std::size_t> count, const ObjectPtr<Track>& track);
std::string _name;
std::optional<int> _number;
std::optional<int> _count;
Wt::Dbo::ptr<Track> _track;
};
} // namespace lms::db
@@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "database/IdType.hpp"
LMS_DECLARE_IDTYPE(MovementId)
@@ -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<Track, TrackId>
{
@@ -279,6 +283,8 @@ namespace lms::db
void setGroupings(std::span<const ObjectPtr<Grouping>> groupings);
void setLanguages(std::span<const ObjectPtr<Language>> languages);
void setMoods(std::span<const ObjectPtr<Mood>> moods);
void setWorks(std::span<const ObjectPtr<Work>> works);
void clearMovements();
void clearLyrics();
void clearEmbeddedLyrics();
void addLyrics(const ObjectPtr<TrackLyrics>& lyrics);
@@ -310,7 +316,7 @@ namespace lms::db
// Metadata
std::optional<std::size_t> 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<int> getYear() const;
const core::PartialDateTime& getOriginalDate() const { return _originalDate; }
@@ -347,6 +353,10 @@ namespace lms::db
std::vector<LanguageId> getLanguageIds() const;
std::vector<ObjectPtr<Mood>> getMoods() const;
std::vector<MoodId> getMoodIds() const;
std::vector<ObjectPtr<Work>> getWorks() const;
bool hasWork() const;
std::vector<ObjectPtr<Movement>> getMovements() const;
bool hasMovement() const;
ObjectPtr<MediaLibrary> getMediaLibrary() const;
ObjectPtr<Directory> getDirectory() const;
ObjectPtr<Artwork> 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> _medium;
Wt::Dbo::ptr<Release> _release;
Wt::Dbo::ptr<MediaLibrary> _mediaLibrary;
@@ -454,6 +465,8 @@ namespace lms::db
Wt::Dbo::collection<Wt::Dbo::ptr<Grouping>> _groupings;
Wt::Dbo::collection<Wt::Dbo::ptr<Language>> _languages;
Wt::Dbo::collection<Wt::Dbo::ptr<Mood>> _moods;
Wt::Dbo::collection<Wt::Dbo::ptr<Work>> _works;
Wt::Dbo::collection<Wt::Dbo::ptr<Movement>> _movements;
Wt::Dbo::collection<Wt::Dbo::ptr<TrackLyrics>> _trackLyrics;
Wt::Dbo::collection<Wt::Dbo::ptr<TrackEmbeddedImageLink>> _embeddedImageLinks;
};
@@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include <Wt/Dbo/Field.h>
#include <Wt/Dbo/collection.h>
#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<Work, WorkId>
{
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<WorkId> findOrphanIds(Session& session, std::optional<Range> range = std::nullopt);
void setName(std::string_view name);
std::string_view getName() const { return _name; }
std::optional<core::UUID> getMBID() const { return _mbid; }
template<class Action>
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<core::UUID>& mbid);
static pointer create(Session& session, std::string_view name, const std::optional<core::UUID>& mbid);
std::string _name;
std::optional<core::UUID> _mbid;
Wt::Dbo::collection<Wt::Dbo::ptr<Track>> _tracks;
};
} // namespace lms::db
@@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "database/IdType.hpp"
LMS_DECLARE_IDTYPE(WorkId)
+2
View File
@@ -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
+172
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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
+213
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "Common.hpp"
#include "database/objects/Work.hpp"
namespace lms::db::tests
{
using ScopedWork = ScopedEntity<db::Work>;
TEST_F(DatabaseFixture, Work_create)
{
ScopedWork work{ session, "Symphony No. 5", std::optional<core::UUID>{} };
{
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<core::UUID>{} };
ScopedWork work2{ session, "Piano Sonata", std::optional<core::UUID>{} };
{
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<core::UUID>{} };
{
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<core::UUID>{} };
ScopedWork work2{ session, "Missa Solemnis", std::optional<core::UUID>{} };
{
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<core::UUID>{} };
ScopedWork work2{ session, "Symphony No. 6", std::optional<core::UUID>{} };
{
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<core::UUID>{} };
{
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<Work>(longName, std::optional<core::UUID>{}) };
ASSERT_TRUE(work);
EXPECT_EQ(work->getName().size(), Work::maxNameLength);
EXPECT_EQ(work->getName(), expectedName);
work.remove();
}
}
} // namespace lms::db::tests
@@ -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<db::Work::pointer> getOrCreateWorks(db::Session& session, db::ReleaseId releaseId, std::span<const Work> works)
{
std::vector<db::Work::pointer> 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<db::Work>(work.name, work.mbid);
else if (dbWork->getName() != work.name)
dbWork.modify()->setName(work.name);
dbWorks.push_back(dbWork);
}
return dbWorks;
}
std::vector<db::Cluster::pointer> getOrCreateClusters(db::Session& session, const Track& track)
{
std::vector<db::Cluster::pointer> 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);
@@ -184,6 +184,38 @@ namespace lms::scanner
return res;
}
std::vector<Work> getWorks(const audio::ITagReader& tagReader)
{
const std::vector<std::string> titles{ getTagValuesAs<std::string>(tagReader, audio::TagType::WorkTitle, {}) };
const std::vector<core::UUID> mbids{ getTagValuesAs<core::UUID>(tagReader, audio::TagType::MusicBrainzWorkID, {}) };
const bool mbidsMatch{ mbids.size() == titles.size() };
std::vector<Work> 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<Track::MovementData> getMovements(const audio::ITagReader& tagReader)
{
const std::vector<std::string> names{ getTagValuesAs<std::string>(tagReader, audio::TagType::Movement, {}) };
const std::vector<std::size_t> numbers{ getTagValuesAs<std::size_t>(tagReader, audio::TagType::MovementNumber, {}) };
const std::vector<std::size_t> counts{ getTagValuesAs<std::size_t>(tagReader, audio::TagType::MovementCount, {}) };
const bool numbersMatch{ numbers.size() == names.size() };
const bool countsMatch{ counts.size() == names.size() };
std::vector<Track::MovementData> 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<Artist> getArtists(const audio::ITagReader& tagReader,
std::initializer_list<audio::TagType> artistTagNames,
std::initializer_list<audio::TagType> artistSortTagNames,
@@ -328,6 +360,8 @@ namespace lms::scanner
track.title = getTagValueAs<std::string>(tagReader, TagType::TrackTitle).value_or("");
track.mbid = getTagValueAs<core::UUID>(tagReader, TagType::MusicBrainzTrackID);
track.recordingMBID = getTagValueAs<core::UUID>(tagReader, TagType::MusicBrainzRecordingID);
track.works = getWorks(tagReader);
track.movements = getMovements(tagReader);
track.acoustID = getTagValueAs<core::UUID>(tagReader, TagType::AcoustID);
track.position = getTagValueAs<std::size_t>(tagReader, TagType::TrackNumber); // May parse 'Number/Total', that's fine
if (const auto dateStr{ getTagValueAs<std::string>(tagReader, TagType::Date) })
@@ -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<db::Mood>(context);
}
void ScanStepRemoveOrphanedDbEntries::removeOrphanedWorks(ScanContext& context)
{
LMS_LOG(DBUPDATER, DEBUG, "Checking orphaned works...");
removeOrphanedEntries<db::Work>(context);
}
void ScanStepRemoveOrphanedDbEntries::removeOrphanedArtists(ScanContext& context)
{
LMS_LOG(DBUPDATER, DEBUG, "Checking orphaned artists...");
@@ -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);
@@ -54,6 +54,14 @@ namespace lms::scanner
using PerformerContainer = std::map<std::string /*role*/, std::vector<Artist>>;
struct Work
{
std::optional<core::UUID> mbid;
std::string name;
auto operator<=>(const Work&) const = default;
};
struct Release
{
std::optional<core::UUID> mbid;
@@ -100,8 +108,17 @@ namespace lms::scanner
Clean,
};
struct MovementData
{
std::string name;
std::optional<std::size_t> number;
std::optional<std::size_t> count;
};
std::optional<core::UUID> mbid;
std::optional<core::UUID> recordingMBID;
std::vector<Work> works;
std::vector<MovementData> movements;
std::string title;
std::optional<Medium> medium;
std::optional<std::size_t> position; // in medium
@@ -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" } },
@@ -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)
{
{
@@ -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() } };
+24
View File
@@ -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