* feat(scrobbler): add scrobble_filter column to user
* feat(scrobbler): validate scrobble filter criteria on user save
* refactor(persistence): make smart playlist join helpers package-level
* feat(scrobbler): add MediaFileRepository.MatchesCriteria
* feat(scrobbler): filter external scrobbles with per-user criteria
* feat(ui): add scrobble filter field to user form
* fix(scrobbler): default scrobble_filter to empty string for existing users
* refactor(scrobbler): also gate playback reports on the scrobble filter
Playback reports carry the same track metadata to plugin scrobblers, so a
filtered track leaked through that third dispatch path. Skip the filter
evaluation entirely when no scrobbler is active.
* refactor(persistence): move criteria join building into criteria_sql.go
The join set a criteria needs was decided in criteria_sql.go but built in
smart_playlist_repository.go, so both callers had to pair the two by hand.
* refactor(persistence): unexport smartPlaylistCriteria methods
The type never leaves the package, so the exported names advertised an API
that callers outside persistence could never reach. Also disambiguates
where/orderBy from squirrel's SelectBuilder methods of the same name.
* fix(ui): cap the scrobble filter field width
fullWidth stretched it across the whole page next to 256px inputs. Bounded
at 40em, with two rows and a resize handle so JSON rules stay readable.
* refactor(ui): move scrobble filter input in UserEdit component
* feat(ui): add pt-BR translations for the scrobble filter
* fix(scrobbler): take the filter verdict before incPlay
incPlay mutates play counts and dates a filter can test on, so evaluating at
dispatch time let one play decide differently on either side of the increment:
a track could be scrobbled despite matching, or lose only its stopped report
and strand presence plugins. Reject limit/offset too, rather than silently
ignoring part of a rule copied from a smart playlist.
* fix(scrobbler): filter the report from an expired session
The expiry callback runs with a stub user carrying no filter, so evaluating
there always returned false and leaked the track to plugin scrobblers. That is
the normal path for clients that never send stopped, such as legacy Subsonic
now-playing. Carry the last verdict on the session instead.
* refactor(scrobbler): skip the now-playing enqueue instead of threading the verdict
Queuing an entry only to drop it at dispatch also cancelled a pending
announcement for the previous, unfiltered track, since the queue is keyed by
player and a new entry replaces the old one.
* fix(scrobbler): evaluate the filter regardless of active scrobblers
The verdict is stored on the session and dispatched at expiry, so skipping
evaluation when no scrobbler was active let a plugin enabled mid-session
receive a filtered track. The empty-filter guard above already gives servers
without scrobbling the same free path, so the shortcut only ever applied to
users who had a filter set.
* fix(smartplaylists): merge negated artist/tag rules in AND groups
Smart playlists with many negated role/tag conditions ANDed together (e.g.
100+ "isNot artist" rules, issue #5511) generated one correlated NOT EXISTS
subquery per rule, scanning media_file_artists for every candidate row. On
large libraries this took minutes and triggered API timeouts and SQLite lock
contention.
By De Morgan, "NOT EXISTS(role=X) AND NOT EXISTS(role=Y)" is equivalent to
"NOT EXISTS(role=X OR role=Y)", so multiple negated conditions for the same
field can be collapsed into a single batched NOT EXISTS. This mirrors the
existing OR-group merge that #5515 added for positive conditions.
The shared grouping/batching logic is extracted into mergeSameFieldConds,
parameterized by polarity, so the OR/positive and AND/negated paths reuse one
algorithm instead of duplicating it. roleCondGroup/tagCondGroup gain a 'not'
flag to emit the negated subquery.
Benchmark (323k tracks, 120 isNot artist rules, reporter's exact shape):
merged ~54ms vs unmerged ~8.7s steady-state (~160x faster).
* docs: trim redundant comments on merge helpers
The De Morgan explanation was repeated across three doc comments. Keep it in
one place (mergeNegatedJsonConds, where negation is introduced) and reduce the
shared core and group-type comments to concise one-liners.
* fix(server): optimize smart playlist role queries for large criteria (#5511)
Role-based smart playlist criteria (artist, composer, etc.) now query
the indexed media_file_artists join table instead of parsing JSON via
json_tree() on every row. Multiple conditions for the same role within
an OR group are merged into a single EXISTS subquery (batched at 200
to stay under SQLite's expression tree depth limit).
A composite index (media_file_id, role) replaces the now-redundant
single-column (media_file_id) index on media_file_artists.
Benchmark (40k tracks, 500 patterns, 3 artists/track):
- Merged join-table: 15ms (9.3x faster)
- Merged json_tree: 30ms (4.6x faster)
- Unmerged baseline: 137ms
* refactor: simplify role condition SQL generation and benchmark
Extract shared roleCondSQL/roleExistsSQL helpers to deduplicate the
EXISTS template between roleCond and roleCondGroup. Use slices.Chunk
for batching per project convention. Extract runBenchQuery helper to
eliminate triplicated benchmark execution loop.
* chore: raise roleCondBatchSize to 350
The empirical SQLite limit is 496 conditions per merged EXISTS
subquery. Raising from 200 to 350 reduces the number of batches
(e.g. 500 patterns now splits into 2 batches instead of 3).
* fix(server): apply OR-merge optimization to tag conditions too
Generalize mergeRoleConds into mergeJsonConds to also collapse multiple
tag conditions for the same tag (e.g. genre) within OR groups. This
gives the same ~5x speedup for tag-heavy smart playlists as the role
optimization gives for artist-heavy ones.
* refactor: benchmark uses real criteria pipeline instead of hand-built SQL
The "Current" sub-benchmark now builds criteria.Criteria expressions and
runs them through the actual newSmartPlaylistCriteria → Where() → ToSql()
pipeline, validating the real production code path. The baseline still
uses hand-built SQL representing the old json_tree approach.
* fix: stabilize merged group ordering and close rows before error check
Sort group keys in mergeJsonConds so the merged additions have
deterministic order across runs, improving SQLite statement cache reuse.
Move rows.Close() before rows.Err() in benchmark helper.