ADR 0006 established that “the split points, the minimum tag count, the blacklist and the
caps are all configuration, never hard-coded”. That was implemented as environment
variables (TAG_BLACKLIST_EXTRA and friends), which is the right shape for deployment
configuration: it is set once per environment, versioned with the deployment, and needs no
UI.
It is the wrong shape for the blacklist. What belongs on a blacklist is a property of one
particular library’s community tag set — seen live, bought at concert, and an artist’s
own name are all real examples — and an operator only discovers them by watching what the
tool proposes. The env-var implementation made that discovery loop expensive and, worse,
made a change invisible: editing the value required changing compose config and restarting
the container, so “saved but not in effect” was not a bug state but the normal one. The
only signal was a restart the operator had to remember to perform.
Operator policy lives in Postgres, in a genre_blacklist table, editable at runtime
through /api/settings/genre-blacklist. The environment variable continues to work and
is merged, so an existing deployment that sets TAG_BLACKLIST_EXTRA keeps behaving
exactly as before and no migration of intent is required.
The three sources are named and merged in one place. BlacklistState reports the
built-in defaults, the env var and the stored rows separately and merged:
separately, because an operator who saved an entry and did not see it take effect needs to
know which source supplied what; merged, because effective is what the tag policy
enforces. Every call site reads the merged value rather than assembling its own union —
three sources with three normalisations is precisely how the env var and the table would
come to disagree.
The merged set is read per request, never cached. One indexed query over tens of rows, against a value that is mutable at runtime. A cached copy would go stale the moment the operator saved, reintroducing the exact problem this replaced.
Stored entries go into TagPolicy.blacklist, not extra_blacklist, so a request body
can add to the operator’s policy but cannot remove it. A caller must not be able to talk
the server out of a setting the operator chose.
Saving replaces the stored list rather than merging into it. The UI presents a textarea, so what an operator sees when they press save is what they mean; a hidden union with previous entries would make deleting an entry impossible and the entry would keep applying with nothing on screen to explain why.
This is the third concern in one database, alongside editor state (snapshot,
audit_log) and the Last.fm archive. It is separated from both by what it is: small,
mutable, append-nothing, and derived from nothing. The other two are append-only histories.
A change to the blacklist never writes to Jellyfin. It alters what a future plan
proposes. Removing a genre from a library is the separate, reviewed, revertible operation
at /api/bulk/remove-genre/*. “Stop proposing this” and “delete this from 500 items” have
different blast radii, and the second must not be a side effect of the first.
test_the_setting_takes_effect_without_a_restart) rather than assumed,
because it is the property that motivated the change.test_replace_with_empty_input_clears_the_list.alembic check cannot be used as a CI gate on this repository, and the migration
docstring records why: autogenerate reads the runtime-created
lastfm_request_YYYY_MM partitions as tables to remove, and applying its output
verbatim would delete the archived request log. The report is indistinguishable from
real drift. Reviewing an autogenerated revision by hand is mandatory here.