Stickers: added support for stickers on playlists and some tag types
This commit is contained in:
parent
70ac638d93
commit
432675d4c2
1
NEWS
1
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
|
||||
|
|
|
@ -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 <filter_syntax>`.
|
||||
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 <filter_syntax>`.
|
||||
|
||||
- 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
|
||||
===================
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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<StickerCleanupService>(*this,
|
||||
*sticker_database,
|
||||
*database);
|
||||
sticker_cleanup->Start();
|
||||
}
|
||||
|
||||
#endif // ENABLE_SQLITE
|
||||
|
|
|
@ -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<StickerDatabase> sticker_database;
|
||||
|
||||
std::unique_ptr<StickerCleanupService> 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 */
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
||||
class DomainHandler {
|
||||
public:
|
||||
virtual ~DomainHandler() = default;
|
||||
|
||||
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()) {
|
||||
response.FmtError(ACK_ERROR_NO_EXIST, "no such sticker: \"{}\"", name);
|
||||
return CommandResult::ERROR;
|
||||
}
|
||||
|
||||
sticker_print_value(response, name, value.c_str());
|
||||
|
||||
return CommandResult::OK;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
response.FmtError(ACK_ERROR_NO_EXIST, "no such sticker: \"{}\"", name);
|
||||
return CommandResult::ERROR;
|
||||
}
|
||||
|
||||
return CommandResult::OK;
|
||||
}
|
||||
|
||||
virtual CommandResult List(const char *uri) {
|
||||
const auto sticker = sticker_database.Load(sticker_type,
|
||||
ValidateUri(uri).c_str());
|
||||
sticker_print(response, sticker);
|
||||
|
||||
return CommandResult::OK;
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
auto callback = [](const char *found_uri, const char *found_value, void *user_data) {
|
||||
auto context = reinterpret_cast<CallbackContext *>(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;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
} // 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;
|
||||
auto *data = (struct sticker_song_find_data *)user_data;
|
||||
|
||||
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]);
|
||||
if (value.empty()) {
|
||||
r.Error(ACK_ERROR_NO_EXIST, "no such sticker");
|
||||
return CommandResult::ERROR;
|
||||
}
|
||||
|
||||
sticker_print_value(r, args[3], 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);
|
||||
|
||||
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]);
|
||||
if (!ret) {
|
||||
r.Error(ACK_ERROR_NO_EXIST, "no such sticker");
|
||||
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];
|
||||
|
||||
StickerOperator op = StickerOperator::EXISTS;
|
||||
const char *value = nullptr;
|
||||
|
||||
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],
|
||||
const LightSong* song = nullptr;
|
||||
};
|
||||
|
||||
sticker_song_find(sticker_database, db, base_uri, data.name,
|
||||
op, value,
|
||||
sticker_song_find_print_cb, &data);
|
||||
/**
|
||||
* 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) {
|
||||
}
|
||||
};
|
||||
|
||||
return CommandResult::OK;
|
||||
} else {
|
||||
r.Error(ACK_ERROR_ARG, "bad request");
|
||||
return CommandResult::ERROR;
|
||||
/**
|
||||
* 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<DomainHandler> handler;
|
||||
|
||||
if (StringIsEqual(sticker_type, "song"))
|
||||
handler = std::make_unique<SongHandler>(r, db, sticker_database);
|
||||
|
||||
else if (StringIsEqual(sticker_type, "playlist"))
|
||||
handler = std::make_unique<PlaylistHandler>(r, db, sticker_database);
|
||||
|
||||
else if (StringIsEqual(sticker_type, "filter"))
|
||||
handler = std::make_unique<FilterHandler>(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<TagHandler>(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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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 */
|
||||
|
|
|
@ -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 <cassert>
|
||||
|
||||
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();
|
|
@ -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;
|
|
@ -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<StickerDatabase::StickerTypeUriPair> &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<StickerDatabase::StickerTypeUriPair> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <atomic>
|
||||
|
||||
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;
|
||||
};
|
|
@ -13,6 +13,7 @@
|
|||
#include <cassert>
|
||||
#include <iterator>
|
||||
#include <array>
|
||||
#include <stdexcept>
|
||||
|
||||
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::StickerTypeUriPair>
|
||||
StickerDatabase::GetUniqueStickers()
|
||||
{
|
||||
auto result = std::list<StickerTypeUriPair>{};
|
||||
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<StickerTypeUriPair> &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"});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <list>
|
||||
|
||||
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<std::string, std::string>;
|
||||
|
||||
/**
|
||||
* @return A list of unique type-uri pairs of all the stickers
|
||||
* in the database.
|
||||
*/
|
||||
std::list<StickerTypeUriPair> GetUniqueStickers();
|
||||
|
||||
/**
|
||||
* Delete stickers by type and uri
|
||||
* @param stickers A list of stickers to delete
|
||||
*/
|
||||
void BatchDeleteNoIdle(const std::list<StickerTypeUriPair> &stickers);
|
||||
|
||||
private:
|
||||
void ListValues(std::map<std::string, std::string, std::less<>> &table,
|
||||
const char *type, const char *uri);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright The Music Player Daemon Project
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
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;
|
Loading…
Reference in New Issue