diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d0b311f..6cb75ea9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_SOURCE_DIR}/cmake/modules/) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED TRUE) -if (UNIX) +if (UNIX AND NOT APPLE) set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--no-undefined") endif () diff --git a/README.md b/README.md index 5e1eb30e..b82baa57 100644 --- a/README.md +++ b/README.md @@ -38,15 +38,18 @@ _LMS_ provides several ways to help you find the music you like: __Note__: depending on your database size and/or your hardware, the tag-based recommendation engine may significantly slow down the user interface. You can disable it in the administration settings. ## About tags -_LMS_ relies exclusively on tags to organize your music collection. +_LMS_ primarily relies on tags to organize your music collection but also supports browsing by directory using the [Subsonic/OpenSubsonic API](SUBSONIC.md). ### Filtering -It is possible to apply global filters on your collection using `genre`, `mood`, `grouping` and `language` tags. More tags can be added in the database administration settings, even custom tags. +It is possible to apply global filters on your collection using `genre`, `mood`, `grouping`, `language`, and by music library. More tags, including custom ones, can be added in the database administration settings. -__Note__: you can use the `lms-metadata` tool to have an idea of the tags parsed by _LMS_ using [TagLib](https://github.com/taglib/taglib). +__Note__: You can use the `lms-metadata` tool to get an idea of the tags parsed by _LMS_ using [TagLib](https://github.com/taglib/taglib). + +### Multiple artists +_LMS_ works best when using the default Picard settings, where the `artist` tag contains a single display-friendly value, and the `artists` tag holds the actual artist names. This ensures a cleaner, more organized representation of artist names, when multiple artists are involved. ### Multiple album artists -_LMS_ requires the `albumartists` and `albumartistssort` tags to properly handle multiple album artists on the same album. As they are custom tags, you may need to set up your favorite tagger to add them. +While LMS can manage multiple album artists using the `albumartist` tag, it works better when using the custom `albumartists` and `albumartistssort` tags, similar to how it handles regular artist tags. __Note__: if you use [Picard](https://picard.musicbrainz.org/), add the following script to include these tags: ``` diff --git a/SUBSONIC.md b/SUBSONIC.md index 34a8053a..cc3a6d6f 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -24,6 +24,7 @@ The following extra fields are implemented: * `moods` * `musicBrainzId` * `originalReleaseDate` + * `recordLabels` * `releaseTypes` * `userRating` * `Child` response: diff --git a/src/libs/core/impl/ChildProcess.cpp b/src/libs/core/impl/ChildProcess.cpp index d2956a3d..599bd16b 100644 --- a/src/libs/core/impl/ChildProcess.cpp +++ b/src/libs/core/impl/ChildProcess.cpp @@ -66,36 +66,49 @@ namespace lms::core static std::mutex mutex; std::unique_lock lock{ mutex }; - int pipe[2]; + int pipefd[2]; - int res{ pipe2(pipe, O_NONBLOCK | O_CLOEXEC) }; - if (res < 0) - throw SystemException{ errno, "pipe2 failed!" }; + // Use 'pipe' instead of 'pipe2', more portable + if (pipe(pipefd) < 0) + throw SystemException{ errno, "pipe failed!" }; + + // Manually set the O_NONBLOCK and O_CLOEXEC flags for both ends of the pipe + if (fcntl(pipefd[0], F_SETFL, O_NONBLOCK) == -1) + throw SystemException{ errno, "fcntl failed to set O_NONBLOCK!" }; + + if (fcntl(pipefd[1], F_SETFL, O_NONBLOCK) == -1) + throw SystemException{ errno, "fcntl failed to set O_NONBLOCK!" }; + + if (fcntl(pipefd[0], F_SETFD, FD_CLOEXEC) == -1) + throw SystemException{ errno, "fcntl failed to set FD_CLOEXEC!" }; + + if (fcntl(pipefd[1], F_SETFD, FD_CLOEXEC) == -1) + throw SystemException{ errno, "fcntl failed to set FD_CLOEXEC!" }; - { #if defined(__linux__) && defined(F_SETPIPE_SZ) + { // Just a hint here to prevent the writer from writing too many bytes ahead of the reader constexpr std::size_t pipeSize{ 65536 * 4 }; - if (fcntl(pipe[0], F_SETPIPE_SZ, pipeSize) == -1) + if (fcntl(pipefd[0], F_SETPIPE_SZ, pipeSize) == -1) throw SystemException{ errno, "fcntl failed!" }; - if (fcntl(pipe[1], F_SETPIPE_SZ, pipeSize) == -1) + if (fcntl(pipefd[1], F_SETPIPE_SZ, pipeSize) == -1) throw SystemException{ errno, "fcntl failed!" }; -#endif } +#endif - res = fork(); + int res{ fork() }; if (res == -1) throw SystemException{ errno, "fork failed!" }; if (res == 0) // CHILD { - close(pipe[0]); + close(pipefd[0]); close(STDIN_FILENO); close(STDERR_FILENO); // Replace stdout with pipe write - if (dup2(pipe[1], STDOUT_FILENO) == -1) + if (dup2(pipefd[1], STDOUT_FILENO) == -1) exit(-1); std::vector execArgs; @@ -108,10 +121,10 @@ namespace lms::core } else // PARENT { - close(pipe[1]); + close(pipefd[1]); { boost::system::error_code assignError; - _childStdout.assign(pipe[0], assignError); + _childStdout.assign(pipefd[0], assignError); if (assignError) throw SystemException{ assignError, "fork failed!" }; } diff --git a/src/libs/core/impl/String.cpp b/src/libs/core/impl/String.cpp index a891e766..226caa1d 100644 --- a/src/libs/core/impl/String.cpp +++ b/src/libs/core/impl/String.cpp @@ -97,6 +97,41 @@ namespace lms::core::stringUtils return res; } + + template + std::vector splitString(std::string_view str, std::span separators) + { + std::vector res; + + size_t currentPos{}; + while (currentPos < str.size()) + { + size_t nextSeparatorPos{ std::string_view::npos }; + size_t sepLen{}; + + for (const std::string_view sep : separators) + { + if (sep.empty()) + continue; + + size_t found{ str.find(sep, currentPos) }; + if (found < nextSeparatorPos) + { + nextSeparatorPos = found; + sepLen = sep.size(); + } + } + + if (nextSeparatorPos == std::string_view::npos) + break; + + res.push_back(str.substr(currentPos, nextSeparatorPos - currentPos)); + currentPos = nextSeparatorPos + sepLen; + } + + res.push_back(str.substr(currentPos)); + return res; + } } // namespace details template<> @@ -129,24 +164,17 @@ namespace lms::core::stringUtils std::vector splitString(std::string_view str, std::string_view separator) { - std::vector res; + return splitString(str, std::span(&separator, 1)); + } - if (separator.empty()) - return { str }; + std::vector splitString(std::string_view str, std::span separators) + { + return details::splitString(str, separators); + } - size_t pos{}; - size_t found{ str.find(separator) }; - - while (found != std::string_view::npos) - { - res.push_back(str.substr(pos, found - pos)); - pos = found + separator.size(); - found = str.find(separator, pos); - } - - res.push_back(str.substr(pos)); - - return res; + std::vector splitString(std::string_view str, std::span separators) + { + return details::splitString(str, separators); } std::string joinStrings(std::span strings, std::string_view delimiter) diff --git a/src/libs/core/include/core/String.hpp b/src/libs/core/include/core/String.hpp index 2cf47885..2e1a488b 100644 --- a/src/libs/core/include/core/String.hpp +++ b/src/libs/core/include/core/String.hpp @@ -40,6 +40,8 @@ namespace lms::core::stringUtils { [[nodiscard]] std::vector splitString(std::string_view string, char separator); [[nodiscard]] std::vector splitString(std::string_view string, std::string_view separator); + [[nodiscard]] std::vector splitString(std::string_view string, std::span separators); + [[nodiscard]] std::vector splitString(std::string_view string, std::span separators); [[nodiscard]] std::string joinStrings(std::span strings, std::string_view delimiter); [[nodiscard]] std::string joinStrings(std::span strings, std::string_view delimiter); @@ -65,14 +67,25 @@ namespace lms::core::stringUtils template [[nodiscard]] std::optional readAs(std::string_view str) { - T res; + if constexpr (std::is_enum_v) + { + using UnderlyingType = std::underlying_type_t; + std::optional underlyingValue{ readAs(str) }; + if (!underlyingValue) + return std::nullopt; - std::istringstream iss{ std::string{ str } }; - iss >> res; - if (iss.fail()) - return std::nullopt; + return static_cast(*underlyingValue); + } + else + { + T res; + std::istringstream iss{ std::string{ str } }; + iss >> res; + if (iss.fail()) + return std::nullopt; - return res; + return res; + } } template<> diff --git a/src/libs/core/test/String.cpp b/src/libs/core/test/String.cpp index 142145c8..2e9789ef 100644 --- a/src/libs/core/test/String.cpp +++ b/src/libs/core/test/String.cpp @@ -79,6 +79,9 @@ namespace lms::core::stringUtils::tests TestCase tests[]{ { "", "", { "" } }, + { "//", "//", { "", "" } }, + { "//abc//", "//", { "", "abc", "" } }, + { "//abc////abc//", "//", { "", "abc", "", "abc", "" } }, { "abc", "", { "abc" } }, { "abc", "-", { "abc" } }, { "abc", "b", { "a", "c" } }, @@ -99,6 +102,35 @@ namespace lms::core::stringUtils::tests } } + TEST(StringUtils, splitString_multiStringDelim) + { + struct TestCase + { + std::string_view input; + std::vector delimiters; + std::vector expectedOutput; + }; + + TestCase tests[]{ + { "", { "" }, { "" } }, + { "abc", { "" }, { "abc" } }, + { "abc", { "b" }, { "a", "c" } }, + { "ab/cd", { "/" }, { "ab", "cd" } }, + { "ab/cd", { "/", ";" }, { "ab", "cd" } }, + { "ab;/cd", { "/", ";" }, { "ab", "", "cd" } }, + { "ab;/;cd", { "/", ";" }, { "ab", "", "", "cd" } }, + { "ab/;cd", { "/", ";" }, { "ab", "", "cd" } }, + { "ab/;/cd", { "/", ";" }, { "ab", "", "", "cd" } }, + { "ab/cd/ef", { "/", "cd" }, { "ab", "", "", "ef" } }, + }; + + for (const TestCase& test : tests) + { + const std::vector res{ splitString(test.input, test.delimiters) }; + EXPECT_EQ(res, test.expectedOutput) << "Input = '" << test.input << "'"; + } + } + TEST(StringUtils, joinStrings) { struct TestCase diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index 6e8abadf..1b840fa0 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(lmsdatabase SHARED impl/Track.cpp impl/TrackBookmark.cpp impl/Types.cpp + impl/UIState.cpp impl/User.cpp impl/Utils.cpp ) diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index f9d8e431..b75f5c4b 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -35,7 +35,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 64 }; + static constexpr Version LMS_DATABASE_VERSION{ 67 }; } VersionInfo::VersionInfo() @@ -714,6 +714,53 @@ SELECT session.getDboSession()->execute("DROP INDEX IF EXISTS listen_user_backend_date_time"); } + void migrateFromV64(Session& session) + { + session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "label" ( + "id" integer primary key autoincrement, + "version" integer not null, + "name" text not null +))"); + + session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "release_label" ( + "label_id" bigint, + "release_id" bigint, + primary key ("label_id", "release_id"), + constraint "fk_release_label_key1" foreign key ("label_id") references "label" ("id") on delete cascade deferrable initially deferred, + constraint "fk_release_label_key2" foreign key ("release_id") references "release" ("id") on delete cascade deferrable initially deferred +))"); + session.getDboSession()->execute(R"(CREATE INDEX "release_label_label" on "release_label" ("label_id"))"); + session.getDboSession()->execute(R"(CREATE INDEX "release_label_release" on "release_label" ("release_id"))"); + + // Just increment the scan version of the settings to make the next scheduled scan rescan everything + session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1"); + } + + void migrateFromV65(Session& session) + { + session.getDboSession()->execute("ALTER TABLE release ADD is_compilation BOOLEAN NOT NULL DEFAULT(false)"); + + // Just increment the scan version of the settings to make the next scheduled scan rescan everything + session.getDboSession()->execute("UPDATE scan_settings SET scan_version = scan_version + 1"); + } + + void migrateFromV66(Session& session) + { + // New way of handling UI settings + session.getDboSession()->execute(R"(CREATE TABLE IF NOT EXISTS "ui_state" ( + "id" integer primary key autoincrement, + "version" integer not null, + "item" text not null, + "value" text not null, + "user_id" bigint, + constraint "fk_ui_state_user" foreign key ("user_id") references "user" ("id") on delete cascade deferrable initially deferred +))"); + + session.getDboSession()->execute("ALTER TABLE user DROP COLUMN repeat_all"); + session.getDboSession()->execute("ALTER TABLE user DROP COLUMN radio"); + session.getDboSession()->execute("ALTER TABLE user DROP COLUMN cur_playing_track_pos"); + } + bool doDbMigration(Session& session) { static const std::string outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -754,6 +801,9 @@ SELECT { 61, migrateFromV61 }, { 62, migrateFromV62 }, { 63, migrateFromV63 }, + { 64, migrateFromV64 }, + { 65, migrateFromV65 }, + { 66, migrateFromV66 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/impl/Release.cpp b/src/libs/database/impl/Release.cpp index baea5c02..16f723de 100644 --- a/src/libs/database/impl/Release.cpp +++ b/src/libs/database/impl/Release.cpp @@ -41,6 +41,9 @@ namespace lms::db template Wt::Dbo::Query createQuery(Session& session, std::string_view itemToSelect, const Release::FindParameters& params) { + assert(params.keywords.empty() || params.name.empty()); + assert(!params.directory.isValid() || !params.parentDirectory.isValid()); + auto query{ session.getDboSession()->query("SELECT " + std::string{ itemToSelect } + " from release r") }; if (params.sortMethod == ReleaseSortMethod::ArtistNameThenName @@ -54,11 +57,18 @@ namespace lms::db || params.artist.isValid() || params.clusters.size() == 1 || params.mediaLibrary.isValid() - || params.directory.isValid()) + || params.directory.isValid() + || params.parentDirectory.isValid()) { query.join("track t ON t.release_id = r.id"); } + if (params.parentDirectory.isValid()) + { + query.join("directory d ON t.directory_id = d.id"); + query.where("d.parent_directory_id = ?").bind(params.parentDirectory); + } + if (params.mediaLibrary.isValid()) query.where("t.media_library_id = ?").bind(params.mediaLibrary); @@ -82,6 +92,9 @@ namespace lms::db query.where("COALESCE(CAST(SUBSTR(t.date, 1, 4) AS INTEGER), t.year) <= ?").bind(params.dateRange->end); } + if (!params.name.empty()) + query.where("r.name = ?").bind(params.name); + for (std::string_view keyword : params.keywords) query.where("r.name LIKE ? ESCAPE '" ESCAPE_CHAR_STR "'").bind("%" + utils::escapeLikeKeyword(keyword) + "%"); @@ -216,6 +229,36 @@ namespace lms::db } } // namespace + Label::Label(std::string_view name) + : _name{ name } + { + // As we use the name to uniquely identoify release type, we must throw (and not truncate) + if (name.size() > _maxNameLength) + throw Exception{ "Label name is too long: " + std::string{ name } + "'" }; + } + + Label::pointer Label::create(Session& session, std::string_view name) + { + return session.getDboSession()->add(std::unique_ptr