Skip to content

Commit 8d6a877

Browse files
committed
Merge branch 'feature_ignore_lists' of https://github.com/push0ret/mpdscribble
2 parents 50f9e19 + 3222609 commit 8d6a877

File tree

10 files changed

+298
-2
lines changed

10 files changed

+298
-2
lines changed

NEWS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mpdscribble 0.26 - not yet released
2+
* add ignore lists
23

34
mpdscribble 0.25 - (2023-12-11)
45
* fall back to "album artist" tag if there is no "artist" tag

doc/mpdscribble.1

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,56 @@ Your Last.fm password, either cleartext or its MD5 sum.
126126
The file where mpdscribble should store its journal in case you do not
127127
have a connection to the scrobbler. This option used to be called
128128
"cache". It is optional.
129+
.TP
130+
.B ignore = FILE
131+
Include an ignore file for this scrobbler to exclude tracks from scrobbling.
132+
133+
.SH IGNORE FILE FORMAT
134+
Tracks can be ignored by listing them in an \fBignore file\fP.
135+
Each line in the file specifies a pattern to match tracks you wish to exclude from scrobbling.
136+
The format is simple and flexible, allowing you to match by artist, album, title, track number or a combination of these fields.
137+
.SS File Format
138+
A tag match is specified as tagname="value", where tagname is replaced with one of the supported tags (artist, album, title, track).
139+
Values \fBmust\fP be quoted and spaces are not allowed surrounding the equal sign.
140+
141+
Each line consists of one or more tag matches separated by spaces:
142+
.nf
143+
.in +4
144+
tag1="value1" tag2="value2" ...
145+
.in
146+
.fi
147+
148+
Tags can appear in any order and blank lines are ignored.
149+
150+
A backslash (\e) is interpreted as escape character and may be used to escape literal double quotes within a value.
151+
To write a literal backslash, use a double backslash (\e\e).
152+
153+
Each line is limited to 4096 characters including the newline character.
154+
Superflous characters are silently ignored.
155+
156+
.SS Examples
157+
If a tag is omitted, any value for that field is matched:
158+
.TP
159+
.B title="Bohemian Rhapsody"
160+
Matches any track titled \fIBohemian Rhapsody\fP.
161+
.TP
162+
.B artist="Queen"
163+
Matches any track by \fIQueen\fP, regardless of album or title.
164+
.TP
165+
.B artist="Queen" album="A Night at the Opera"
166+
Matches any track by \fIQueen\fP from \fIA Night at the Opera\fP, regardless of title.
167+
.TP
168+
.B artist="Queen" album="A Night at the Opera" title="Bohemian Rhapsody"
169+
Matches a specific track by \fIQueen\fP, from \fIA Night at the Opera\fP, titled \fIBohemian Rhapsody\fP.
170+
.TP
171+
.B artist="Queen" album="A Night at the Opera" track="01"
172+
Matches the first track on the album \fIA Night at the Opera\fP by \fIQueen\fP.
173+
Note that track tags are interpreted as text and not numbers, meaning "01" is not the same as "1".
174+
.TP
175+
.B artist="Clark \e"Plazmataz\e" Powell"
176+
Matches tracks by \fIClark "Plazmataz" Powell\fP.
177+
178+
129179
.SH FILES
130180
.I /etc/mpdscribble.conf
131181
.RS

doc/mpdscribble.conf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ password =
3737
# The file where mpdscribble should store its Last.fm journal in case
3838
# you do not have a connection to the Last.fm server.
3939
journal = /var/cache/mpdscribble/lastfm.journal
40+
# Optional ignore file, see manpage for details!
41+
#ignore = /etc/mpdscribble_lastfm.ignore
4042

4143
#[libre.fm]
4244
#url = http://turtle.libre.fm/

meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ executable(
208208
'src/MpdObserver.cxx',
209209
'src/Log.cxx',
210210
'src/XdgBaseDirectory.cxx',
211+
'src/IgnoreList.cxx',
211212

212213
include_directories: inc,
213214
dependencies: [

src/Config.hxx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
#include <forward_list>
1010
#include <string>
11+
#include <map>
1112

1213
enum file_location { file_etc, file_home, file_unknown, };
1314

@@ -17,7 +18,10 @@ NullableString(const std::string &s) noexcept
1718
return s.empty() ? nullptr : s.c_str();
1819
}
1920

21+
2022
struct Config {
23+
using IgnoreListMap = std::map<std::string, IgnoreList>;
24+
2125
/** don't daemonize the mpdscribble process */
2226
bool no_daemon = false;
2327

@@ -41,6 +45,8 @@ struct Config {
4145
int verbose = -1;
4246
enum file_location loc = file_unknown;
4347

48+
// Key=file path, value=loaded ignore list
49+
IgnoreListMap ignore_lists;
4450
std::forward_list<ScrobblerConfig> scrobblers;
4551
};
4652

src/IgnoreList.cxx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// SPDX-License-Identifier: GPL-2.0-or-later
2+
// Copyright The Music Player Daemon Project
3+
4+
#include "IgnoreList.hxx"
5+
6+
#include <cassert>
7+
#include <algorithm>
8+
9+
[[gnu::pure]]
10+
static constexpr bool
11+
MatchIgnoreIfSpecified(std::string_view ignore, std::string_view value)
12+
{
13+
return ignore.empty() || ignore == value;
14+
}
15+
16+
bool
17+
IgnoreListEntry::matches_record(const Record& record) const noexcept
18+
{
19+
/*
20+
The below logic would always return true if the entry is empty.
21+
This condition should never be true, as we don't push empty entries.
22+
*/
23+
assert(!artist.empty() || !album.empty() || !title.empty());
24+
25+
/*
26+
Note the mismatch of 'title' and 'track' field names with the Record structure.
27+
This is not a bug - the Record structure does not use the expected field names.
28+
*/
29+
return MatchIgnoreIfSpecified(artist, record.artist) &&
30+
MatchIgnoreIfSpecified(album, record.album) &&
31+
MatchIgnoreIfSpecified(title, record.track) &&
32+
MatchIgnoreIfSpecified(track, record.number);
33+
}
34+
35+
bool
36+
IgnoreList::matches_record(const Record& record) const noexcept
37+
{
38+
return std::any_of(entries.begin(),
39+
entries.end(),
40+
[&record](const auto& entry) { return entry.matches_record(record); });
41+
}

src/IgnoreList.hxx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-License-Identifier: GPL-2.0-or-later
2+
// Copyright The Music Player Daemon Project
3+
4+
#ifndef IGNORE_LIST_HXX
5+
#define IGNORE_LIST_HXX
6+
7+
#include <string>
8+
#include <vector>
9+
10+
#include "Record.hxx"
11+
12+
struct IgnoreListEntry {
13+
14+
std::string artist;
15+
std::string album;
16+
std::string title;
17+
std::string track;
18+
19+
[[nodiscard]] bool matches_record(const Record& record) const noexcept;
20+
};
21+
22+
struct IgnoreList {
23+
std::vector<IgnoreListEntry> entries;
24+
25+
[[nodiscard]] bool matches_record(const Record& record) const noexcept;
26+
};
27+
28+
29+
#endif

src/ReadConfig.cxx

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
#include <stdlib.h>
2323
#include <string.h>
24+
#include <unordered_map>
2425

2526
#ifndef _WIN32
2627
#include <sys/stat.h>
@@ -231,10 +232,151 @@ load_unsigned(const IniFile &file, const char *name, unsigned *value_r)
231232
return true;
232233
}
233234

235+
static std::unordered_map<std::string, std::string>
236+
parse_ignore_list_line(std::string_view input)
237+
{
238+
IgnoreListEntry ignore_list_entry;
239+
240+
/*
241+
Format: tag1="value1" tag2="value2" ...
242+
Backslash escaping is supported.
243+
*/
244+
245+
enum class ParserState {
246+
ExpectTagStart,
247+
InTag,
248+
ExpectQuote,
249+
InValue,
250+
InEscapeSequence
251+
} state = ParserState::ExpectTagStart;
252+
253+
std::string current_tag;
254+
std::string current_value;
255+
std::unordered_map<std::string, std::string> result;
256+
257+
for (size_t i = 0; i < input.length(); ++i) {
258+
char c = input[i];
259+
260+
switch (state) {
261+
case ParserState::ExpectTagStart:
262+
if (std::isspace(c)) continue;
263+
if (std::isalpha(c)) {
264+
current_tag = c;
265+
state = ParserState::InTag;
266+
} else {
267+
throw FormatRuntimeError("Error at position %d: expected tag start, got: '%c'", i, c);
268+
}
269+
break;
270+
271+
case ParserState::InTag:
272+
if (std::isalpha(c)) {
273+
current_tag += c;
274+
} else if (c == '=') {
275+
state = ParserState::ExpectQuote;
276+
} else {
277+
throw FormatRuntimeError("Error at position %d: invalid tag character, got: '%c'", i, c);
278+
}
279+
break;
280+
281+
case ParserState::ExpectQuote:
282+
if (c == '"') {
283+
current_value.clear();
284+
state = ParserState::InValue;
285+
} else {
286+
throw FormatRuntimeError("Error at position %d: expected quote, got: '%c'", i, c);
287+
}
288+
break;
289+
290+
case ParserState::InValue:
291+
if (c == '\\') {
292+
state = ParserState::InEscapeSequence;
293+
} else if (c == '"') {
294+
if (result.contains(current_tag)) {
295+
throw FormatRuntimeError("Error at position %d: tag '%s' is duplicated", i, current_tag.c_str());
296+
}
297+
result.emplace(std::move(current_tag), std::move(current_value));
298+
state = ParserState::ExpectTagStart;
299+
} else {
300+
current_value += c;
301+
}
302+
break;
303+
304+
case ParserState::InEscapeSequence:
305+
current_value += c;
306+
state = ParserState::InValue;
307+
break;
308+
}
309+
}
310+
311+
if (state != ParserState::ExpectTagStart) {
312+
throw FormatRuntimeError("Unexpected end of line");
313+
}
314+
315+
return result;
316+
}
317+
318+
static IgnoreList*
319+
load_ignore_list(const std::string& path, Config::IgnoreListMap& ignore_lists)
320+
{
321+
322+
FILE *file = fopen(path.c_str(), "r");
323+
if (file == nullptr) {
324+
throw FormatRuntimeError("Cannot load ignore file: cannot open '%s' for reading", path.c_str());
325+
}
326+
327+
AtScopeExit(file) { fclose(file); };
328+
329+
IgnoreList ignore_list;
330+
331+
{
332+
char line_buf[4096];
333+
size_t line_num = 0;
334+
while (fgets(line_buf, sizeof(line_buf), file)) {
335+
std::string_view line(line_buf);
336+
if (line.back() == '\n') {
337+
line.remove_suffix(1);
338+
}
339+
340+
line_num++;
341+
if (line.empty()) {
342+
continue;
343+
}
344+
345+
try {
346+
auto parsed_line = parse_ignore_list_line(line);
347+
348+
if (parsed_line.empty()) {
349+
continue;
350+
}
351+
352+
IgnoreListEntry entry{};
353+
354+
for (auto& [tag, value] : parsed_line) {
355+
#define set_tag_entry(tagname) if (tag == #tagname) { entry.tagname = std::move(value); continue; }
356+
set_tag_entry(artist)
357+
set_tag_entry(album)
358+
set_tag_entry(title)
359+
set_tag_entry(track)
360+
#undef set_tag_entry
361+
throw FormatRuntimeError("Unsupported tag: '%s'", tag.c_str());
362+
}
363+
364+
ignore_list.entries.emplace_back(std::move(entry));
365+
} catch (const std::runtime_error& error) {
366+
throw FormatRuntimeError("Error loading ignore list '%s': Error parsing line %d: %s",
367+
path.c_str(), line_num, error.what());
368+
}
369+
}
370+
}
371+
372+
return &(ignore_lists[path] = std::move(ignore_list));
373+
}
374+
234375
static ScrobblerConfig
235376
load_scrobbler_config(const Config &config,
236377
const std::string &section_name,
237-
const IniSection &section)
378+
const IniSection &section,
379+
Config::IgnoreListMap& ignore_lists)
238380
{
239381
ScrobblerConfig scrobbler;
240382

@@ -270,6 +412,17 @@ load_scrobbler_config(const Config &config,
270412
scrobbler.journal = get_default_cache_path(config);
271413
}
272414

415+
std::string ignore_list = GetStdString(section, "ignore");
416+
if (!ignore_list.empty()) {
417+
if (auto existing_ignore_list = ignore_lists.find(ignore_list); existing_ignore_list != ignore_lists.end()) {
418+
scrobbler.ignore_list = &existing_ignore_list->second;
419+
} else {
420+
scrobbler.ignore_list = load_ignore_list(ignore_list, ignore_lists);
421+
}
422+
} else {
423+
scrobbler.ignore_list = nullptr;
424+
}
425+
273426
return scrobbler;
274427
}
275428

@@ -300,7 +453,8 @@ load_config_file(Config &config, const char *path)
300453

301454
config.scrobblers.emplace_front(load_scrobbler_config(config,
302455
section.first,
303-
section.second));
456+
section.second,
457+
config.ignore_lists));
304458
}
305459
}
306460

src/Scrobbler.cxx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,10 @@ Scrobbler::ScheduleNowPlaying(const Record &song) noexcept
436436
/* there's no "now playing" support for files */
437437
return;
438438

439+
if (config.ignore_list && config.ignore_list->matches_record(song)) {
440+
return;
441+
}
442+
439443
now_playing = song;
440444

441445
if (state == State::READY && !submit_timer.IsPending())
@@ -515,6 +519,10 @@ Scrobbler::Submit() noexcept
515519
void
516520
Scrobbler::Push(const Record &song) noexcept
517521
{
522+
if (config.ignore_list && config.ignore_list->matches_record(song)) {
523+
return;
524+
}
525+
518526
if (file != nullptr) {
519527
fprintf(file, "%s %s - %s\n",
520528
log_date(),

0 commit comments

Comments
 (0)