From 432675d4c23450c527333ed4a29d13f843e001da Mon Sep 17 00:00:00 2001 From: gd Date: Sat, 29 Oct 2022 19:58:42 +0300 Subject: [PATCH] Stickers: added support for stickers on playlists and some tag types --- NEWS | 1 + doc/protocol.rst | 72 ++++++ meson.build | 3 + src/Instance.cxx | 66 +++++ src/Instance.hxx | 11 + src/command/PlaylistCommands.cxx | 3 + src/command/StickerCommands.cxx | 412 +++++++++++++++++++++++-------- src/db/PlaylistVector.cxx | 9 + src/db/PlaylistVector.hxx | 3 + src/sticker/AllowedTags.cxx | 44 ++++ src/sticker/AllowedTags.hxx | 11 + src/sticker/CleanupService.cxx | 112 +++++++++ src/sticker/CleanupService.hxx | 53 ++++ src/sticker/Database.cxx | 66 +++++ src/sticker/Database.hxx | 19 ++ src/sticker/TagSticker.cxx | 90 +++++++ src/sticker/TagSticker.hxx | 79 ++++++ 17 files changed, 955 insertions(+), 99 deletions(-) create mode 100644 src/sticker/AllowedTags.cxx create mode 100644 src/sticker/AllowedTags.hxx create mode 100644 src/sticker/CleanupService.cxx create mode 100644 src/sticker/CleanupService.hxx create mode 100644 src/sticker/TagSticker.cxx create mode 100644 src/sticker/TagSticker.hxx diff --git a/NEWS b/NEWS index 086adf379..5ffad60a6 100644 --- a/NEWS +++ b/NEWS @@ -9,6 +9,7 @@ ver 0.24 (not yet released) - operator "starts_with" - show PCRE support in "config" response - apply Unicode normalization to case-insensitive filter expressions + - stickers on playlists and some tag types * database - proxy: require MPD 0.21 or later - proxy: require libmpdclient 2.15 or later diff --git a/doc/protocol.rst b/doc/protocol.rst index 0dde4a05f..a197ef23d 100644 --- a/doc/protocol.rst +++ b/doc/protocol.rst @@ -1401,6 +1401,41 @@ Objects which may have stickers are addressed by their object type ("song" for song objects) and their URI (the path within the database for songs). +.. note:: Since :program:`MPD` 0.24 stickers can also be attached to playlists, + some tag types, and :ref:`filter expressions `. + The following tag types are alloed: Title, Album, Artist, AlbumArtist, Genre, + Composer, Performer, Conductor, Work, Ensable, Location, and Label. + +.. list-table:: Sticker addressing + :widths: 10 45 45 + :header-rows: 2 + + * - Type + - URI + - URI + + * - + - get, set, delete, list, find + - find only + + * - "song" + - File path within the database + - Directory path within the database to find a sticker on + all songs under this path recursively + + * - "playlist" + - The playlist name of a stored playlist + - An empty string to find a sticker in all playlists + + * - Tag type e.g. "Album" + - The tag value + - An empty string to find a sticker in all instances of the tag type + + * - "filter" + - A :ref:`filter expression `. + + - An empty string to find a sticker in all instances of the filter + .. _command_sticker_get: :command:`sticker get {TYPE} {URI} {NAME}` @@ -1441,6 +1476,43 @@ the database for songs). Other supported operators are: "``<``", "``>``" +Examples: + + .. code-block:: + + sitcker set song "path/to/song_1.mp3" "name_1" "value_1" + OK + sitcker set song "path/to/song_2.mp3" "name_1" "value_2" + OK + sticker get song "path/to/song_1.mp3" "name_1" + sticker: name_1=value_1 + OK + sitcker find song "path" "name_1" + file: path/to/song_1.mp3 + sticker: name_1=value_1 + file: path/to/song_2.mp3 + sticker: name_1=value_2 + OK + + .. code-block:: + + sticker set Album "Greatest Hits" "name_1" "value_1" + OK + sticker find Album "" name_1 + Album: Greatest Hits + sticker: name_1=value_1 + OK + stciker set filter "((album == 'Greatest Hits') AND (artist == 'Vera Lynn'))" name_1 value_1 + OK + stciker set filter "((album == 'Greatest Hits') AND (artist == 'Johnny Chester'))" name_1 value_1 + OK + sticker find filter "" name_1 + filter: ((album == 'Greatest Hits') AND (artist == 'Johnny Chester')) + sticker: name_1=value_1 + filter: ((album == 'Greatest Hits') AND (artist == 'Vera Lynn')) + sticker: name_1=value_1 + OK + Connection settings =================== diff --git a/meson.build b/meson.build index 235fa7fb2..92d25d931 100644 --- a/meson.build +++ b/meson.build @@ -454,6 +454,9 @@ if sqlite_dep.found() 'src/sticker/Database.cxx', 'src/sticker/Print.cxx', 'src/sticker/SongSticker.cxx', + 'src/sticker/TagSticker.cxx', + 'src/sticker/AllowedTags.cxx', + 'src/sticker/CleanupService.cxx', ] endif diff --git a/src/Instance.cxx b/src/Instance.cxx index 58fb18ea2..8644c067d 100644 --- a/src/Instance.cxx +++ b/src/Instance.cxx @@ -32,13 +32,21 @@ #ifdef ENABLE_SQLITE #include "sticker/Database.hxx" #include "sticker/SongSticker.hxx" +#include "sticker/TagSticker.hxx" +#include "sticker/CleanupService.hxx" #endif + #endif Instance::Instance() = default; Instance::~Instance() noexcept { +#ifdef ENABLE_SQLITE + if (sticker_cleanup) + sticker_cleanup.reset(); +#endif + #ifdef ENABLE_DATABASE delete update; @@ -105,6 +113,11 @@ Instance::OnDatabaseModified() noexcept for (auto &partition : partitions) partition.DatabaseModified(*database); + +#ifdef ENABLE_SQLITE + if (sticker_database) + StartStickerCleanup(); +#endif } void @@ -186,3 +199,56 @@ Instance::FlushCaches() noexcept if (input_cache) input_cache->Flush(); } + +void +Instance::OnPlaylistDeleted(const char *name) const noexcept +{ +#ifdef ENABLE_SQLITE + /* if the playlist has stickers, remove theme */ + if (HasStickerDatabase()) { + try { + sticker_database->Delete("playlist", name); + } catch (...) { + } + } +#endif +} + +#ifdef ENABLE_SQLITE + +void +Instance::OnStickerCleanupDone(bool changed) noexcept +{ + assert(event_loop.IsInside()); + + sticker_cleanup.reset(); + + if (changed) + EmitIdle(IDLE_STICKER); + + if (need_sticker_cleanup) + StartStickerCleanup(); +} + +void +Instance::StartStickerCleanup() +{ + assert(sticker_database != nullptr); + + if (sticker_cleanup) { + /* still runnning, start a new one when that one + finishes*/ + need_sticker_cleanup = true; + return; + } + + need_sticker_cleanup = false; + + sticker_cleanup = + std::make_unique(*this, + *sticker_database, + *database); + sticker_cleanup->Start(); +} + +#endif // ENABLE_SQLITE diff --git a/src/Instance.hxx b/src/Instance.hxx index 6b14d8476..4a27ab808 100644 --- a/src/Instance.hxx +++ b/src/Instance.hxx @@ -25,6 +25,7 @@ class NeighborGlue; #ifdef ENABLE_DATABASE #include "db/DatabaseListener.hxx" #include "db/Ptr.hxx" + class Storage; class UpdateService; #ifdef ENABLE_INOTIFY @@ -40,6 +41,7 @@ struct Partition; class StateFile; class RemoteTagCache; class StickerDatabase; +class StickerCleanupService; class InputCacheManager; /** @@ -125,6 +127,10 @@ struct Instance final #ifdef ENABLE_SQLITE std::unique_ptr sticker_database; + + std::unique_ptr sticker_cleanup; + + bool need_sticker_cleanup = false; #endif Instance(); @@ -187,6 +193,9 @@ struct Instance final bool HasStickerDatabase() const noexcept { return sticker_database != nullptr; } + + void OnStickerCleanupDone(bool changed) noexcept; + void StartStickerCleanup(); #endif void BeginShutdownUpdate() noexcept; @@ -201,6 +210,8 @@ struct Instance final void FlushCaches() noexcept; + void OnPlaylistDeleted(const char *name) const noexcept; + private: #ifdef ENABLE_DATABASE /* virtual methods from class DatabaseListener */ diff --git a/src/command/PlaylistCommands.cxx b/src/command/PlaylistCommands.cxx index 593a4d01e..65dbe4044 100644 --- a/src/command/PlaylistCommands.cxx +++ b/src/command/PlaylistCommands.cxx @@ -155,6 +155,9 @@ handle_rm([[maybe_unused]] Client &client, Request args, [[maybe_unused]] Respon const char *const name = args.front(); spl_delete(name); + + client.GetInstance().OnPlaylistDeleted(name); + return CommandResult::OK; } diff --git a/src/command/StickerCommands.cxx b/src/command/StickerCommands.cxx index dbf8e6b0f..800114efa 100644 --- a/src/command/StickerCommands.cxx +++ b/src/command/StickerCommands.cxx @@ -6,7 +6,10 @@ #include "SongPrint.hxx" #include "db/Interface.hxx" #include "sticker/Sticker.hxx" +#include "sticker/TagSticker.hxx" +#include "sticker/Database.hxx" #include "sticker/SongSticker.hxx" +#include "sticker/AllowedTags.hxx" #include "sticker/Print.hxx" #include "client/Client.hxx" #include "client/Response.hxx" @@ -14,133 +17,285 @@ #include "Instance.hxx" #include "util/StringAPI.hxx" #include "util/ScopeExit.hxx" +#include "tag/ParseName.hxx" +#include "tag/Names.hxx" +#include "sticker/TagSticker.hxx" +#include "song/LightSong.hxx" +#include "PlaylistFile.hxx" +#include "db/PlaylistInfo.hxx" +#include "db/PlaylistVector.hxx" +#include "db/DatabaseLock.hxx" +#include "song/Filter.hxx" namespace { -struct sticker_song_find_data { - Response &r; - const char *name; -}; -} // namespace -static void -sticker_song_find_print_cb(const LightSong &song, const char *value, - void *user_data) -{ - auto *data = - (struct sticker_song_find_data *)user_data; +class DomainHandler { +public: + virtual ~DomainHandler() = default; - song_print_uri(data->r, song); - sticker_print_value(data->r, data->name, value); -} - -static CommandResult -handle_sticker_song(Response &r, Partition &partition, - StickerDatabase &sticker_database, - Request args) -{ - const Database &db = partition.GetDatabaseOrThrow(); - - const char *const cmd = args.front(); - - /* get song song_id key */ - if (args.size() == 4 && StringIsEqual(cmd, "get")) { - const LightSong *song = db.GetSong(args[2]); - assert(song != nullptr); - AtScopeExit(&db, song) { db.ReturnSong(song); }; - - const auto value = sticker_song_get_value(sticker_database, - *song, args[3]); + virtual CommandResult Get(const char *uri, const char *name) { + const auto value = sticker_database.LoadValue(sticker_type, + ValidateUri(uri).c_str(), + name); if (value.empty()) { - r.Error(ACK_ERROR_NO_EXIST, "no such sticker"); + response.FmtError(ACK_ERROR_NO_EXIST, "no such sticker: \"{}\"", name); return CommandResult::ERROR; } - sticker_print_value(r, args[3], value.c_str()); + sticker_print_value(response, name, value.c_str()); return CommandResult::OK; - /* list song song_id */ - } else if (args.size() == 3 && StringIsEqual(cmd, "list")) { - const LightSong *song = db.GetSong(args[2]); - assert(song != nullptr); - AtScopeExit(&db, song) { db.ReturnSong(song); }; + } - const auto sticker = sticker_song_get(sticker_database, *song); - sticker_print(r, sticker); + virtual CommandResult Set(const char *uri, const char *name, const char *value) { + sticker_database.StoreValue(sticker_type, + ValidateUri(uri).c_str(), + name, + value); return CommandResult::OK; - /* set song song_id id key */ - } else if (args.size() == 5 && StringIsEqual(cmd, "set")) { - const LightSong *song = db.GetSong(args[2]); - assert(song != nullptr); - AtScopeExit(&db, song) { db.ReturnSong(song); }; + } - sticker_song_set_value(sticker_database, *song, - args[3], args[4]); - return CommandResult::OK; - /* delete song song_id [key] */ - } else if ((args.size() == 3 || args.size() == 4) && - StringIsEqual(cmd, "delete")) { - const LightSong *song = db.GetSong(args[2]); - assert(song != nullptr); - AtScopeExit(&db, song) { db.ReturnSong(song); }; - - bool ret = args.size() == 3 - ? sticker_song_delete(sticker_database, *song) - : sticker_song_delete_value(sticker_database, *song, - args[3]); + virtual CommandResult Delete(const char *uri, const char *name) { + std::string validated_uri = ValidateUri(uri); + uri = validated_uri.c_str(); + bool ret = name == nullptr + ? sticker_database.Delete(sticker_type, uri) + : sticker_database.DeleteValue(sticker_type, uri, name); if (!ret) { - r.Error(ACK_ERROR_NO_EXIST, "no such sticker"); + response.FmtError(ACK_ERROR_NO_EXIST, "no such sticker: \"{}\"", name); return CommandResult::ERROR; } return CommandResult::OK; - /* find song dir key */ - } else if ((args.size() == 4 || args.size() == 6) && - StringIsEqual(cmd, "find")) { - /* "sticker find song a/directory name" */ + } - const char *const base_uri = args[2]; + virtual CommandResult List(const char *uri) { + const auto sticker = sticker_database.Load(sticker_type, + ValidateUri(uri).c_str()); + sticker_print(response, sticker); - StickerOperator op = StickerOperator::EXISTS; - const char *value = nullptr; + return CommandResult::OK; + } - if (args.size() == 6) { - /* match the value */ - - const char *op_s = args[4]; - value = args[5]; - - if (StringIsEqual(op_s, "=")) - op = StickerOperator::EQUALS; - else if (StringIsEqual(op_s, "<")) - op = StickerOperator::LESS_THAN; - else if (StringIsEqual(op_s, ">")) - op = StickerOperator::GREATER_THAN; - else { - r.Error(ACK_ERROR_ARG, "bad operator"); - return CommandResult::ERROR; - } - } - - struct sticker_song_find_data data = { - r, - args[3], + virtual CommandResult Find(const char *uri, const char *name, StickerOperator op, const char *value) { + auto data = CallbackContext{ + .name = name, + .sticker_type = sticker_type, + .response = response, + .is_song = StringIsEqual("song", sticker_type) }; - sticker_song_find(sticker_database, db, base_uri, data.name, + auto callback = [](const char *found_uri, const char *found_value, void *user_data) { + auto context = reinterpret_cast(user_data); + context->response.Fmt("{}: {}\n", + context->is_song ? "file" : context->sticker_type, found_uri); + sticker_print_value(context->response, context->name, found_value); + }; + + sticker_database.Find(sticker_type, + uri, + name, + op, value, + callback, &data); + + return CommandResult::OK; + } + +protected: + DomainHandler(Response &_response, + const Database &_database, + StickerDatabase &_sticker_database, + const char *_sticker_type) : + sticker_type(_sticker_type), + response(_response), + database(_database), + sticker_database(_sticker_database) { + } + + /** + * Validate the command uri or throw std::runtime_error if not valid. + * + * @param uri the uri from the sticker command + * + * @return the uri to use in the sticker database query + */ + virtual std::string ValidateUri(const char *uri) { + return {uri}; + } + + const char *const sticker_type; + Response &response; + const Database &database; + StickerDatabase &sticker_database; + +private: + struct CallbackContext { + const char *const name; + const char *const sticker_type; + Response &response; + const bool is_song; + }; +}; + +/** + * 'song' stickers handler + */ +class SongHandler final : public DomainHandler { +public: + SongHandler(Response &_response, + const Database &_database, + StickerDatabase &_sticker_database) : + DomainHandler(_response, _database, _sticker_database, "song") { + } + + ~SongHandler() override { + if (song != nullptr) + database.ReturnSong(song); + } + + CommandResult Find(const char *uri, const char *name, StickerOperator op, const char *value) override { + struct sticker_song_find_data data = { + response, + name, + }; + + sticker_song_find(sticker_database, database, uri, data.name, op, value, sticker_song_find_print_cb, &data); return CommandResult::OK; - } else { - r.Error(ACK_ERROR_ARG, "bad request"); - return CommandResult::ERROR; } -} + +protected: + std::string ValidateUri(const char *uri) override { + // will throw if song uri not found + song = database.GetSong(uri); + assert(song != nullptr); + return song->GetURI(); + } + +private: + struct sticker_song_find_data { + Response &r; + const char *name; + }; + + static void + sticker_song_find_print_cb(const LightSong &song, const char *value, + void *user_data) + { + auto *data = (struct sticker_song_find_data *)user_data; + + song_print_uri(data->r, song); + sticker_print_value(data->r, data->name, value); + } + + const LightSong* song = nullptr; +}; + +/** + * Base for Tag and Filter handlers + */ +class SelectionHandler : public DomainHandler { +protected: + SelectionHandler(Response &_response, + const Database &_database, + StickerDatabase &_sticker_database, + const char* _sticker_type) : + DomainHandler(_response, _database, _sticker_database, _sticker_type) { + } +}; + +/** + * Tag type stickers handler + */ +class TagHandler : public SelectionHandler { +public: + TagHandler(Response &_response, + const Database &_database, + StickerDatabase &_sticker_database, + TagType _tag_type) : + SelectionHandler(_response, _database, _sticker_database, tag_item_names[_tag_type]), + tag_type(_tag_type) { + + assert(tag_type != TAG_NUM_OF_ITEM_TYPES); + } + +protected: + std::string ValidateUri(const char *uri) override { + if (tag_type == TAG_NUM_OF_ITEM_TYPES) + throw std::invalid_argument(fmt::format("no such tag: \"{}\"", sticker_type)); + + if (!sticker_allowed_tags.Test(tag_type)) + throw std::invalid_argument(fmt::format("unsupported tag: \"{}\"", sticker_type)); + + if (!TagExists(database, tag_type, uri)) + throw std::invalid_argument(fmt::format("no such {}: \"{}\"", sticker_type, uri)); + + return {uri}; + } + +private: + const TagType tag_type; +}; + +/** + * Filter stickers handler + * + * The URI is parsed as a SongFilter + */ +class FilterHandler : public SelectionHandler { +public: + FilterHandler(Response &_response, + const Database &_database, + StickerDatabase &_sticker_database) : + SelectionHandler(_response, _database, _sticker_database, "filter") { + } + +protected: + std::string ValidateUri(const char *uri) override { + + auto filter = MakeSongFilter(uri); + + auto normalized = filter.ToExpression(); + + if (!FilterMatches(database, filter)) + throw std::invalid_argument(fmt::format("no matches found: \"{}\"", normalized)); + + return normalized; + } +}; + +/** + * playlist stickers handler + */ +class PlaylistHandler : public DomainHandler { +public: + PlaylistHandler(Response &_response, + const Database &_database, + StickerDatabase &_sticker_database) : + DomainHandler(_response, _database, _sticker_database, "playlist") { + } + +private: + std::string ValidateUri(const char *uri) override { + PlaylistVector playlists = ListPlaylistFiles(); + + const ScopeDatabaseLock protect; + if (!playlists.exists(uri)) + throw std::invalid_argument(fmt::format("no such playlist: \"{}\"", uri)); + + return {uri}; + } +}; + +} // namespace CommandResult handle_sticker(Client &client, Request args, Response &r) { + // must be enforced by the caller assert(args.size() >= 3); auto &instance = client.GetInstance(); @@ -149,14 +304,73 @@ handle_sticker(Client &client, Request args, Response &r) return CommandResult::ERROR; } + auto &db = client.GetPartition().GetDatabaseOrThrow(); auto &sticker_database = *instance.sticker_database; - if (StringIsEqual(args[1], "song")) - return handle_sticker_song(r, client.GetPartition(), - sticker_database, - args); + auto cmd = args.front(); + auto sticker_type = args[1]; + auto uri = args[2]; + auto sticker_name = args.GetOptional(3); + + std::unique_ptr handler; + + if (StringIsEqual(sticker_type, "song")) + handler = std::make_unique(r, db, sticker_database); + + else if (StringIsEqual(sticker_type, "playlist")) + handler = std::make_unique(r, db, sticker_database); + + else if (StringIsEqual(sticker_type, "filter")) + handler = std::make_unique(r, db, sticker_database); + + // allow tags in the command to be case insensitive + // the handler will normalize the tag name with tag_item_names() + else if (auto tag_type = tag_name_parse_i(sticker_type); tag_type != TAG_NUM_OF_ITEM_TYPES) + handler = std::make_unique(r, db, sticker_database, tag_type); + else { - r.Error(ACK_ERROR_ARG, "unknown sticker domain"); + r.FmtError(ACK_ERROR_ARG, "unknown sticker domain \"{}\"", sticker_type); return CommandResult::ERROR; } + + /* get */ + if (args.size() == 4 && StringIsEqual(cmd, "get")) + return handler->Get(uri, sticker_name); + + /* list */ + if (args.size() == 3 && StringIsEqual(cmd, "list")) + return handler->List(uri); + + /* set */ + if (args.size() == 5 && StringIsEqual(cmd, "set")) + return handler->Set(uri, sticker_name, args[4]); + + /* delete */ + if ((args.size() == 3 || args.size() == 4) && StringIsEqual(cmd, "delete")) + return handler->Delete(uri, sticker_name); + + /* find */ + if ((args.size() == 4 || args.size() == 6) && StringIsEqual(cmd, "find")) { + bool has_op = args.size() > 4; + auto value = has_op ? args[5] : nullptr; + StickerOperator op = StickerOperator::EXISTS; + if (has_op) { + /* match the value */ + auto op_s = args[4]; + if (StringIsEqual(op_s, "=")) + op = StickerOperator::EQUALS; + else if (StringIsEqual(op_s, "<")) + op = StickerOperator::LESS_THAN; + else if (StringIsEqual(op_s, ">")) + op = StickerOperator::GREATER_THAN; + else { + r.FmtError(ACK_ERROR_ARG, "bad operator \"{}\"", op_s); + return CommandResult::ERROR; + } + } + return handler->Find(uri, sticker_name, op, value); + } + + r.Error(ACK_ERROR_ARG, "bad request"); + return CommandResult::ERROR; } diff --git a/src/db/PlaylistVector.cxx b/src/db/PlaylistVector.cxx index 2a96b7014..f1aa0a287 100644 --- a/src/db/PlaylistVector.cxx +++ b/src/db/PlaylistVector.cxx @@ -48,3 +48,12 @@ PlaylistVector::erase(std::string_view name) noexcept erase(i); return true; } + +bool +PlaylistVector::exists(std::string_view name) const noexcept +{ + assert(holding_db_lock()); + + return std::find_if(begin(), end(), + PlaylistInfo::CompareName(name)) != end(); +} \ No newline at end of file diff --git a/src/db/PlaylistVector.hxx b/src/db/PlaylistVector.hxx index 91c562e68..f03392e5f 100644 --- a/src/db/PlaylistVector.hxx +++ b/src/db/PlaylistVector.hxx @@ -35,6 +35,9 @@ public: * Caller must lock the #db_mutex. */ bool erase(std::string_view name) noexcept; + + [[nodiscard]] + bool exists(std::string_view name) const noexcept; }; #endif /* SONGVEC_H */ diff --git a/src/sticker/AllowedTags.cxx b/src/sticker/AllowedTags.cxx new file mode 100644 index 000000000..0deec7672 --- /dev/null +++ b/src/sticker/AllowedTags.cxx @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#include "AllowedTags.hxx" +#include "tag/Mask.hxx" + +#include + +static constexpr TagType sticker_allowed_tags_init[] = { + TAG_ARTIST, + TAG_ALBUM, + TAG_ALBUM_ARTIST, + TAG_TITLE, + TAG_GENRE, + TAG_COMPOSER, + TAG_PERFORMER, + TAG_CONDUCTOR, + TAG_WORK, + TAG_ENSEMBLE, + TAG_LOCATION, + TAG_LABEL, + TAG_MUSICBRAINZ_ARTISTID, + TAG_MUSICBRAINZ_ALBUMID, + TAG_MUSICBRAINZ_ALBUMARTISTID, + TAG_MUSICBRAINZ_RELEASETRACKID, + TAG_MUSICBRAINZ_WORKID, +}; + +static constexpr auto +TagArrayToMask() noexcept +{ + auto result = TagMask::None(); + + for (const auto i : sticker_allowed_tags_init) { + /* no duplicates allowed */ + assert(!result.Test(i)); + + result |= i; + } + + return result; +} + +constinit const TagMask sticker_allowed_tags = TagArrayToMask(); diff --git a/src/sticker/AllowedTags.hxx b/src/sticker/AllowedTags.hxx new file mode 100644 index 000000000..db04bab07 --- /dev/null +++ b/src/sticker/AllowedTags.hxx @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#pragma once + +class TagMask; + +/** + * These are the tags that are allowed to have stickers. + */ +extern const TagMask sticker_allowed_tags; diff --git a/src/sticker/CleanupService.cxx b/src/sticker/CleanupService.cxx new file mode 100644 index 000000000..a7b3aedf8 --- /dev/null +++ b/src/sticker/CleanupService.cxx @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#include "CleanupService.hxx" +#include "Database.hxx" +#include "Sticker.hxx" +#include "TagSticker.hxx" +#include "lib/fmt/ExceptionFormatter.hxx" +#include "song/Filter.hxx" +#include "thread/Name.hxx" +#include "util/Domain.hxx" +#include "Log.hxx" +#include "Instance.hxx" + +static constexpr Domain sticker_domain{"sticker"}; + +StickerCleanupService::StickerCleanupService(Instance &_instance, + StickerDatabase &_sticker_db, + Database &_db) noexcept + :instance(_instance), + sticker_db(_sticker_db.Reopen()), + music_db(_db), + defer(_instance.event_loop, BIND_THIS_METHOD(RunDeferred)) +{ +} + +StickerCleanupService::~StickerCleanupService() noexcept +{ + // call only by the owning instance + assert(GetEventLoop().IsInside()); + + CancelAndJoin(); +} + +void +StickerCleanupService::Start() +{ + // call only by the owning instance + assert(GetEventLoop().IsInside()); + + thread.Start(); + + FmtDebug(sticker_domain, + "spawned thread for cleanup job"); +} + +void +StickerCleanupService::RunDeferred() noexcept +{ + instance.OnStickerCleanupDone(deleted_count != 0); +} + +static std::size_t +DeleteStickers(StickerDatabase &sticker_db, + std::list &stickers) +{ + if (stickers.empty()) + return 0; + sticker_db.BatchDeleteNoIdle(stickers); + auto count = stickers.size(); + stickers.clear(); + return count; +} + +void +StickerCleanupService::Task() noexcept +{ + SetThreadName("sticker"); + + FmtDebug(sticker_domain, "begin cleanup"); + + try { + auto stickers = sticker_db.GetUniqueStickers(); + auto iter = stickers.cbegin(); + std::list batch; + while (!cancel_flag && !stickers.empty()) { + const auto &[sticker_type, sticker_uri] = *iter; + + const auto filter = MakeSongFilterNoThrow(sticker_type.c_str(), sticker_uri.c_str()); + + if (filter.IsEmpty() || FilterMatches(music_db, filter)) + // skip if found a match or if not a valid sticker filter + iter = stickers.erase(iter); + else { + batch.splice(batch.end(), stickers, iter++); + if (batch.size() == DeleteBatchSize) + deleted_count += DeleteStickers(sticker_db, batch); + } + } + + if (!cancel_flag) + deleted_count += DeleteStickers(sticker_db, batch); + } catch (...) { + FmtError(sticker_domain, "cleanup failed: {}", + std::current_exception()); + } + + defer.Schedule(); + + FmtDebug(sticker_domain, "end cleanup: {} stickers deleted", + deleted_count); +} + +void +StickerCleanupService::CancelAndJoin() noexcept +{ + if (thread.IsDefined()) { + cancel_flag = true; + thread.Join(); + } +} + diff --git a/src/sticker/CleanupService.hxx b/src/sticker/CleanupService.hxx new file mode 100644 index 000000000..a50332b6d --- /dev/null +++ b/src/sticker/CleanupService.hxx @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#pragma once + +#include "Database.hxx" +#include "event/InjectEvent.hxx" +#include "thread/Thread.hxx" + +#include + +class Database; +struct Instance; + +/** + * Delete stickers that no longer match items in the music database. + * + * When done calls Instance::OnSickerCleanupDone() in the instance event loop. + */ +class StickerCleanupService { + /** + * number of stickers to delete in one transaction + */ + static constexpr std::size_t DeleteBatchSize = 50; + + Instance &instance; + StickerDatabase sticker_db; + Database &music_db; + Thread thread{BIND_THIS_METHOD(Task)}; + InjectEvent defer; + std::size_t deleted_count{0}; + std::atomic_bool cancel_flag{false}; + +public: + StickerCleanupService(Instance &_instance, + StickerDatabase &_sticker_db, + Database &_db) noexcept; + + ~StickerCleanupService() noexcept; + + auto &GetEventLoop() const noexcept { + return defer.GetEventLoop(); + } + + void Start(); + +private: + void Task() noexcept; + + void RunDeferred() noexcept; + + void CancelAndJoin() noexcept; +}; diff --git a/src/sticker/Database.cxx b/src/sticker/Database.cxx index 39d734789..2c78fe997 100644 --- a/src/sticker/Database.cxx +++ b/src/sticker/Database.cxx @@ -13,6 +13,7 @@ #include #include #include +#include using namespace Sqlite; @@ -27,6 +28,11 @@ enum sticker_sql { STICKER_SQL_FIND_VALUE, STICKER_SQL_FIND_LT, STICKER_SQL_FIND_GT, + STICKER_SQL_DISTINCT_TYPE_URI, + STICKER_SQL_TRANSACTION_BEGIN, + STICKER_SQL_TRANSACTION_COMMIT, + STICKER_SQL_TRANSACTION_ROLLBACK, + STICKER_SQL_COUNT }; @@ -54,6 +60,18 @@ static constexpr auto sticker_sql = std::array { //[STICKER_SQL_FIND_GT] = "SELECT uri,value FROM sticker WHERE type=? AND uri LIKE (? || '%') AND name=? AND value>?", + + //[STICKER_SQL_DISTINCT_TYPE_URI] = + "SELECT DISTINCT type,uri FROM sticker", + + //[STICKER_SQL_TRANSACTION_BEGIN] + "BEGIN", + + //[STICKER_SQL_TRANSACTION_COMMIT] + "COMMIT", + + //[STICKER_SQL_TRANSACTION_ROLLBACK] + "ROLLBACK", }; static constexpr const char sticker_sql_create[] = @@ -331,3 +349,51 @@ StickerDatabase::Find(const char *type, const char *base_uri, const char *name, user_data); }); } + +std::list +StickerDatabase::GetUniqueStickers() +{ + auto result = std::list{}; + sqlite3_stmt *const s = stmt[STICKER_SQL_DISTINCT_TYPE_URI]; + assert(s != nullptr); + AtScopeExit(s) { + sqlite3_reset(s); + }; + ExecuteForEach(s, [&s, &result]() { + result.emplace_back((const char*)sqlite3_column_text(s, 0), + (const char*)sqlite3_column_text(s, 1)); + }); + return result; +} + +void +StickerDatabase::BatchDeleteNoIdle(const std::list &stickers) +{ + sqlite3_stmt *const s = stmt[STICKER_SQL_DELETE]; + + sqlite3_stmt *const begin = stmt[STICKER_SQL_TRANSACTION_BEGIN]; + sqlite3_stmt *const rollback = stmt[STICKER_SQL_TRANSACTION_ROLLBACK]; + sqlite3_stmt *const commit = stmt[STICKER_SQL_TRANSACTION_COMMIT]; + + try { + ExecuteBusy(begin); + + for (auto &sticker: stickers) { + AtScopeExit(s) { + sqlite3_reset(s); + sqlite3_clear_bindings(s); + }; + + BindAll(s, sticker.first.c_str(), sticker.second.c_str()); + + ExecuteCommand(s); + } + + ExecuteBusy(commit); + } catch (...) { + // "If the transaction has already been rolled back automatically by the error response, + // then the ROLLBACK command will fail with an error, but no harm is caused by this." + ExecuteBusy(rollback); + std::throw_with_nested(std::runtime_error{"failed to batch-delete stickers"}); + } +} diff --git a/src/sticker/Database.hxx b/src/sticker/Database.hxx index 512b9acd0..c4a3cceef 100644 --- a/src/sticker/Database.hxx +++ b/src/sticker/Database.hxx @@ -33,6 +33,7 @@ #include #include +#include class Path; struct Sticker; @@ -49,6 +50,10 @@ class StickerDatabase { SQL_FIND_VALUE, SQL_FIND_LT, SQL_FIND_GT, + SQL_DISTINCT_TYPE_URI, + SQL_TRANSACTION_BEGIN, + SQL_TRANSACTION_COMMIT, + SQL_TRANSACTION_ROLLBACK, SQL_COUNT }; @@ -141,6 +146,20 @@ public: void *user_data), void *user_data); + using StickerTypeUriPair = std::pair; + + /** + * @return A list of unique type-uri pairs of all the stickers + * in the database. + */ + std::list GetUniqueStickers(); + + /** + * Delete stickers by type and uri + * @param stickers A list of stickers to delete + */ + void BatchDeleteNoIdle(const std::list &stickers); + private: void ListValues(std::map> &table, const char *type, const char *uri); diff --git a/src/sticker/TagSticker.cxx b/src/sticker/TagSticker.cxx new file mode 100644 index 000000000..2c0884411 --- /dev/null +++ b/src/sticker/TagSticker.cxx @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#include "TagSticker.hxx" +#include "Database.hxx" +#include "AllowedTags.hxx" +#include "db/Interface.hxx" +#include "db/Selection.hxx" +#include "tag/Mask.hxx" +#include "tag/Names.hxx" +#include "tag/ParseName.hxx" +#include "song/Filter.hxx" +#include "util/StringAPI.hxx" + +SongFilter +MakeSongFilter(const char *filter_string) +{ + const std::array args{filter_string}; + + auto filter = SongFilter(); + filter.Parse(args, false); + filter.Optimize(); + + return filter; +} + +SongFilter +MakeSongFilter(TagType tag_type, const char *tag_value) +{ + if (!sticker_allowed_tags.Test(tag_type)) + throw std::runtime_error("tag type not allowed for sticker"); + + const std::array args{tag_item_names[tag_type], tag_value}; + + SongFilter filter; + filter.Parse(args, false); + filter.Optimize(); + return filter; +} + +SongFilter +MakeSongFilter(const char *sticker_type, const char *sticker_uri) +{ + if (StringIsEqual(sticker_type, "filter")) + return MakeSongFilter(sticker_uri); + + if (auto tag_type = tag_name_parse_i(sticker_type); tag_type != TAG_NUM_OF_ITEM_TYPES) + return MakeSongFilter(tag_type, sticker_uri); + + return {}; +} + +SongFilter +MakeSongFilterNoThrow(const char *sticker_type, const char *sticker_uri) noexcept +{ + try { + return MakeSongFilter(sticker_type, sticker_uri); + } catch (...) { + return {}; + } +} + +bool +TagExists(const Database &database, TagType tag_type, const char *tag_value) +{ + return FilterMatches(database, MakeSongFilter(tag_type, tag_value)); +} + +bool +FilterMatches(const Database &database, const SongFilter &filter) noexcept +{ + if (filter.IsEmpty()) + return false; + + const DatabaseSelection selection{"", true, &filter}; + + // TODO: we just need to know if the tag selection has a match. + // a visitor callback that can stop the db visit by return value + // may be cleaner than throwing an exception. + struct MatchFoundException {}; + try { + database.Visit(selection, [](const LightSong &) { + throw MatchFoundException{}; + }); + } catch (MatchFoundException) { + return true; + } + + return false; +} diff --git a/src/sticker/TagSticker.hxx b/src/sticker/TagSticker.hxx new file mode 100644 index 000000000..c25be5a05 --- /dev/null +++ b/src/sticker/TagSticker.hxx @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#pragma once + +#include + +enum TagType : uint8_t; +class Database; +class StickerDatabase; +class SongFilter; + +/** + * Parse a filter_string. + * + * @param filter_string a valid filter expression + * + * @return SongFilter + * + * @throws std::runtime_error if failed to parse filter string + */ +SongFilter +MakeSongFilter(const char *filter_string); + +/** + * Make a song filter from tag and value e.g. album name + * + * @return SongFilter + * + * @throws std::runtime_error if failed to make song filter or tag type not allowd for sticker + */ +SongFilter +MakeSongFilter(TagType tag_type, const char *tag_value); + +/** + * Make a song filter by sticker type and uri + * + * @param sticker_type either one of the allowed tag names or "filter" + * @param sticker_uri if the type is a tag name then this is the value, + * if the type if "filter" then this is a filter expression + * + * @return SongFilter + * + * @throws std::runtime_error if failed to make song filter or tag type not allowd for sticker + */ +SongFilter +MakeSongFilter(const char *sticker_type, const char *sticker_uri); + +/** + * Like MakeSongFilter(const char *sticker_type, const char *sticker_uri) + * but return an empty filter instead of throwing + * + * @param sticker_type + * @param sticker_uri + * + * @return SongFilter + */ +SongFilter +MakeSongFilterNoThrow(const char *sticker_type, const char *sticker_uri) noexcept; + +/** + * Try to make a selection on the database using the tag type and value + * from a sticker command. + * + * @return true if the selection returned at least one match or false otherwise + * + * @throws std::runtime_error if failed to make song filter + */ +bool +TagExists(const Database &database, TagType tag_type, const char *tag_value); + +/** + * Try to make a selection on the database using a filter + * from a sticker command. + * + * @return true if the selection returned at least one match or false otherwise + */ +bool +FilterMatches(const Database &database, const SongFilter &filter) noexcept;