diff --git a/doc/mpdscribble.1 b/doc/mpdscribble.1 index 1cee7e3..ea0ac39 100644 --- a/doc/mpdscribble.1 +++ b/doc/mpdscribble.1 @@ -126,6 +126,56 @@ Your Last.fm password, either cleartext or its MD5 sum. The file where mpdscribble should store its journal in case you do not have a connection to the scrobbler. This option used to be called "cache". It is optional. +.TP +.B ignore = FILE +Include an ignore file for this scrobbler to exclude tracks from scrobbling. + +.SH IGNORE FILE FORMAT +Tracks can be ignored by listing them in an \fBignore file\fP. +Each line in the file specifies a pattern to match tracks you wish to exclude from scrobbling. +The format is simple and flexible, allowing you to match by artist, album, title, track number or a combination of these fields. +.SS File Format +A tag match is specified as tagname="value", where tagname is replaced with one of the supported tags (artist, album, title, track). +Values \fBmust\fP be quoted and spaces are not allowed surrounding the equal sign. + +Each line consists of one or more tag matches separated by spaces: +.nf +.in +4 +tag1="value1" tag2="value2" ... +.in +.fi + +Tags can appear in any order and blank lines are ignored. + +A backslash (\e) is interpreted as escape character and may be used to escape literal double quotes within a value. +To write a literal backslash, use a double backslash (\e\e). + +Each line is limited to 4096 characters including the newline character. +Superflous characters are silently ignored. + +.SS Examples +If a tag is omitted, any value for that field is matched: +.TP +.B title="Bohemian Rhapsody" +Matches any track titled \fIBohemian Rhapsody\fP. +.TP +.B artist="Queen" +Matches any track by \fIQueen\fP, regardless of album or title. +.TP +.B artist="Queen" album="A Night at the Opera" +Matches any track by \fIQueen\fP from \fIA Night at the Opera\fP, regardless of title. +.TP +.B artist="Queen" album="A Night at the Opera" title="Bohemian Rhapsody" +Matches a specific track by \fIQueen\fP, from \fIA Night at the Opera\fP, titled \fIBohemian Rhapsody\fP. +.TP +.B artist="Queen" album="A Night at the Opera" track="01" +Matches the first track on the album \fIA Night at the Opera\fP by \fIQueen\fP. +Note that track tags are interpreted as text and not numbers, meaning "01" is not the same as "1". +.TP +.B artist="Clark \e"Plazmataz\e" Powell" +Matches tracks by \fIClark "Plazmataz" Powell\fP. + + .SH FILES .I /etc/mpdscribble.conf .RS diff --git a/doc/mpdscribble.conf b/doc/mpdscribble.conf index 2e9917f..79a04c6 100644 --- a/doc/mpdscribble.conf +++ b/doc/mpdscribble.conf @@ -37,6 +37,8 @@ password = # The file where mpdscribble should store its Last.fm journal in case # you do not have a connection to the Last.fm server. journal = /var/cache/mpdscribble/lastfm.journal +# Optional ignore file, see manpage for details! +#ignore = /etc/mpdscribble_lastfm.ignore #[libre.fm] #url = http://turtle.libre.fm/ diff --git a/meson.build b/meson.build index 76ca8a9..97ecebe 100644 --- a/meson.build +++ b/meson.build @@ -208,6 +208,7 @@ executable( 'src/MpdObserver.cxx', 'src/Log.cxx', 'src/XdgBaseDirectory.cxx', + 'src/IgnoreList.cxx', include_directories: inc, dependencies: [ diff --git a/src/Config.hxx b/src/Config.hxx index 1819c42..77f7f83 100644 --- a/src/Config.hxx +++ b/src/Config.hxx @@ -8,6 +8,7 @@ #include #include +#include enum file_location { file_etc, file_home, file_unknown, }; @@ -17,7 +18,10 @@ NullableString(const std::string &s) noexcept return s.empty() ? nullptr : s.c_str(); } + struct Config { + using IgnoreListMap = std::map; + /** don't daemonize the mpdscribble process */ bool no_daemon = false; @@ -41,6 +45,8 @@ struct Config { int verbose = -1; enum file_location loc = file_unknown; + // Key=file path, value=loaded ignore list + IgnoreListMap ignore_lists; std::forward_list scrobblers; }; diff --git a/src/IgnoreList.cxx b/src/IgnoreList.cxx new file mode 100644 index 0000000..0a846e4 --- /dev/null +++ b/src/IgnoreList.cxx @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#include "IgnoreList.hxx" + +#include +#include + +[[gnu::pure]] +static constexpr bool +MatchIgnoreIfSpecified(std::string_view ignore, std::string_view value) +{ + return ignore.empty() || ignore == value; +} + +bool +IgnoreListEntry::matches_record(const Record& record) const noexcept +{ + /* + The below logic would always return true if the entry is empty. + This condition should never be true, as we don't push empty entries. + */ + assert(!artist.empty() || !album.empty() || !title.empty()); + + /* + Note the mismatch of 'title' and 'track' field names with the Record structure. + This is not a bug - the Record structure does not use the expected field names. + */ + return MatchIgnoreIfSpecified(artist, record.artist) && + MatchIgnoreIfSpecified(album, record.album) && + MatchIgnoreIfSpecified(title, record.track) && + MatchIgnoreIfSpecified(track, record.number); +} + +bool +IgnoreList::matches_record(const Record& record) const noexcept +{ + return std::any_of(entries.begin(), + entries.end(), + [&record](const auto& entry) { return entry.matches_record(record); }); +} diff --git a/src/IgnoreList.hxx b/src/IgnoreList.hxx new file mode 100644 index 0000000..f1a859c --- /dev/null +++ b/src/IgnoreList.hxx @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright The Music Player Daemon Project + +#ifndef IGNORE_LIST_HXX +#define IGNORE_LIST_HXX + +#include +#include + +#include "Record.hxx" + +struct IgnoreListEntry { + + std::string artist; + std::string album; + std::string title; + std::string track; + + [[nodiscard]] bool matches_record(const Record& record) const noexcept; +}; + +struct IgnoreList { + std::vector entries; + + [[nodiscard]] bool matches_record(const Record& record) const noexcept; +}; + + +#endif diff --git a/src/ReadConfig.cxx b/src/ReadConfig.cxx index 0ba55e7..4850bbc 100644 --- a/src/ReadConfig.cxx +++ b/src/ReadConfig.cxx @@ -21,6 +21,7 @@ #include #include +#include #ifndef _WIN32 #include @@ -231,10 +232,151 @@ load_unsigned(const IniFile &file, const char *name, unsigned *value_r) return true; } +static std::unordered_map +parse_ignore_list_line(std::string_view input) +{ + IgnoreListEntry ignore_list_entry; + + /* + Format: tag1="value1" tag2="value2" ... + Backslash escaping is supported. + */ + + enum class ParserState { + ExpectTagStart, + InTag, + ExpectQuote, + InValue, + InEscapeSequence + } state = ParserState::ExpectTagStart; + + std::string current_tag; + std::string current_value; + std::unordered_map result; + + for (size_t i = 0; i < input.length(); ++i) { + char c = input[i]; + + switch (state) { + case ParserState::ExpectTagStart: + if (std::isspace(c)) continue; + if (std::isalpha(c)) { + current_tag = c; + state = ParserState::InTag; + } else { + throw FormatRuntimeError("Error at position %d: expected tag start, got: '%c'", i, c); + } + break; + + case ParserState::InTag: + if (std::isalpha(c)) { + current_tag += c; + } else if (c == '=') { + state = ParserState::ExpectQuote; + } else { + throw FormatRuntimeError("Error at position %d: invalid tag character, got: '%c'", i, c); + } + break; + + case ParserState::ExpectQuote: + if (c == '"') { + current_value.clear(); + state = ParserState::InValue; + } else { + throw FormatRuntimeError("Error at position %d: expected quote, got: '%c'", i, c); + } + break; + + case ParserState::InValue: + if (c == '\\') { + state = ParserState::InEscapeSequence; + } else if (c == '"') { + if (result.contains(current_tag)) { + throw FormatRuntimeError("Error at position %d: tag '%s' is duplicated", i, current_tag.c_str()); + } + result.emplace(std::move(current_tag), std::move(current_value)); + state = ParserState::ExpectTagStart; + } else { + current_value += c; + } + break; + + case ParserState::InEscapeSequence: + current_value += c; + state = ParserState::InValue; + break; + } + } + + if (state != ParserState::ExpectTagStart) { + throw FormatRuntimeError("Unexpected end of line"); + } + + return result; +} + +static IgnoreList* +load_ignore_list(const std::string& path, Config::IgnoreListMap& ignore_lists) +{ + + FILE *file = fopen(path.c_str(), "r"); + if (file == nullptr) { + throw FormatRuntimeError("Cannot load ignore file: cannot open '%s' for reading", path.c_str()); + } + + AtScopeExit(file) { fclose(file); }; + + IgnoreList ignore_list; + + { + char line_buf[4096]; + size_t line_num = 0; + while (fgets(line_buf, sizeof(line_buf), file)) { + std::string_view line(line_buf); + if (line.back() == '\n') { + line.remove_suffix(1); + } + + line_num++; + if (line.empty()) { + continue; + } + + try { + auto parsed_line = parse_ignore_list_line(line); + + if (parsed_line.empty()) { + continue; + } + + IgnoreListEntry entry{}; + + for (auto& [tag, value] : parsed_line) { +#define set_tag_entry(tagname) if (tag == #tagname) { entry.tagname = std::move(value); continue; } + set_tag_entry(artist) + set_tag_entry(album) + set_tag_entry(title) + set_tag_entry(track) +#undef set_tag_entry + throw FormatRuntimeError("Unsupported tag: '%s'", tag.c_str()); + } + + ignore_list.entries.emplace_back(std::move(entry)); + } catch (const std::runtime_error& error) { + throw FormatRuntimeError("Error loading ignore list '%s': Error parsing line %d: %s", + path.c_str(), line_num, error.what()); + } + } + } + + return &(ignore_lists[path] = std::move(ignore_list)); +} + static ScrobblerConfig load_scrobbler_config(const Config &config, const std::string §ion_name, - const IniSection §ion) + const IniSection §ion, + Config::IgnoreListMap& ignore_lists) { ScrobblerConfig scrobbler; @@ -270,6 +412,17 @@ load_scrobbler_config(const Config &config, scrobbler.journal = get_default_cache_path(config); } + std::string ignore_list = GetStdString(section, "ignore"); + if (!ignore_list.empty()) { + if (auto existing_ignore_list = ignore_lists.find(ignore_list); existing_ignore_list != ignore_lists.end()) { + scrobbler.ignore_list = &existing_ignore_list->second; + } else { + scrobbler.ignore_list = load_ignore_list(ignore_list, ignore_lists); + } + } else { + scrobbler.ignore_list = nullptr; + } + return scrobbler; } @@ -300,7 +453,8 @@ load_config_file(Config &config, const char *path) config.scrobblers.emplace_front(load_scrobbler_config(config, section.first, - section.second)); + section.second, + config.ignore_lists)); } } diff --git a/src/Scrobbler.cxx b/src/Scrobbler.cxx index b052f19..f6145c7 100644 --- a/src/Scrobbler.cxx +++ b/src/Scrobbler.cxx @@ -436,6 +436,10 @@ Scrobbler::ScheduleNowPlaying(const Record &song) noexcept /* there's no "now playing" support for files */ return; + if (config.ignore_list && config.ignore_list->matches_record(song)) { + return; + } + now_playing = song; if (state == State::READY && !submit_timer.IsPending()) @@ -515,6 +519,10 @@ Scrobbler::Submit() noexcept void Scrobbler::Push(const Record &song) noexcept { + if (config.ignore_list && config.ignore_list->matches_record(song)) { + return; + } + if (file != nullptr) { fprintf(file, "%s %s - %s\n", log_date(), diff --git a/src/ScrobblerConfig.hxx b/src/ScrobblerConfig.hxx index f60a6a8..7df1053 100644 --- a/src/ScrobblerConfig.hxx +++ b/src/ScrobblerConfig.hxx @@ -4,6 +4,8 @@ #ifndef SCROBBLER_CONFIG_HXX #define SCROBBLER_CONFIG_HXX +#include "IgnoreList.hxx" + #include struct ScrobblerConfig { @@ -29,6 +31,8 @@ struct ScrobblerConfig { * AudioScrobbler server. */ std::string file; + + IgnoreList* ignore_list; }; #endif