CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
Condensed release history, newest first.
---
## Unreleased
**Conversation starters reach every member again.** Since 1.1.0 the starters job ended in an
error after the first member with a new batch: the milestone check read the new openers after
their insert had committed and closed the session. The batch was saved, but the milestone it
used stayed pending (so it could be offered again) and every member after that got no new
openers. The check now reads the openers before the insert.
**Last played shows the pinned title's poster.** A play row without an id got its poster from a
title search, and a short title found the wrong show (another series' artwork next to the right
episode), although the owner had pinned the right one in the Knowledge Base. The recent-history
posters now take the owner's match pin first (tmdb or tvdb id; a show pin also answers for an
anime play), and search by title only where there is neither an id nor a pin.
**You and the crowd, at a glance.** The Wrap-Up story's crowd slide was a scatter of every scored
title: thirty anonymous dots, one corner label, names only on hover, and the title the headline
named could not be found. It is now one title with its poster and its two places among the same
titles of the period: yours by hours (in amber) and the community's by score, "for you #2, the
community #27". With nothing yours alone it shows the community's favourite you barely touched,
when the two places are at least five apart; otherwise there is no slide. FACTS_VERSION 18.
**A week and a month of your own in the Wrap-Up.** Next to 30 days, 6 months and the year, the
Wrap-Up now has "This week" (Monday to Sunday) and "This month", both on the member's own clock:
a week starts at their Monday midnight, not the server's. Earlier weeks and months open by key
(`week:2026-W45`, `month:2026-10`).
**The Wrap-Up tells the shape of the period, not only its totals.** Code finds a ritual (the same
evening and daypart, most weeks), the genre a member came back to, the title a month belonged
to, the series kept for after midnight and what the period started with; the editor is offered
them as cards like every other fact. A week or a month tells a mini story of its five strongest
cards. To read what the writer makes of them before a merge, `scripts/wrapup_read.py` prints the
facts, the cards and their fallback texts from a database copy (with `--write`, the story too)
and stores nothing.
**Your week, your month, your year, when they are ready.** From Sunday 17:00 to the end of
Monday the chat landing shows "Your week": its hours, the days with something on, what most of
it was and a genre new to the member. It is facts only, no model, and only for a week that
reached the Wrap-Up's own threshold; a quiet week gets no card. In a month's first seven days
"Your October is ready" appears once the custodian has written the month's mini story (it now
does so on the 1st, one generation at a time under the game lock; opening the page never writes
one), and from 1 December to 14 January "Your 2026 is ready". No streaks, no counts of what was
missed; every card has "Not now". The words come in English or German, as the member writes to
the curator.
**A Wrap-Up playlist you can press play on.** "Make it a playlist" answers with a play card
that opens the playlist on the member's server, on Plex (through app.plex.tv) as on Jellyfin.
A week's and a month's playlist is one rolling playlist each, "Curatarr · Your Week" /
"Curatarr · Deine Woche", replaced in place each time rather than piling up. A member whose account cannot write
playlists on the server is told so, not "nothing found".
**Share a slide as a picture.** Every slide of the story has "Share": its share line, the
period's label and the wordmark drawn as an image in the browser and handed to the phone's share
sheet, or saved. Nothing is uploaded, and no server, user or household name is on it, nor a
device name (they often carry one). The line is fitted by its measured width, the type made
smaller rather than a word cut.
**Warmer words when the writer falls short.** A slide the writer could not write used to say
its fact followed by the editor's note on why it mattered. Now it says the fact with the headline
and the warm line of that very fact ("The long wait … Good things keep."), and none where none
fits; the next pick is an offer ("Next, if you feel like it: …") instead of "you have not played
it yet". In a German story all of it is German, the fact included, with German numbers ("18,2
Stunden", "44 %"), as are the archetype names, the stand-in story without the curator and the
"Your week" card. A story never says a field name ("new_share") any more: the writer no longer
sees them, and a section that says one is rewritten. The editor and the writer carry the voice's
law, the work and never the person, for the archetype line too, and the balance card says what
the server did ("the server grew"), not what the member is.
**A German Wrap-Up is written in German.** The editor and the writer of a story in German now
read its cards in German - claims, the renderings they may use ("11,9 Stunden", "735 Tage",
"20.10.2026") and the note on why each matters - and no English background sheet, and the editor
writes the archetype, its line and the share line in German. They used to read English and carried
"11.9 hours" or "2026-10-20" into the German text. The device card says the kind of device ("the
TV", "the computer"), never the name a device was given, and one kind is no story. The writer's
law, the work and never the person, now stands where each headline and roast is written.
**Delete & exit closes its own discussion.** The approve waits for the arr to delete the files;
a discussion opened meanwhile was closed by its answer, its memories were flushed instead of the
deleted title's, and the next message went to free chat without the title. The answer now acts
on the deleted proposal's own thread only: a pane that moved on keeps its discussion and gets a
toast.
**Talk turns into action.** Under a chat reply, every title the curator set in bold that is in the
library gets a row: "Open in Plex" or "Open in Jellyfin", "+ Watchlist" and "Keep". Keep is the
explicit form of "keep this": written and confirmed at once, where the keep-detector's outcome
used to arrive later in the bell (it still runs as before). For the owner it is a protection;
for a member a household voice the owner's protection rules weigh, and the button says so. A name
the library holds as a film and as a series gets a row for each. Owned
recommendation cards gained "Open in …" and "+ Watchlist" next to Discuss. Links go through
app.plex.tv on Plex, so they work for a shared account away from home. The match is exact
library names and the library ledger's server key; a title the reply did not set in bold, or one
the library does not hold, gets no row.
**A Tonight card on the chat landing.** Above the chat a member now finds one pick for this
moment of their own week, from their series in progress, stored recommendations and Swipe wants:
on a weeknight the series they are in the middle of, on Friday and Saturday evening a film, on a
Sunday afternoon a rewatch, during the day something they wanted to watch "for later". The lines
are templates of facts in the member's time zone and language (English or German, as they write
to the curator), no model; the second line is facts too (the titles a pick is close to, year and
genres, Rediscover's own line), never the stored recommendation text. A series in progress
opens on the series, not on the episode already watched. "Open in Plex" (through
app.plex.tv, so it works away from home) or "Open in Jellyfin" opens it on their server, "+
Watchlist" puts a stored pick on their watchlist in one tap, and "Something else" steps through
the other candidates (`GET /api/home/tonight`).
**A "Loved" shows at once.** A Loved answer in Swipe, or "Loved it" on the landing card, fed the
taste rebuild, and the Recommendations page and the TV's "Curatarr Recommended" rows showed
nothing of it until the weekly regeneration. Now the member's stored picks nearest the loved
title (at most three, from the library and because lanes) move up: first in their lane, marked
"moved up" on the card, and first in the next playlist push, which the lift makes due. The
answer says how many moved, and says nothing when none were close. Undoing a Loved swipe takes
its lift back, even while the lift is still being worked out, and only its own: a "Loved it" in
chat on the same title keeps its lift.
**Swipe comes in decks of ten, with a reason you can read in two seconds.** The stack refilled
for ever and its ring counted only seen-it answers toward the next taste refresh, so it barely
moved. Now the ring counts the deck, and after ten answers a screen says what went on the
watchlist and what you loved, with "Another 10" or "Done for tonight". Each card names one
reason: the title you loved (a "Loved it" on the landing card, a Rediscover title) or watched
recently that sits nearest it, instead of a taste cluster's three strongest titles. Cards show
one line of the synopsis, all of it on a tap, and the stack no longer runs three of one genre in
a row. A member with no profile in a category no longer reads "watch a few titles first": they
get a calibration deck of the category's most central library titles, best-rated first, and
their fifth seen-it answer builds their first taste profile, plays or not.
**"What should I watch tonight?" is answered from what the member can start tonight.** Free chat
used to search the vector store for that sentence and hand the curator its five nearest library
items, watched or not. The evening question the page starts - the "What should I watch tonight?"
chip and its daytime and German siblings, and the Tonight card's new "Ask the curator" - now
carries a `[TONIGHT CANDIDATES]` block, built without a model from what Curatarr already keeps:
the series the member is in the middle of and how far they are, up to four stored
recommendations from the because, library and rediscover lanes with their stored reason, and up
to two Swipe "want to watch" answers. Anything played since it was recommended drops out, music
only joins when the chat is about music. A typed message goes without it, as before: measured on
real data, the bare list made a targeted ask ("a film like …") answer with the same stored pick
every time, whatever its header said. The turn is ordinary free chat otherwise, so the follow-up
stays in the same thread.
**A one-tap "how did it land?" now shapes the taste** (design record stage 2, the owner's
decision). The landing's follow-up card stores the member's verdict (Loved it / Liked it / It
was okay / Not for me) in a new table, `post_watch_verdicts`, instead of sending it as a chat
message that only memory extraction read. The taste rebuild weighs the play that is already in
the history by it, never a second row: a film by its play's key, a series by its title. It
replaces the completion and drop guesses for that play, and "Not for me" pushes the vector away,
where any positive weight on a finished film would still have pulled it closer. The answer
rebuilds that category's vector in the background without the curator's summaries (the rebuild
a tenth Swipe answer starts), and "Tell the curator why" opens the message's thread for the
words a tap cannot carry. The card now asks only about one film or series: music follow-ups and
morning lines about several things stay in the bell. The Plex/Jellyfin rebind carries the
verdicts' play keys over.
**A member's chat landing speaks to the member** (design record
[docs/UX_RETENTION_DESIGN.md](docs/UX_RETENTION_DESIGN.md), stage 1). Beside the curator's
openers the landing now offers chips in the member's own voice, built without a model from the
same facts (their daypart and weekday, the series they are in, the music on rotation) and
written in the language they talk to the curator in. The owner's clean-up chip and greeting
line, the enrichment tile, the Knowledge Base and Activity entries, Recompute taste, Plex
libraries, Re-fetch and the beta tag are the owner's only; the recommendations tile reads the
cache and no longer starts a generation when it is empty. A member's chat error and queue lines
say what happened in plain words; the owner still reads the job and the Ollama address. The
newest unanswered "how did it land?" message (a watched recommendation, or a morning line about
exactly one title) sits on the landing as a card with four one-tap answers instead of only
behind the bell, and a
returning visit opens on the landing with the last conversation folded behind "Continue our
last conversation". The starters' daypart, weekday and day-named expiry and the morning line
read the member's own time zone instead of the machine's (a UTC container put a Berlin evening
an hour or two late). The chat gained one rule for recommendation turns: one or two titles with
a reason each, then one question that narrows the next pick, a longer list only when asked;
every other turn ends once it is answered, with no question tacked on.
Notification labels judge the watching, not the person ("Started, not finished" for "Attention
deficit", "Comfort picks" for "Guilty pleasures", ...), and the bell badge shows the label
instead of the internal type. Swipe, Wrap-Up and History name Jellyfin on a Jellyfin install,
and a Jellyfin member without a linked account is told so instead of "not found in Plex".
---
## 2026-11-08 — v1.1.0: Wrap-Up for everyone, a tactile glass UI on desktop and phone, Curatarr on a NAS, one SQLite writer
Four weeks after 1.1.0-beta, the first release without the suffix (the
badge next to the logo still says beta). The milestones, in the order the entries below
follow them:
- **Wrap-Up** replaces the owner-only Curation Report: every household
member's own period in review, with every number computed in code before
the curator writes about it. Behind it, watch history keeps every play,
each user has their own clock (DST included), a library ledger records
what arrived and what left, and a credits index keeps who made what.
- **The interface**, light instead of lines: frosted-glass surfaces, poster
art with a tinted glow, a bento grid for the Wrap-Up, and on phones an
installable app with a floating glass dock, card-stack tables, bottom
sheets and a Swipe view.
- **Concurrency**: one writer for SQLite; 561 database calls and the
vector store moved to worker threads, with a runtime guard
(`CURATARR_LOOP_GUARD`) and a ratchet test that lets the count of the
rest only fall (one is left). Chat no longer waits on background work.
- **Resilience**: the deletion judge fails closed; a run that did not judge
everything keeps its proposals; no error handler swallows a failure
silently any more; slow arrs get the time they need
(`ARR_LISTING_TIMEOUT_S`); memory stays bounded across months.
- **Security**: the AppSec review of 2026-10-10 (loopback-only setup
exemption, saved secrets only to their saved address, the service-URL
policy, the PIN setting removed) and, after it, the opt-in
`ALLOWED_HOSTS` check and Cloudflare Access at the origin.
- **Deployment**: a container image for a NAS, the gaming PC as a named
compute node that lends its GPU, per-machine model bakes, and an update
check with "Update available" under the sidebar.
- **Jellyfin**: one install runs on Plex *or* Jellyfin 10.9+, behind one
media-server seam, and moves between them both ways without losing
anything (Settings -> Media server, or `scripts/rebind_server.py`).
**Anime watched, reported to Shoko (2026-11-08).** An optional Shoko Server
(`SHOKO_URL`, `SHOKO_API_KEY`, one key per Shoko user) now gets every
finished anime episode marked watched, with the day it was watched. The
media server's file decides which Shoko episode it is, then TMDB; a play
whose Plex key is gone (a replaced file, a series moved between libraries)
is found again through the series' current copy. On the owner's history
that placed 686 of 859 plays. What is left is listed per series and season
under Settings -> Integrations -> Shoko, where the owner links it to its
AniDB entry (one link per cour where AniDB splits a season) or skips it (western
cartoons filed as anime). Nothing runs
without the two settings.
**Ready for the TrueNAS app catalog.** `deploy/truenas/catalog/curatarr` is the app in the
official catalog's format (made from its Tautulli app): settings form, compose template on the
catalog's library, test values, readme; `SUBMITTING.md` lists the steps to propose it. A new
workflow runs it through the catalog's own test script on every change (ports against the
whole catalog, the template rendered, the app started until its health check passes). A
release tag `vX.Y.Z` now also publishes the image as `:X.Y.Z`, the fixed tag a catalog app
pins, after checking it matches `VERSION` (`docs/RELEASING.md`).
**Jellyfin plays while Curatarr is off.** Curatarr may run only a few hours a day on a PC, and
Jellyfin itself keeps only each item's current state. The live-session poller now notices
when it saw nothing for more than 15 minutes (the app off, the PC asleep, Jellyfin out of
reach) and catches up at once instead of at the next daily sync. No plugin is needed: when a
title's play count rose by more than one, Jellyfin's own activity log dates the extra plays
(a start-to-stop span of 80 % of the runtime makes a play; the log calls every stop "finished
playing"), where before all but the last had no time. Where the optional Playback Reporting
plugin is installed it reads the plugin's plays inside that stretch, with their times and
devices; before, the plugin was read once, for the time before Curatarr. Its times were read
as UTC although the plugin stamps the server's local clock, so every play sat an hour or two
off; they are now converted on the server and stamped at the play's end, as every other
witness stamps it. The delta poll no longer counts plays again that the live sessions already
filed since the last poll (a track played five times in a day), and a count up by three no
longer collapses into two rows.
**A household member's playlists land in their own account.** A look at the live server found
the member's recommendations in the owner's "Curatarr Recommended" playlists: Plex answers a
Plex Home member's sign-in token with the owner's playlists and view state, so the member's
weekly push replaced the owner's Shows and Anime playlists, and "next episode" read the
owner's progress. Every per-user write (the recommended, music and Wrap-Up playlists, and the
played marks on the way back from Jellyfin) now uses the server's own token for that account
from plex.tv; without one the member is skipped. The music playlist had found no artist since
the install maps no music library (the Plex index holds the music); it now uses every music
library the server has. A series no longer starts with a special that sorts first, and the
playlists follow the recommendations daily instead of weekly.
**Collaboration credits count for their artists.** A music "artist" such as "North Signal &
South Static" that no source knows as an act of its own now counts for the artists it names, when
the first-billed one has a profile: its plays join their taste, and the Knowledge Base files it
as "Credit of known artists" instead of "not found". A name known itself ("Sun & Moon",
"Simon & Garfunkel") is never split, and an unknown first name keeps it whole, so "Blank &
Jones" does not land on an unrelated "Jones". On the owner's history most credits already had a
profile; the rule moved 16 names (34 plays).
**Curatarr's collections on every home screen, edited in place.** The weekly push deleted the
shelves and made new ones, so a shelf placed on a home screen was gone each week, and none was
ever put there. A library's shelves now keep their objects: the new rotation renames,
describes and refills them, and they show on the owner's home, every other account's home and
the library's Recommended tab (`COLLECTIONS_ON_HOME`: `all`, `owner` or `off`; Plex has no
choice per account). Checked live: all four shelves on the owner's and the member's home. On
Jellyfin, whose home screen has no row for a single collection, each shelf now carries the
designer's description and says where to find it (checked on a test server). The first
collection on a Jellyfin adds its Collections library without a view, so nobody saw the
shelf until a library scan; Curatarr starts that scan once when the view is missing.
**A season shelf in every library.** Each library's first shelf now follows the household's
moment of the year, in the owner's language: in German Advent und Weihnachten, zwischen den
Jahren, Karneval (moving with Easter), Ostern, Sommerferien, Halloween, else the season. It
keeps its slot, and so its place on the home screens, while the moment changes; what the
household watched in the same month of earlier years is the designer's hint. Every shelf is
now named and described in the owner's language (a shelf has one name for every account), the
Jellyfin note included. Checked with the curator on the owner's library: German names and descriptions, for now
and for Advent, all from owned titles only.
**Playlists in the member's language.** A playlist lives in one member's account, so its name
follows the language that member writes to the curator in: "Curatarr empfiehlt · Filme",
"Curatarr Merkliste" on Jellyfin, and so on. A playlist under another language's name is found
and renamed in place, so its id and pins stay (checked live on the owner's Plex).
**Because you watched A, B and C.** A third recommendation lane takes three titles a member
finished in the last 60 days and keeps only owned, unwatched titles close to all three (the
lowest of the three similarities decides, on vectors centred on the category's mean); the
curator writes one line per pick on what links it to the three, from their themes and plots.
It shows as its own sections on the recommendations page ("Because you watched ...", a lane
chip of its own) and as one more playlist per member ("Curatarr · Because You Watched").
Checked on a copy of the owner's data: three recent anime gave five owned picks with their
links; films and series had too few finished titles in the window, so no lane there.
Recommendation pitches are now written in the member's own language (they were English for
everyone, being written ahead with no conversation to read).
**Plex plays arrive within minutes.** Plex keeps every play, but Curatarr fetched them once a
day, so the curator learned of an evening's viewing the next morning. A small watcher now looks
at Plex's sessions every 5 minutes while something played in the last hour (every 15 otherwise,
`PLEX_SESSION_POLL_S`, 0 = off) and runs the incremental history sync a few minutes after a
session ends, batched so a music session does not sync every track. No live display and no
webhooks (they need a Plex Pass).
**Rediscover, and a year ago.** Two quiet rows on the recommendations page. "Rediscover":
owned titles a member finished (a film) or watched in earnest (six episodes of a series) and
has not touched for two years, ranked by today's taste, one film of a series at most, each with
its fact in the member's language ("Zuletzt vor 3 Jahren · 2× gesehen"); no model writes
anything. "A year ago": what they finished within three days of this date last year, on the
days that have it, each title hideable for good. Checked on a copy of the owner's data.
**Milestones.** Fixed rules over the watch history decide what a milestone is: the 10th, 50th,
100th ... different film, series and anime series finished (every aired episode of an ended
series, from Sonarr), episodes, hours, different artists, and years since the first play. Each
carries its day and the title that crossed it, computed back over the whole history (the
custodian's daily `milestones` job, no model). The Wrap-Up page shows them as a timeline, with
the kinds a member can switch off; a story gets a slide for the ones reached in its period; one
newly reached is offered once as a chat opener, never again, and the ones a first run finds
stay history. On a copy of the owner's data: 26, in 2.2 s.
**Your decades.** A Wrap-Up tile shows each decade of release with its share of the hours, its
leading titles and, for a year, the points against the year before; when a decade that is not
this one held a quarter of the hours or more, the story gets a slide for it (the curator writes
the line, a bar chart beside it); and members who compare with the household hear which
decades are theirs in the house, nobody else's numbers. Facts version 13. On a copy of the
owner's data: "Your 2010s: 52% of the hours" over the last six months.
**The anime season as a Wrap-Up.** A fourth kind of period, "Anime season" (Winter from
January, Spring, Summer, Fall), offered for the seasons a member watched anime in. Besides the
usual wrap-up it says what they started (a first episode that season), finished (every aired
episode of a season, from Sonarr) and dropped (begun, not finished, untouched for a month at
the season's end), as a tile and a story slide. On a copy of the owner's data: Summer 2026, 13
started, 7 finished, 6 dropped.
**Same-name works, and the owner's call that read like a keep.** The deletion discussion read
the 2004 animated "Teacher's Pet" for the 1958 Gable/Day comedy (a name search ranks the
popular one first) and then rightly refused a verdict on the mixed record. Its Wikipedia read
now resolves the article by IMDb id through Wikidata, and without one the year decides: a hit
whose "(… film)" names another year is dropped, and a film article with no year in its title
must name it in its opening. A cut that became the owner's call (major honours, or a household
member's keep) now gets a note with both sides instead of praise under "suggested for
deletion", the chat opens such a card as "up as your call", and cached notes are rewritten
once. Settings -> Integrations no longer flags a curator that passed the bench on this card as
a mismatch with the bench's pick.
**The curator's tone, per user: it judges the work, never the person.** A replay of the
owner's own chats found the old persona reading moods and motives into viewing ("Excuses.",
"a regression into comfort-watching", "a lapse in self-control" to another household
member), arguing with a user's own account of their evening, and building a "pattern" on a
single title. Settings -> Account -> Curator now picks the tone for chat, chat openers and
proactive messages: Friendly, Neutral (the default for household members) or Sharp (the
admin's default, the old edge, aimed at titles). Every tone shares the same rules: an
explanation is taken as fact and the talk returns to the title, a different reading of a work
gets the curator's own view instead of a verdict on the user's, and a pattern needs more than
one title. The openers' forms point at the facts (a number, a title) instead of "a pattern of
theirs"; the taste summaries are written plainly and no longer invent what a user rejects; in
German the curator says "du". Measured over seven rounds on the owner's data with
`tests/benchmarks/tone_bench.py`; thinking, a second checking call and word filters were tried
and dropped.
**Jellyfin support (docs/JELLYFIN_DESIGN.md).** `MEDIA_SERVER_TYPE=jellyfin`
runs the install on Jellyfin instead of Plex. Sign-in through Quick Connect
or the Jellyfin password, only for the server's own users, the first one an
administrator; the wizard picks the server, tests it (refusing anything
older than 10.9) and maps its libraries. Play history comes in four tiers,
none of which needs a plugin to start: live session polling (a play counts
from 80 %), a delta poll of each user's played state, and, when installed,
the Webhook plugin and a Playback Reporting backfill, merged into one play
per viewing. Writes match Plex's: "Curatarr Recommended" playlists per
member and collections, edited in place; the watchlist is a "Curatarr
Watchlist" playlist; mark played; deletes with a re-read. Parity: the music
index with lyrics, music deletion candidates without Lidarr, moods and
styles from Genres and Tags, likes and favourites as ratings, subtitle
signals from Jellyfin's own tracks, deep links. `scripts/rebind_server.py
--to jellyfin|plex` re-keys history, enrichment, vectors, verdicts and open
proposals onto the other server's ids: a dry run first, `--write` with a
backup and a manifest, `--undo`, and a permanent id memory
(`server_id_map`) so a move back finds every title again.
Tried live against the owner's Plex and a test Jellyfin on the same anime library (2,188 series,
39,703 episodes, Jellyfin mid-scan with the TheTVDB plugin), and fixed:
- **Ghost items:** Jellyfin lists the episodes and seasons its metadata knows but no file holds as
"missing" items, 58,135 episodes beside 39,427 real ones. They carried the TVDB ids, so matches
went onto them. The library walk, the children of a series and the title search leave them out.
- **Every key, the strongest shared one wins:** one key per title could not meet across servers.
Plex keyed episodes by their TVDB id, Jellyfin (no ids or numbers yet) by file, and a TMDb-only
Jellyfin keys series by TMDb. Each title now files all its keys (id, series chain, file), the
rebind takes the strongest both sides share, and conflicts count one identity per title.
Matched: series 99.8 %, episodes 0.4 % -> 99.6 %. The dry run on a copy of the owner's
database re-keyed 156 of 156 anime watch-history rows.
- **Timeout:** a page of 500 items took up to 104 s while the server scanned; 120 s instead of 30.
- **The full round trip, in a sandbox copy:** `--write` re-keyed 156 of 156 anime plays, 2,178 tech
profiles and the owner's account. Quick Connect signed him in. The first poll stored the episode
he had watched (as a mark), and the live sessions caught the next one at 24:07 of 24:12 from
Firefox; the poll after it merged it instead of doubling it. The watchlist took the next unwatched
episode, mark-played moved it on, and `--undo` put every row back exactly. Fixed on the way:
- a library left on its Plex key by the rebind (the owner's Movies, not on the test server) was
searched with that key: HTTP 400, the watchlist said so and the playlist push broke off. Now it
is not in the library;
- a series whose episodes Jellyfin had not attached yet went onto the watchlist as the series id,
which Jellyfin drops silently, and Curatarr said ok. Now it says there is no episode yet;
- plays of a title first seen after the switch stayed on the Jellyfin id through `--undo`. The
id memory knows both ids, and the undo uses it now.
- a shelf collection's members were read without a user, and Jellyfin lists them only in a
user's view, so they always read empty and nothing ever left a shelf. Members are now read as
an administrator: a title that leaves a shelf leaves the collection (live: one in, one out,
same collection id).
**No scripts: setup and the move, both on the web page.**
- **Setup finds and connects Jellyfin.** The wizard no longer asks for a pasted API key. It looks
for the server and an admin approves a Quick Connect code (or types the admin password once).
Curatarr then makes its own API key, "Curatarr", reuses it on a reconnect, and closes the admin's
session. The key stays on the server under a ticket and never reaches the browser.
The discovery broadcast went unanswered from the TrueNAS app (bridge network). So the host the
page came from, the container's gateway and the configured hosts are asked too, on 8096 and
30013, each through the endpoint policy.
- **Settings -> Media server** connects the other server and moves the install, Plex to Jellyfin
and back. A check runs first: both libraries are walked and the report is shown, with a dropdown
for each user or library without a match. The move then runs inside the live server:
- the custodian is paused, the session poller stopped, and the Plex and Jellyfin sync locks held;
- the database is backed up, every key re-keyed, the setting flipped and reloaded in place;
- the first sync on the new server runs, with progress in Activity;
- it refuses while an enrichment, the music pipeline or a deletion run holds its lock;
- undo stays as an emergency exit.
- **No gap on the way back.** The move to Plex carries the Jellyfin period's plays like every
other row. "Mark what was watched as played" then marks each finished Jellyfin-period play on
Plex with its member's own token, so Plex's "unwatched" agrees. The next Plex sync knows those
marks and does not count them as viewings. Towards Jellyfin, the first poll after a move skips
items Curatarr already holds a finished play of. Before, the marks the move set came back as
twin "marked" rows.
- **Marks seen late are no plays either.** A Jellyfin still scanning listed only 4 of 133 fresh
marks as played; the rest would have come in later as new plays. Each move notes the keys it
marked and when, and every history tier on both servers leaves exactly those out.
- The move reuses the check's library walks for an hour, so it takes seconds instead of another
full walk. If plex.tv refuses the server token (HTTP 401, seen live), the way back matches
users by the id memory alone and the page says so. The settings write runs off the event loop
(`write_env` runs icacls on Windows).
- **Live, in a sandbox copy against the owner's Plex and the test Jellyfin, all from the page:**
- Plex -> Jellyfin: 2,332 titles moved, 133 plays marked played on Jellyfin, first sync, no
twin rows.
- An episode watched on Jellyfin came in as a live-session play.
- Jellyfin -> Plex in 26 s: 2,336 titles, 0 unmatched, 0 collisions, every anime row back on
a Plex key. The episode came along as its one row and was marked played on Plex
(viewCount 1). The next Plex sync left that mark out ("1 played mark(s) the move back from
Jellyfin set, not viewings").
Tests: `tests/test_media_server_keys.py`, `tests/test_jellyfin_live_findings.py`, `tests/test_rebind.py`,
`tests/test_jellyfin_connect.py`, `tests/test_media_switch.py`.
**Wrap-Up: every household member's own period in review (the Curation Report folded in).**
A new Wrap-Up view for everyone, not only the owner: last 30 days, 6 months, a calendar year
or all time, for everything or one medium. Every number is decided in code before the
curator sees it (`services/wrapup_facts.py`): played hours (not item length — the old report
counted a ten-minute abandon as the whole film and Spotify plays and imported history as
nothing), the listening clock, the biggest day, the 48-hour binge, the comfort rewatch, the
contrarian gem (your stars against the community's), the acclaimed title you walked away from,
viewing age and decades, shortest vs longest, comebacks, genre drift against your own past,
taste clusters, and the Curatarr side: GB freed, the one that came back, the titles you kept
over the curator, the most debated, the dust collector. The curator then writes an archetype,
two to four chapters, a roast and a toast and a look-ahead in a forced JSON shape; every title
enters by reference and every number in its prose must already be in the facts, or the answer
gets one repair turn and then a story built from the facts alone. Tiles render only when they
are earned; under five plays the page says "not enough yet". A quiz before the reveal,
"Ask about it" (a chat thread whose context is the same fact sheet), a Plex playlist of the
period, a share card and a festival-lineup card, per-tile sharing. The custodian writes the
rolling month ahead weekly, the year from 1 December and the closed year once more in early
January. The owner's curation ledger (the old Report) is a section at the bottom; `#report`
opens the Wrap-Up; the yearly paragraph and its endpoint are gone. Design notes:
`docs/WRAPUP_DESIGN.md`. Tests: `tests/test_wrapup_facts.py`, `tests/test_wrapup_story.py`.
**Wrap-Up v2, step 3: the story mode.** v1 put everything on one scroll. The archetype came
first (the conclusion before the evidence), then fifteen tiles of equal weight, with the story
under them. The period is now told as a paced, full-screen sequence (`frontend/js/wrapup_story.js`):
- **The order:** a cold-open number, a prediction, the reveal of the number one, the rhythm as a
heatmap of every day, the curator's slides in the editor's order, roast and toast, then who you
were (the archetype with a six-axis taste radar), and a send-off with the next pick and Share.
- **Charts inside the slides:** the court slide draws the debates won and lost, and the balance
slide draws GB in vs out by month.
- **Controls:** tap the sides to move, hold to pause, arrows and Escape on a keyboard. Reduced
motion turns off the animation and the auto-advance.
- **Short periods stay short:** slides without data are skipped, and a period without a written
story is told from the insight cards.
- **Opening:** a curator-written story opens by itself the first time it is seen. "Play your
wrap-up" brings it back, and the page below is now its "Explore" view.
- **CSP-safe:** charts are SVG coloured by class, no inline style.
- **Facts:** they carry each active day's hours for the heatmap (`FACTS_VERSION` 6).
- **Reading time:** a slide stays as long as its text takes to read, 6 to 20 s, instead of a
fixed 7 s that cut the longer ones off.
Tests: `tests/test_wrapup_story_mode.py`.
**Marked as played is no viewing; music from Plex's history has its hours.** The first Wrap-Up
on the real server named a title for the last 30 days that had been played in March. Two causes:
- **Marked as played:** "Mark as played" in Plex gives every item it touches one `lastViewedAt`
second and writes no history event. The sync read each item as a viewing, a rewatch where the
real plays were stored months before.
- On the real server, 268 of 1259 video rows were such marks; Kim Possible had 86 episodes in
one second.
- Three or more items of one library in one second are now a mark. An item with a stored play
gets nothing; a first mark is stored as `source="marked"`, which keeps "seen" for curation and
stays out of everything the Wrap-Up counts inside a period.
- A one-time migration (schema generation 2) marks the rows already stored.
- **Music from Plex's history:** the orphan import took Plexamp plays from the history, which has
no length, and stored them unfinished, so every artist read 0 h.
- Such a play now counts as played, with its length estimated.
- The same migration repairs the stored rows.
- `FACTS_VERSION` 7 rebuilds the wrap-ups.
Tests: `tests/test_marked_plays.py`.
**The household in a wrap-up: only among those who said yes.**
- **Opt-in:** a member can turn on "Compare with the household" under the Wrap-Up's chapters
(`/api/wrapup/prefs`, off by default). They are then compared with the other members who
turned it on, and with nobody else (`services/household_compare.py`).
- **What it shows:** their rank by hours, a title in their top five that none of the others ever
played, and the household median as a dashed line on the taste radar. The median needs two
others at least, so the line is never one person's shape. No other member's name, title or
number is shown.
- **Nothing is stored:** it is worked out from the others' stored wrap-ups each time the page
opens, so turning it off takes a member out of every comparison at once. The curator's story
is not given it, so no written text keeps it either.
Tests: `tests/test_household_compare.py`.
**How the music felt, and the player of the night, in the Wrap-Up.**
- **Moods and styles:** a custodian job (`music_tags`, every three days, no model) reads Plex's own
moods and styles for every music section into `music_tags`. It works the way Plex's filter bar
does: it lists the tags per kind and level (moods on artists, albums and tracks; styles on
artists and albums), then the items under each tag. It also reads the tags a listing carries on
the item itself. Only a walk that read a whole section replaces that section's rows.
- **A play's moods:** a track takes its own moods, else its album's, else its artist's; styles
come from the album, else the artist. Most track events in Plex's history carry no ratingKey,
so a play the music index does not know by key is found by its artist and album names.
- **Wrap-Up:**
- **Moods:** the leading moods and styles over the played time, and the leading mood of each
part of the day. They need a third of the music time tagged.
- **Players:** the players by hours (`watch_history.player`, else the platform read as its
family: "iOS 17.1 (iPhone14,2)" is an iPhone) and the player of the night. They need half
the time with a known player.
- **Cards:** two new insight cards, "Mornings ran Energetic, nights Melancholy" and "After
23:00 it was the iPhone" (`FACTS_VERSION` 10).
Tests: `tests/test_music_moods.py`.
**The player of every Plex play.** Groundwork for the Wrap-Up's rhythm clock and "phone after
midnight", measured on the real server:
- **Player:** every Plex history event names its device, and `/devices` gives its name and
platform ("iPhone" / "iOS"). Nothing stored it.
- The sync and the history import now write `player` (new column) and `platform` for every
Plex play.
- A one-time custodian job, `plex_players`, fills the plays already stored. Most music events no
longer carry a ratingKey (the track was re-added), so a play is also found by its second and
title.
- On a copy of the NAS data, about 19,000 music plays got their player; 968 plays happened on a
phone between midnight and 5 am.
Tests: `tests/test_plex_players.py`.
**Nanogenres: titles whose themes sit together, named once.** The facet index keeps about six
theme phrases per title; nothing aggregated them. Clustering the phrases gave broad themes ("urban
life", "humor") with music and video mixed. Clustering the titles works: each title is the mean of
its own phrases, and video and music are clustered apart. On the real store that gave coming-of-age
teen comedies (Superbad, Clueless), serial-killer thrillers (Zodiac) and german street rap, from
29,630 titles at a 340 MB peak (`services/nanogenres.py`):
- **Build:** a custodian job, `nanogenres` (no model), runs a spherical k-means with a fixed seed,
about one cluster per 80 titles (137 video, 233 music). It builds once, and again only on a new
version or embedding model. Later runs only assign titles new to the index to the nearest
centre, so clusters and names stay put. Tables `nanogenres` and `title_nanogenres`.
- **Names:** `nanogenre_names` (the summarizer, 40 a run) names each cluster from its central
titles, their genres and their shared phrases ("serial killer psychological thriller", "german
urban hip hop"). Without the genres in the prompt the names were lists of themes.
- **Wrap-Up:** the facts carry the period's played hours per named nanogenre, the biggest first
with their titles, the ones never played before the period, and the share of hours that have a
nanogenre (93% of the owner's last 30 days). `FACTS_VERSION` 9.
Tests: `tests/test_nanogenres.py`.
**The story's last three views: rhythm clock, taste river, you and the crowd.** Visualizations 2,
7 and 8 of the Wrap-Up blueprint, drawn as SVG with classes only:
- **Rhythm clock:** 24 hour sectors with a ring per weekday, shaded by the quartiles of the
played hours, with the music's leading mood per part of the day underneath. The facts carry a
weekday-by-hour grid (`tempo.clock_h`).
- **Taste river:** the five leading nanogenres stacked month by month around a centre line, each
a shade of the accent and named where it is widest (labels kept inside the chart, none over
another), with the nanogenres new to you. The facts carry the hours by month
(`nanogenres.by_month`).
- **You and the crowd:** your hours against the community's score for up to 40 of the period's
video titles. The judge's last verdict (`judge_verdicts`) is the marker: a cross for a title it
wanted gone, a triangle for one it kept. The top left is named: what you loved and the crowd did
not.
- The pool is the period's own video titles, because a music-heavy rollup left only 11.
- AniList scores, which carry no vote count, count; a known small vote count does not.
- `FACTS_VERSION` 11.
Tests: `tests/test_wrapup_views.py`.
**Anime the audit flags, re-resolved through the TVDB -> AniDB -> AniList crossref.** Sonarr knows a
series by its TVDB id, the anime-lists mapping names its AniDB entry, and the offline snapshot names
that entry's AniList id (year-checked). A new daily custodian job, `anime_crossref` (no model, no
network), walks the pending anime findings (`wrong_entity:*`, `id_conflict:anilist_id`):
- **When the chain gives one answer** that differs from the profile's, the item gets an automatic
pin (`media_match_overrides`, note "auto: TVDB -> AniDB -> AniList crossref", no owner), its
findings close and it re-enriches on the pin.
- **Otherwise it is left for the owner:** several AniDB entries for one TVDB series and none marked
main, no AniList id, no mapping, or no known arr year (the year check keeps a drifted AniDB id
out). An item that already has a pin is never touched, so an automatic pin that did not help is
not re-pinned in a loop; the weekly audit re-raises it after the grace week.
- **Measured** on a copy of the NAS data: 10 of 30 pending anime findings pinned (Vampire Princess
Miyu, Piano no Mori, Gundam: Iron-Blooded Orphans, Golgo 13 among them).
Tests: `tests/test_anime_crossref.py`.
**What each card is doing, in one place.** Settings -> Maintenance -> Compute nodes now shows,
under this server and under the node, a line that refreshes every 5 s while it is on screen
(`GET /api/hosts/cards`, admins; `services/compute_cards.py`). Until now this was spread over
the log, the lane badge and Activity:
- **The lane:** who holds the card ("deletion scan: anime") and how many wait, chats counted
apart.
- **The card:** VRAM used of total and utilisation. On the node it comes from its agent's last
report, and its state is shown with the game that holds it.
- **The memory:** the container's own use and limit (cgroup v2, else v1), Curatarr's RSS and the
RAM the host has free.
Tests: `tests/test_compute_cards.py`.
**Netflix viewing history for household members, counted like Plex.** Settings -> Users ->
Netflix history import (`services/netflix_import.py`, `services/netflix_match.py`):
- **Both exports:**
- the data export's zip (only `CONTENT_INTERACTION/ViewingActivity.csv` is read: every
profile, start time, watched time, bookmark, device; trailers, previews and starts under two
minutes are left out);
- a profile's `NetflixViewingHistory.csv` (title and day, in the account's locale; the date
format is told by the file's newest-first order).
- **Profiles to members:** each profile found is attached to a household member, or skipped.
Importing the same files again adds nothing.
- **The same rows as Plex:** `watch_history` with `source="netflix"`, stamped when the play ended.
The real watched time is the played time (`listened_ms`); the position reached is the offset.
A day-only play counts for its day and weekday, never for a clock hour or a sitting
(`date_only`).
- **On the library's titles:** a film takes the key its Plex plays use (else `radarr:N`); a
series takes Sonarr's title and category. The match is exact: by title, else through TMDB in
English and German ("Haus des Geldes" is Money Heist). A missing episode number comes from
Sonarr's episode list, else TMDB's list of the season in both languages ("Lügen" is Dark
S1E2). Titles that arrive in the library later are linked by a daily custodian pass
(`netflix_relink`, no network).
- **Counted the same way:** an episode without a number counts by its title
(`viewing_sessions.episode_id`). This applies to the household protection of the deletion
judge, the owner's own watch line, the watch status, comfort replays and series completion.
- **Left alone by the Plex writers:** the sync's resume and in-progress merges, the
re-attribution, the orphan cleanup, the player backfill and the dedupe script.
Tests: `tests/test_netflix_import.py`.
**Two blind spots found while seeding the README's demo household.**
- **Taste: series without the profile alias count.** The show/anime rebuild looked up enrichment
profiles only under the played episode keys. A Plex episode carries its series profile there,
because the enrichment aliases it. A Netflix or imported episode does not, so its series fell
out of the taste vector. On the demo history that was 0 of 9 series; the series' own key is now
looked up too (`taste_engine._profile_keys`).
- **The owner's free chat knows who else watches a title.** The deletion judge and the proposal
cards had the other members' use (pillar III); free chat did not. It called a sitcom another
member had watched 135 episodes of "a prime candidate for the chopping block". The owner's chat
now gets each member's distinct episodes, plays and last play; a member's chat does not, because
household compare stays opt-in (`chat._household_use_block`).
Tests: `tests/test_series_taste_aggregation.py`, `tests/test_chat_household.py`.
**The deletion run no longer skips every short title.** Titles the curator took a position on in
chat stay out of the deletion run. The check tested each candidate as a lower-case substring of
the last 60 days of replies: "Her" sits in every "other", "IF" in every "if", "10" in any number.
On the owner's real chat 458 titles were never proposed. Now a title of several words matches as a
whole phrase in its own capitalisation, and a one-word title only when it is emphasised or quoted:
116 (`recommendations_engine.positioned_titles`).
Tests: `tests/test_curator_stance_match.py`.
**Recommendation posters for titles written with their year.** The curator writes a discovery pick
as "The Cycle (2026)", and the arr names some titles "Given (2021)". Searched with the year in it,
TMDB found nothing and the card showed a "?". On the owner's cache that was every movie discovery
and 7 of 10 anime picks. The year is now the search hint, and a music pick ("Artist - Track") is
looked up by its artist (`recommendations.poster_query`). The next recommendation refresh fills
the cards.
Tests: `tests/test_rec_posters.py`.
**The README shows the app as it is now.** The Preview still showed three pre-redesign images, and the
block for the new ones had sat behind a comment because no frame had been captured.
- **A demo household on a real library:** Jane Doe (the fictional owner of `CURATOR_EXAMPLES.md`)
and Sam, with an invented 2026, on a scrubbed copy of the data that can write nowhere. Taste,
nanogenres, the Wrap-Up and its story, recommendations, a deletion analysis and one chat answer
were then run by the app itself. `docs/screenshots/README.md` says how.
- **New sections:**
- "A look around": the Wrap-Up and four story slides, the chat, the deletion proposals, the
phone, and more behind a fold.
- Features grouped as the curator, the household, library care and your hardware; Netflix,
Swipe, the household rule and the card view are new there.
- "How it works" as a Mermaid diagram.
- **The capture script** steps through the Wrap-Up story to the slide it names, opens the
calendar year, and stores phone frames at 1.5x.
- **Poster cards on a phone** (deletions, recommendations, arr search) are a grid now: a small
poster beside the title line, the actions on a line of their own, the text at full width. The
110 px poster beside everything else had squeezed the pitch into a narrow column and stacked the
buttons three high.
Tests: `tests/test_readme_screenshots.py`.
**The facet index holds one point per phrase: a third of the size, and searches find the right
titles.** Each title's ~6 theme phrases were their own vector points: 199,249 points on the owner's
store, but only 87,922 distinct phrases. The copies crowded every query: the 20 nearest points were
often the same few phrases, so a contrast search found few titles. Measured on a copy, as title
recall@20 against an exact search (titles that share a phrase share its score, so ties count as
found):
- **Results:** 0.65 → 0.96 without a domain, 0.88 → 0.97 for films.
- **Size:** the store goes from 1.8 GB to 935 MB on disk.
- **How it works now:**
- The vector collection `media_phrases_v1` holds one point per phrase.
- Which titles carry a phrase is a table beside the store (`facet_map.sqlite3`).
- The wrapper's `facets_*` calls keep their shapes, so the search probe, the backfill and the
nanogenres read it as before.
- A query widens when a domain filter leaves the nearest phrases short of titles.
- **Writing:** a phrase the index already holds is not embedded again.
- **The move:** the new custodian job `facet_phrases` moves the old per-title collection over once,
reusing its vectors (199,249 facets in 104 s on the copy; none lost). A title written meanwhile
keeps its new facets, and one that straddles two pages is completed. The job then removes phrase
points no title carries, and the index folders Chroma leaves on disk after dropping a collection.
- **Gone:** the facet compaction and `FACET_COMPACT`. Phrase points are only added or upserted in
place, so the index no longer grows dead slots.
Tests: `tests/test_ram_footprint.py` (section 5), `tests/test_facet_index.py`.
**The LLM security scan is gone.** It sent changed files to GPT-4o on every PR and the whole
server weekly. It read one file at a time, so its highs were mostly cross-file false positives, and
it failed every run once its API credit ran out. CodeQL, Bandit, OSV-Scanner and Scorecard stay.
Removed: the workflow, its scanner script, its lock and its accepted-findings list.
**Knowledge Base maintenance: lyrics profiles, the "tasks due" badge, the anime card.**
- **Lyrics profiles ask for their JSON shape.** The summariser bake's system prompt lists six modes,
and LYRICS PROFILE is not one of them. The model often opened with "the requested mode is not
among the six defined task modes" and then wrote prose: 79 of 166 artists failed on the NAS one
night. The call now sends Ollama the answer's schema, which carries the cleaner's checks: at
least two themes and a real profile. In a replay 0 of 12 failed with it, 4 of 12 without. The
bake's prompt no longer insists on six modes either; that takes effect with the next
`build_models.py`.
- **No guessing from almost nothing.** A text with fewer than 10 real words (an "[Instrumental]",
a title shouted four times) is not shown to the model. An artist whose texts hold fewer than 40
in all is counted as "without real lyrics", not as a failed profile.
- **No copied examples.** The prompt named two example themes, and the model copied them verbatim
into 13 of 772 profiles ("leaving a small town", "grief turned into anger"; 3 of 20 in a replay,
0 without). The examples are gone, and a profile carrying one is made again.
- **"N tasks due" no longer counts the every-tick tasks.** `plex_followup` and `memory_catchup`
run on every 30-minute tick by design (cadence 0.4 h), so they were always due between two ticks
and the badge never left 2 (`every_tick` in the custodian status).
- **The anime card counts what carries an AniList id.** It showed "0 AniList resolved, 7,514
pending" forever: the mapping file's AniList column has no writer, because resolving through AniDB
ids drifts. It now shows the library's anime whose enriched profile names an AniList id (2,017 of
2,421 on the owner's data) and the pins, the crossref's among them. The coverage check reads the
profiles too.
- **No ERROR on every stop.** The PC agent poll that is in flight when the app stops was cancelled
and logged as a job failure with a traceback; it now ends quietly and counts as no missed poll.
Tests: `tests/test_kb_maintenance_fixes.py`, `tests/test_custodian_queues.py`, `tests/test_pc_agent.py`.
**The model check names bakes that no longer match the code.** A bake (curator, summariser,
pitcher) is built once. A changed system prompt reached the NAS and the PC only through a manual
`build_models.py`, so a host could run a prompt the code had left behind without anyone seeing it.
At start the check now reads each bake back (`/api/show`) on the main host and the node. It
compares the system prompt (line endings and trailing whitespace aside), the base model and
`num_ctx`, and logs every difference with the command that rebuilds it. It only reports: whether a
host rebuilds by itself is decided after the change has been compared on the PC.
**The summariser bake follows the code by itself.** When the start-up check finds the summariser
bake differing from the code, it is made again on that host, the NAS and the node alike, and then
unloaded so the next call reads it. Only a bake that has to change triggers this. When its base model
is not on the host, the base is pulled for the rebuild and removed again afterwards. The removal
frees nothing (the bake is the same weights under its own name) but leaves the host's model list as
it was, and when the weights are still there the pull fetches no layer (1.9 s on the PC). Before this went automatic, the new
summariser prompt was compared with the old one on the PC, each three times (old, old again, new),
on real jobs:
- **60 films and series:** profiles moved no more than two runs of the same bake do (embedding text
0.899 against 0.910; the paired difference is not significant). Themes, moods and keywords
overlapped the same or better.
- **Significance (15 titles) and memory extraction (15 real chats):** the same verdicts or better.
- **Music (20 artists):** a small, real shift towards the music prompt's own rules (0.897 against
0.916), smaller than the run-to-run noise.
No re-enrichment is needed. The curator and the pitcher are only reported: their prompt shapes
chat, the benchmarks and the curator examples.
Tests: `tests/test_model_check.py`, `tests/test_model_autobuild.py`.
**Music enrichment no longer loops on artists a source knows without data.** The outage guard
stops a category when half of its last 50 items come back not found, and gives those items their
attempt back. On the NAS, 1,253 of the 1,288 due music misses had a source that answered: Last.fm
or MusicBrainz found the artist, but without tags or a bio (mostly Spotify imports). Every
30-minute tick, music stopped after 50 items as "upstream degraded" and refunded them. The next
tick tried the same 50 again, and each run ended with a full taste recomputation, about 110 s on
the GPU with four curator calls: 80 times in two days. A miss where a source answered now counts
like a credit line. It does not count toward the ratio and its attempt stays, so the normal backoff
applies. A miss that every source missed still trips the guard.
Tests: `tests/test_node_names.py`.
**The summariser no longer writes the title and year itself.** The METADATA STRUCTURING template
showed `"title": "..."` and `"year": ...` and let the model fill them in. With no year in the
source, the model wrote `"year": 19XX`: invalid JSON, so the item fell back to a rule-based
profile and was retried. One anime failed 29 times this way on the NAS. A title with quotes in it
("Weird Al" Yankovic) came back unescaped, with the same result. The template now carries the
source's title, year (`null` when there is none) and type as JSON, and the profile takes title and
year from the source again, as it already did for the rating. Before, a year the model guessed
could also win over the source's empty one in the merged profile. On the PC, three runs each: the
two failing titles went from 0 of 3 to 3 of 3, and the two control titles were unchanged.
Tests: `tests/test_summarizer_echo_fields.py`.
**A new logo.** Curatarr has a designed logo now: a C with a diamond at its heart. The ring C
(the "classic") is kept beside it. Every icon is rendered from one set of files in
`assets/brand/` (SVG plus a 1024 px master each):
- **Favicon:** the mark, as `/icons/favicon.svg`.
- **Phone and PWA icons:** the app icon, a dark tile with a ruby diamond. The rounded tile serves
as "any". The maskable and iOS icons are full bleed, because the phone rounds them itself, and
the mark sits inside the safe circle. A new monochrome icon covers themed icons and badges.
- **Tray and `.ico`:** the mark (the tray drew the old eye in code).
- **Sidebar, login, shutdown screen:** the wordmark and mark as inline SVG, coloured by brand
tokens (`--brand-amber`, `--brand-diamond`, `--brand-ruby`). Amber stays the UI's only accent.
- **README header:** the wordmark, with a dark one for GitHub's light mode, plus a social preview
image (`docs/brand/social-preview.png`).
`scripts/make_icon.py` renders all of it. The service worker's shell is v2, and the icons
revalidate like the scripts so the new ones show up. The name, logo and wordmark are not under
the AGPL-3.0: all rights reserved (README, License).
Tests: `tests/test_brand.py`.
**The logo at work: classic icon, thinking ring, busy pulse, a watermark.**
- **Classic, per device:** Settings → Account → This device → App icon: Modern / Classic switches
the tab's icon and the top-bar and login logos (`favicon-classic.svg`, the classic wordmark). The
tray has its own "Classic icon" switch in its menu (`data/tray.json`). An installed app keeps the
icon it was installed with, because the manifest is fixed.
- **One logo in the chat:** the curator's avatar beside each reply is the favicon file, the classic
one under Classic. It used to be the old eye drawn as a data URI. The mark now sits on its
circle's own centre: the C opens to the right, so its drawing box was narrower than the circle
and the mark sat 2 % off. Its size is whole pixels (14 px in the 18 px badge).
- **The avatar thinks:** while a reply is on its way, the avatar's own mark moves, in place of
three dots. Its four segments run one after another onto the first, then the stack spins once,
fast, and unfolds back into the C (2.8 s a cycle). The diamond twinkles like an AI sparkle: it
shrinks while the segments gather, then flares, turns and glows ruby during the spin. A
"Thinking…" line shimmers until the server's status lines take over, and they shimmer too. A
first cut lit the classic ring's segments in place, one by one; that read as choppy and put a
third logo on screen.
- **One badge size, and you on the right (2026-11-01):** the thinking mark sat inside the bubble at
24 px while the badge beside the finished reply showed it at 14 px, so the mark jumped and shrank
when the reply landed. Now the badge itself thinks: same 24 px circle, same slot, 18 px mark
either way. Your own messages get the matching badge on the right: your Plex account picture
(asked from plex.tv with your own sign-in token) or your Jellyfin user image, fetched by Curatarr
so the browser never contacts plex.tv or Gravatar, and your initial when there is none. The
sidebar shows the same picture. Tests: `tests/test_user_avatar.py`, `tests/test_brand.py`.
- **Busy pulse:** while a model works, the diamond in the top-bar logo breathes ruby. That covers a
reply streaming in this tab, or the server reporting a curator call or an enrichment run
(`model_busy` in `/api/processes/status`, the 30 s poll).
- **Watermark:** the wrap-up's summary card and every story slide carry the mark, so a screenshot
says where it came from.
Under reduced motion the avatar and the text stand still, and the pulse is a steady ruby.
Tests: `tests/test_brand.py`, `tests/test_a11y_nav.py`.
**Curatarr's icon and description in the TrueNAS app list.** A custom app showed TrueNAS's generic
icon and an empty description: the compose file has no place for them. A new script,
`deploy/truenas/apply_metadata.py`, runs on the NAS as root and fills in the app's metadata file.
It sets:
- the icon and the description
- the home page and the sources
- five screenshots, keywords and a category
- a Web UI button and short notes
It keeps every field TrueNAS owns, leaves a `.bak` copy, and is safe to repeat (TrueNAS can rewrite
the file on an update). `deploy/docker-compose.yml` gains an `x-portals` block, so a fresh install
has the Web UI button from the start. The steps are in `deploy/truenas/README.md`. This is the
community workaround; TrueNAS has no official field for it yet.
The icon is the mark cut out, with the app icon's ruby diamond (`icon-transparent-512.png`, new
in `make_icon.py`): the dark app tile looked like a square box among the other apps' icons. The
script also sets the title back to "Curatarr" (TrueNAS rewrites it to "Custom App").
Tests: `tests/test_truenas_metadata.py` (against a real custom app's metadata file).
**Chat openers no longer scold the user's own pace.** The starters told the owner off for pauses
that were simply how he watches. Examples: "you haven't touched Tanya the Evil in a week" for a
series he watches once a fortnight, and "only on episode four and already losing interest" for an
episode from the night before. The facts handed to the curator said "N days ago" and nothing to
compare it with. Now:
- **A `pause` in each title fact:** last watched, active and stalled each carry one. It is
measured against the user's own rhythm for that title (the median gap between its sittings
over the past year) and against whether anything else was watched since. It reads one of:
- normal for this user, usually every N days
- nothing at all watched since, so a break from everything rather than from this title
- longer than usual, with other titles watched since
- **Enforced in code:** the prompt asks to leave the user's own pace alone. A replay with the real
curator on the owner's data showed that the curator's pointed voice still found gaps to poke
at ("already pausing", "glacial"). So, like the starters' other rules, it is enforced in code. An
opener that talks of pauses, pace or giving up stays only when it is about a title whose pause
is longer than usual. In the replay, every such line about the owner's normal-paced titles was
dropped. The lines about a series really left for 70 days remained.
- **A started episode is no longer "0 episodes":** the viewing rhythm called an episode started but
not finished "0 episodes on Thursday evening", which read as untouched. It now says "an episode
started on Thursday evening, not finished yet".
Starters already in the pool expire on their own within 48 hours.
Tests: `tests/test_chat_starters.py`.
**Episodes marked as played one at a time are no viewings.** Plex's "mark as played" writes no
history event. The sync caught a mark only when three or more items shared one second. Marking
episodes one after another lands them seconds apart: 53 plays in 20 runs on the owner's server. In
one run, Goblin Slayer E8, E5, E7, E1, E3 and E4 came within 30 s. High School DxD E3–E8, 5 s apart,
read as a six-episode binge.
- **The sync:** a finished video item whose last-viewed time matches no play event in the
history is a mark, flagged like a bulk mark: seen, not a viewing. This runs only on an
incremental sync whose history walk covered the window. A first full sync can list items older
than the history keeps.
- **Started, then marked:** the unfinished row of that viewing (within 30 days) is finished now.
It keeps its own evening and the minutes really watched. Before, it moved to the moment of the
mark and counted in full.
- **Played time:** a finished row whose resume point is short of the episode's length counts
that point. A finished viewing's resume point is its length, so nothing else changes.
- **What was stored:** a one-shot migration (schema generation 3) marks two finished plays of
different items of one title, a minute or less apart, neither with a play event (no player).
On the owner's data that is 57 rows, all in such runs. An evening's two episodes, plays with a
player and one item played twice stay viewings.
- **Stored wrap-ups:** `FACTS_VERSION` 12 rebuilds their facts on the repaired history the next
time each is opened. A story whose facts changed is marked stale.
Tests: `tests/test_single_marks.py`; `tests/test_netflix_import.py` counts the third query that
leaves Netflix rows alone.
**The bell says when this server's Ollama stops answering.** The NAS's Ollama app went down on
2026-10-29 at 19:10 and stayed down for 21 hours. Embeddings, the summariser, lyrics and memory
extraction failed, each only in a log line of its own, while chat kept working on the PC. A memory
extraction error finally surfaced it.
- **The probe:** a job asks the main host's `/api/version` every 30 s. Three misses in a row, so
a model that is loading is no outage, mark the host down from the first miss. The log says so
once, and again when it answers (`[hosts]`).
- **The bell:** admins get a danger item. It gives the time it stopped, what waits (embeddings,
the summariser, search), where chat still runs, and that a restart of the Ollama app is all it
takes. It counts in the bell badge although it has no button.
The work that failed meanwhile resumes by itself. Memory extraction only moves its cursor after a
run that succeeded, and the custodian's catch-up retries.
Tests: `tests/test_host_watch.py`.
**Wrap-Up v2, step 2: an editor picks the story, a writer tells it.** v1 asked for the whole
story as one JSON form and checked it with rules that were strict on style and loose on truth:
- an honest rounding, a title with an "!" or a band called Journey sent everything back;
- one repair turn named only the first fault, and a second failure threw every good field away;
- a wrong number that happened to appear elsewhere on the sheet passed, and so did a title
nobody had cited.
Now (`services/wrapup_writer.py`):
- **Editor (JSON, a choice):** picks 3–7 insight cards in story order, the roast and toast cards,
the archetype from the earned traits, a share line, the look-ahead pick and the cluster names.
- **Writer (free prose):** writes one section per card plus roast, toast and the next pick, citing
the cards each sentence rests on.
- **Each sentence is checked against the cards it cites.** Its numbers must be those cards' own
(renderings included), and a title it names must be on them. A sentence without its own
citation takes the next one's, and finally its slide's card.
- **Style is fixed, not rejected:** titles and artist names are masked first, stock phrases are
cut and "!" becomes ".".
- **Repair per slide:** only the slides that failed go back, every fault named. One that fails
again gets its card's template, and the rest stay as written.
- **Voice:** the curator may quote the user's own debate messages back to them, only in their own
wrap-up and opt-out on the page (`/api/wrapup/prefs`). It writes in the language the user writes
to it most.
- **Page compatibility:** the story is stored as slides plus the v1 shape, so the current page
renders it unchanged until the story mode arrives.
Tests: `tests/test_wrapup_writer.py`; `tests/test_wrapup_story.py` now covers the sheet, the
stand-in, the task and the season.
**Wrap-Up v2, step 1: what is worth saying, and the new telemetry in the story.** The v1 fact
sheet handed the curator about 40 equally weighted lines. Nothing said which facts were
remarkable, every tile rendered because data existed, and the number check rejected honest
rounding ("about 58 hours" for 58.3). Changes:
- **New sections** read the records added in the last PRs (`services/wrapup_sections.py`):
- **court record:** debates decided, won and lost; the boldest keep against the judge's score;
what the curator talked you into or out of; GB saved and freed;
- **GB in vs GB out**, owner only, with a hoarder / pruner / balanced verdict;
- **time-to-watch:** how long the period's new titles waited on the server, the longest wait
and the share played within a week;
- **auteurs:** the director, studio and actor behind most of the hours;
- **taste river:** each month against the one before, from the taste snapshots.
- **Insight miner** (`services/wrapup_insights.py`): turns the facts into ranked claim cards.
Each card has a score for surprise, specificity and contrast, a plain "why", the story beat it
belongs to, and approved renderings of each number ("58.3 hours" → "58 hours", "nearly 60
hours", "two and a half days"). Cards under the floor are dropped, so a quiet period gets a
shorter story, not padding.
- **Fact sheet:** it ends with the ranked cards, and the curator is told to build the story from
the top. Their renderings pass the number check.
- **API:** `GET /api/wrapup` returns the cards as `insights`.
- **Rebuild:** `FACTS_VERSION` 5.
Tests: `tests/test_wrapup_insights.py`.
**Taste snapshots: what each month of watching looked like.** `TasteVectorEntry` is one row
per user, overwritten on every rebuild, so the taste profile had no history: no "what you were
in March", no monthly drift. A daily custodian job (`taste_snapshots`, Chroma reads, no model)
now stores one snapshot per user, category and calendar month (`services/taste_snapshots.py`).
- **What a snapshot holds:** plays, hours, titles, the top titles by played time, and the
played-time-weighted mean of the month's stored item vectors.
- **Back-filled:** item vectors are static per item, so the first runs fill every month there is
from the watch history, within a per-run budget.
- **Rewrites:** a closed month is written once; the running month is rewritten until it closes.
A change of embedding model rebuilds everything.
- **Comparable across a year:** means are stored uncentred, and readers centre them on the
current corpus mean (`similarity`, `month_over_month`). Months written a year apart stay
comparable.
Tests: `tests/test_taste_snapshots.py`.
**The judge's record and the plays of removed media are kept, not deleted.**
- **Verdicts:** they lived only in the 30-day verdict cache, so by December the judge's spring
verdicts were gone. Every verdict of a deletion scan now goes into `judge_verdicts`: the verdict
after Python's guards and what the model said, the deciding pillar, the four findings, the
deletion score and the size (`services/judge_record.py`). A row is written when a title's
verdict first comes or changes. The same verdict at the next scan only counts, so nightly scans
that reuse cached verdicts add no rows.
- **Protections:** lifting one deleted its row, and re-judging overwrote its verdict. What it was
now goes into `protection_history` first: lifted (and by whom), changed by the judge, or
downscaled. The live `protected_media` table is unchanged for every reader.
- **Plays of removed media:** the maintenance action "Delete history for removed media" deleted
the plays of every title that had left Plex. A title deleted in March then lost its plays
before the year's Wrap-Up. The action is now "Hide history": it sets `orphaned_at`.
- **Hidden:** every ORM read of `watch_history` hides marked rows (`database/orphaned.py`), so
history, taste and recommendations look as they did after a delete.
- **Counted:** the Wrap-Up and the taste snapshots read them. So does the Plex sync's
duplicate check, which would otherwise import a hidden play again.
- **Unhidden:** a title back in Plex under its old key gets its plays back on the next run.
Tests: `tests/test_judge_record.py`, `tests/test_plex_history_safety.py`.
**Music: skips kept, albums, and the export's own account of each play.**
- **Skips:** the Spotify import dropped every play under 30 seconds, which is where skips live. They
now go into `music_skips`. That table is kept apart from `watch_history` on purpose: every play
count, taste vector and replay rule reads `watch_history`, and a skip is not a play.
- **What each play now records:** a Spotify play keeps its album, how long it ran (`listened_ms`),
the platform, why it ended and whether it was shuffled. A Plex track keeps its album.
- **Re-upload fix:** re-uploading an export after the matcher had run read every play as new.
Dedup keyed on `plex_item_id`, which the matcher rewrites; it now keys on the Spotify URI and
the time.
- **Older imports:** a one-time custodian job (`spotify_reread`) reads the already imported files
again. It fills those columns where they are empty and brings the skips in. Skips go to the user
whose plays the file holds, and are never guessed.
- **Wrap-Up skip rate:** it summed a Boolean column, which comes back as `True` (1). It read near
100% for everyone, and only counted Spotify's skips anyway. It now counts Spotify's skips and
unfinished plays, and names the track skipped most. A Plexamp-only listener gets no skip rate
instead of a wrong one (`FACTS_VERSION` 4).
Tests: `tests/test_music_context.py`.
**Credits index: who made what, queryable and durable.** Directors, writers, cast, voice actors,
studios and companies lived only as JSON inside the raw API cache (90-day TTL). They were not
queryable, and a title deleted in March had lost them by December. TMDB's production companies,
networks, collection and person ids were fetched and dropped. Changes:
- **TMDB fetch:** `fetch_tmdb_full` now also keeps the following, all with TMDB ids, next to the
flat fields, which are unchanged:
- directors, writers, creators, the source author and the composer;
- the billed cast with character and order;
- production companies, networks and the collection.
- **Walker:** a daily custodian job (`credits_index`, no model) files every live raw document of
the video categories into `media_works`, `media_credits` and `media_companies`
(`services/credits_index.py`).
- **One entry per title:** a title is filed under its best id (TMDB; for anime AniList, else MAL,
else TMDB). Rows sharing any id are merged into one title: the credits of the richest row, the
companies of all. An anime's AniList studio row and TMDB voice-cast row therefore become one
entry. A title filed under another of its ids by an earlier walk is folded in.
- **Older rows** give names only, which join across titles by a normalised name key. Anime cast is
filed as voice cast.
- **Only changes are written:** an unchanged title is skipped by fingerprint.
Tests: `tests/test_credits_index.py`.
**The user's own clock, DST included.** Local time came from this machine's current offset,
applied to every play. Three things went wrong with that:
- plays from the other half of the year were an hour off across a DST change, enough to move a
23:30 play out of "night";
- a container on UTC (the Docker default) put everyone's evening in UTC;
- a household member in another zone was wrong all year.
What changes:
- **The user's zone:** the browser now reports its IANA zone (`POST /api/users/me/timezone`, sent
when it differs from the stored `users.timezone`; unknown names are refused).
- **Wrap-Up:** hours, dayparts, days and calendars convert each play with the offset in force at
its own time. Inside the query this is a CASE over the zone's changes in the span
(`services/user_time.py`).
- **Without a stored zone,** the machine's zone stands in: TZ, else /etc/timezone, else today's
offset. Sittings' dayparts use it too, now DST-correct.
- **Rebuild:** closed-year wrap-ups are rebuilt once on the new clock (`FACTS_VERSION` 3).
Tests: `tests/test_user_time.py`.
**Debate outcomes: what the judge proposed, and who convinced whom.** The resolution log recorded
that a deletion debate ended, but not what was at stake or who moved. A row had no link to its
proposal, so the judge's score, the CUT / STAGNANT verdict and the GB could only be guessed back by
title. "Consensus" also covered both "you talked the curator out of it" and "it talked you into
it". Changes (`services/debate_outcome.py`):
- **New columns:** every write path (card Keep, delete, chat keep) now fills `proposal_id`,
`score`, `verdict`, `storage_mb`, `thread_id` and `user_turns`. A chat keep outside the proposal's
thread finds the proposal it just closed.
- **`persuader` by rule:** an override or a debate without a word from the user moved nobody, and
a proposed cut kept by consensus means the curator conceded.
- **`persuader` by the summarizer:** a delete by consensus after a debate goes to the summarizer,
which reads the thread and is asked whether the user argued to keep it. This runs as a custodian
job (`debate_outcomes`, summarizer lane), so labels for old rows and new come from one
classifier.
- **Old rows:** rows from before the columns are linked to their proposals by title and time.
- **Checking the labels:** the owner can compare them with their own words at
`GET /api/stats/debate-labels`.
- **Safety:** a fault filling the columns never costs the log row itself.
Tests: `tests/test_debate_outcome.py`.
**Library ledger: what arrived when, and what left.** Nothing recorded either side of the
storage balance. GB freed was read off Curatarr's own deletions only, so a title deleted straight
in Radarr never counted, and GB added was not known at all. The tech sync's whole-library walk
(every 12 h) now also writes two durable tables (`services/library_ledger.py`):
- **`library_items`** holds every title the library has held, never deleted, with the earliest
Plex `addedAt` it was ever seen with. For a series that is its first episode.
- **`library_events`** is append-only:
- arrivals, one per title and month, sized by that month's files and back-dated from
`addedAt`, so the current library's history is known from the first walk on;
- titles a complete walk no longer lists (gone, deleted anywhere), and gone titles listed
again (returned);
- size changes the arrivals don't explain: a replaced, bigger file grew; a deleted season
shrunk.
- A walk cut short by an outage marks nothing gone.
- `velocity(start, end)` gives GB in vs out by month, with the biggest arrival and departure.
This is the raw material for the Wrap-Up's hoarder-vs-pruner balance and time-to-watch.
- Music is not walked by default, so it has no ledger yet.
Tests: `tests/test_library_ledger.py`.
**The ledger's arrivals come from the arr history.** The first walk against the real
server showed that Plex's `addedAt` cannot date an arrival:
- a replaced file takes the new file's date (1091 of 1110 upgraded films), so an upgrade read
as a new arrival plus a shrink of the old size;
- a re-added library dates every file to the day of the re-add (15,135 anime episodes on one
day), so one month showed 12 TB "new".
Radarr and Sonarr record every import with its size, and every replaced file as an `Upgrade`
deletion in the same second. A new custodian job, `arr_ledger` (`services/arr_ledger.py`, no
model), reads that history:
- all of it once, oldest first, 60 pages a run; then what came since, also right before each
tech walk;
- an import is an arrival; an import paired with its `Upgrade` deletion counts only the
difference;
- departures stay with the Plex walk, which sees a title or a season leave however it left.
Sonarr drops the history of a deleted series, so the arr cannot be the only witness.
Once a service's history is read to the end, its titles are covered:
- the Plex walk writes no addedAt arrival, return or growth for them, only what left (gone, or
shrunk by more than the arr's own records explain);
- the arrivals it had written for them are removed once;
- a covered title's arrival date is its first arr import.
Tests: `tests/test_arr_ledger.py`.
**What left before the ledger existed.** A past period's balance showed every arrival but only the
departures since the first Plex walk; on the real server 2026 read 38.5 TB in and 5.3 TB out.
Before that walk, two witnesses remain:
- **Curatarr's own deletions** (`DeletionProposal`, deleted). Radarr and Sonarr drop the history
of a deleted film or series, so this is the only record of those.
- **Files deleted in the arr by hand** (`Manual`): the arr ledger now keeps them as neutral
`removed` rows, and the history is read once more to collect them (state generation 2; the
coverage stays meanwhile, so no Plex arrival comes back).
`velocity` counts both as GB out only for the time before the first walk; after it, departures are
the walk's. A deletion is counted once even when its arr side or a second member's proposal also
recorded it. `MissingFromDisk` is never a departure: on the real server 32,352 episodes "went
missing" on one day, a moved library.
Replayed on a copy of the NAS backup, 2026 reads 38.3 TB in and 19.2 TB out.
**Watch history: every play of the owner is its own row, and a rewatch in progress is not a
completion.** The owner's plays came from the library listing, which carries one timestamp
per item (`lastViewedAt`). Two plays of a film between two syncs became one row, the history
from before the first sync was one row per item ever watched, and every listed item was
written as finished. Rewatches, comfort titles, streaks, "same span before" baselines and the
completion rate were measured on that. The sync already walked `/status/sessions/history/all`
to attribute plays. It now also keeps the owner's own finished views (accountID 1) that the
listing does not carry, borrowing the listing's or the stored row's genres, duration and ids.
The first sync after the update walks the whole history once and recovers the old plays; a
walk cut short by an error is retried from the start. Recovered old plays do not trigger the
watched-a-recommendation follow-up. A watched item the listing returns with a resume point and
no finished view at that time is now a partial play, which turns into the finished view once
it is finished. An unfinished play of unknown length no longer counts as watched. Closed-year
wrap-ups built on the old data are rebuilt once (`FACTS_VERSION`). Design notes:
`docs/WRAPUP_V2_BLUEPRINT.md`. Tests: `tests/test_plex_owner_history.py`.
**Memory: the container held 4 GB of anonymous heap; the measured owners are smaller now.**
The NAS view (3.3 GB, then 4.1 GB after a few hours) was real heap, not page cache. Measured
on a copy of the real data: the facet index rewrote a title with delete + add, and Chroma never
reuses a deleted slot, so 333k slots held 199k live points in a 1.2 GB resident index. Titles
are now replaced in place (an unchanged id keeps its slot): that stops the growth, which came
from re-enrichment (~25 MB per 1,000 titles re-enriched, never returned). A weekly
`facet_compact` task can rebuild an index whose dead share passes a quarter (200 s on the real
store, queries stay served, writes made during the copy are replayed, then a fresh client
returns the old index's memory: 1.3 GB of indexes became 0.85 GB). It is off by default
(`FACET_COMPACT=true` turns it on): the rebuilt graph's recall against exact search was 0.81
where the grown one had 0.89. The image sets `MALLOC_ARENA_MAX=2`. Every pooled SQLite connection held up to 64 MB of page cache
next to the OS cache: 8 MB now (12 warm connections: 840 MB down to ~100 MB, a 0.6 s bulk read
gets 0.3 s slower). The enrichment cycle loaded all 384k plays as ORM entities (722 MB peak)
to dedupe them to 18k titles: it streams columns and dedupes while reading (12 MB peak). The
taste rebuild shares equal strings between its play rows and reads enrichment profiles a chunk
at a time (peak 730 to 277 MB). The arr library cache drops fields nothing reads (about 26 MB
for Sonarr and Radarr, 88 MB if Lidarr is on). Tests: `tests/test_ram_footprint.py`.
**Light instead of lines: a calmer, more tactile interface.**
- Panels, cards and dialogs are lit from above (a hairline edge, a faint top highlight,
soft layered shadows) instead of outlined; the topbar and sidebar are frosted glass,
and the canvas carries a faint amber light. One type scale and one spring curve.
- Posters shimmer until they arrive and fade in; a failed one stays a quiet placeholder.
A card picks up its poster's colour as a soft ambient light, stronger on hover.
- Deletion cards lead with the curator's pitch; the size reads "frees 4.2 GB" (or MB/TB),
Keep / Discuss / More rest a step back until the card is hovered, and an empty note
is one dashed line until you use it.
- Buttons, chips and tabs give a little when pressed on every device, and switching
views crossfades where the browser supports View Transitions.
- Chat: your own messages are a warm tint instead of a solid amber block, replies keep a
readable line length, the input floats as a glass bar, and a reply growing below the
fold no longer yanks you down while you reread: a "New reply" pill offers the way.
- Phones: the tab bar floats as a glass dock, sheets can be dragged down to close, and
the tables in Activity, History, Knowledge Base, Libraries, Settings, Users and setup
turn into stacked rows instead of scrolling sideways.
- Settings: on/off options are switches, the vibration level is a segmented control,
and vibration now also marks sends, Why? toggles, destructive confirms and errors.
- Fixes after the first look: tables are full width again on desktop (their phone
class collided with an existing layout utility), chat sits in one centred column on
wide screens, Reclassify uses the same sections as every other view, and every
checkbox is amber.
- Knowledge Base overview: one card per library (share enriched as a number and a
meter, only the open states that are not zero, what they wait for) instead of a
13-column table; storage as tiles. Curation: protections read as titles with
plain badges (Protected, Keep · downscale, Asked in chat); the pillar reasoning
folds away under "Why it's protected". Fix: opening that fold re-loaded the
whole section. Report: a twelve-month column chart and even stat tiles.
Swipe on a desktop keeps card and rails together in the middle, folds the
keyboard legend, and shows a soft placeholder for a title without a poster.
- Music artists in the library show their Deezer picture again (from the SoulSync
profile's Deezer id; the Plex music index has no images since Lidarr went off).
**Curatarr on your phone: an installable app with a real mobile layout.**
- Installable (PWA): a manifest, home-screen icons and a service worker. Open Curatarr
over https (or on localhost) and the browser offers "Install app"; on iPhone the
drawer's Install button shows the Add-to-Home-Screen steps. The app shell opens when
the server is out of reach; API answers are never cached.
- Below 768px a bottom tab bar replaces the hunt for the hamburger: Chat, For you
(Recommendations), Swipe, Library (a sheet with TV Shows / Movies / Music) and More
(the full drawer, with a dot when something there needs you).
- Gestures: swipe in from the left edge to open the drawer, pull down at the top of a
view to refresh it, tap the active tab to scroll back to the top.
- Swipe on a phone fills the screen between the topbar and the tab bar: the card and
all six reactions fit without scrolling, the exact-star row is a 5×2 grid of big
buttons, and every decision gives a short vibration (Android; a double pulse for
Loved, off with reduced motion). Fix: the reaction buttons, the exact stars and the
glow around a dragged card now show their reaction colour; a default colour later in
the stylesheet had overridden it.
- Dialogs open as bottom sheets, toasts sit above the tab bar, tabs scroll sideways,
Settings' sub-navigation becomes a chip row, the chat input grows with your text and
uses icon buttons, and every edge is padded for the notch and home indicator.
- The notification count appears on the installed app's icon, and the topbar says
"offline" while the phone has no network.
**Chat no longer waits on background work or on reloading the model.**
- A chat message goes ahead of every queued background job (recommendations, proactive
messages, chat openers, principle learning). If one of those is already generating, it
is stopped and re-run after your reply.
- Chat no longer loads the small model before your reply. On a 24 GB card that pushed
the curator out of VRAM, and the reply paid for a full reload. The title in your
message is now read by the curator itself. The Wikipedia/reception summaries are
fetched in the background once you are idle and are there from the next message on.
- Work after a reply (keep/protect detection, memory extraction, verification answers,
bulk enrichment) waits until you have been quiet for a while. It no longer pushes the
curator out between your reply and your follow-up.
- Memory extraction is skipped for conversations with no likes, dislikes or reasons in
them. Keep/protect detection still runs after every message: its keep-phrase pre-gate
only logs the messages it would have skipped until the log shows it misses no real
keep. Both phrase lists also cover German word order ("Lösch den Film nicht", "Finger
weg") and more ways of saying you liked something.
- The curator stays loaded for 5 minutes after a reply unless background work needs the
card. It used to be unloaded after 60 seconds, so reading a long reply cost a reload.
- Every curator request uses the same context size and keep-alive. The deletion judge and
the taste summary used to shorten the curator's keep-alive. The taste summary also
reloaded the small model at the curator's 16k context.
- During a deletion scan using the separate pitcher model, the scan waits for your
conversation to settle before taking the GPU back. Each message used to swap both
models out and back in.
- Focusing the chat box or typing starts loading the curator if it is not already in
VRAM, so it loads while you type instead of after you press Send.
**Faster deletion scans and taste rebuilds.**
- The judge's per-title watch lookups use new watch-history indexes: under a millisecond
instead of 30–180 ms on a 400k-play history.
- Learned keep-considerations for the 60 judged candidates come from one batched
embedding call and one read of your memories. That was a call and a full read per
candidate, and the scoring is now about 34× faster.
- While the GPU writes one verdict, the next title's evidence is already being gathered.
- Reception, dialogue and Wikidata lookups before a scan run a few titles at a time,
alongside the significance pass, instead of one after another.
- A taste rebuild pulls each category's vector corpus once (4–9 s for 30k vectors), not
twice per household member.
- Pages and API responses over 1 KB are gzip-compressed. Live streams are not.
**Months without a restart: bounded memory, retention, and healing after a restore.**
- Search's in-memory phrase vectors are capped (about 100 MB at most). They used to grow
with every search for as long as the app ran.
- A database restored without its vector store, or with an older one, is noticed at
startup and daily. Missing vectors are rebuilt from the cached profiles in the
background, titles whose profile is gone too are enriched again, and the owner is told
once a week while the two disagree.
- A deletion whose outcome was unknown ("limbo") is recorded as deleted once Radarr,
Sonarr, Lidarr or Plex confirm the item is gone. One still in the library stays for you
to retry, with a weekly reminder. A delete stuck out of sight after a failed save shows
up in limbo the next day instead of after the next restart.
- The weekly database clean-up (VACUUM) runs on servers that are never restarted, and
covers the lyrics index too.
- Old retired rows are removed: deletion proposals replaced over 90 days ago, read
notifications over 90 days old and used conversation starters after 30 days. A row
someone discussed in chat is kept.
- `update_db.py` keeps its newest three pre-migration copies instead of all of them. The
daily backup is skipped, loudly, when the disk could not hold it plus as much again.
- Memories and principles that were replaced keep their text but drop their stored
vectors, and saving a new memory no longer reads every replaced one.
**Easier to use with a keyboard and a screen reader, and recommendations say why.**
- Chat replies are announced once they're finished, not word by word.
- Keyboard focus stays inside an open dialog.
- The tab you're on in Deletions, the Knowledge Base, the arr pages and Recommendations
is part of the link, so a reload or a bookmark opens the same tab.
- Recommendation cards have a "Why?" that lists what each pick was based on: how close it
is to your taste, the genres you like, trending, your own stated values, and so on.
- The thinking-status text is readable now, and an armed delete button is solid red.
- New optional setting `ALLOWED_HOSTS`. Set it to the names you reach Curatarr by, and
requests for any other name are refused, which blocks DNS-rebinding pages. It is empty
by default, so nothing changes until you set it.
- A music job that crashed can no longer leave the music pipeline marked as running.
**Household members' data stays theirs, and maintenance stops repeating itself.**
- A household member who tells the curator to keep a title is heard: the deletion judge
can't cut it any more, it becomes the owner's call. This holds with the pillar judge
switched off as well.
- A title deleted and added back in Radarr/Sonarr/Lidarr no longer shows up twice in
search. Its old vector is merged into the new entry: kept, but hidden from search,
and no longer rebuilt. The chat now says "protected for
you" to members rather than "permanently protected".
- Orphan Repair credits each play to the Plex account that watched it. Before, every
household play went to the admin's taste profile.
- The owner's SoulSync follows and likes only shape the owner's music taste.
- Principle capture from deletion debates runs for the owner only.
- Re-filing a show as anime (or back) takes its protections along.
- The nightly SoulSync→Lidarr catalog sync no longer re-adds artists you deleted.
- The SoulSync ban of a deleted Lidarr artist uses Lidarr's own MusicBrainz id. A Retry
whose item is already gone counts as deleted instead of ending in an error.
- A reply like "which show?" in a verification question's thread no longer counts as
your answer; the question stays open.
- A maintenance task that fails waits longer before each retry (up to its normal
interval) instead of rerunning every 30 minutes.
- The weekly audit no longer rebuilds the same orphaned vectors every week.
- The OMDb top-up keeps going until its backlog is empty, and anime rows are no longer
fetched again every day.
- Two overlapping runs can no longer fill the notification bell with duplicate curator
messages, and the hourly message cap is now applied. Verification questions are asked
once, even when two jobs start at the same moment, and they expire after a week.
- Filling in missing external ids skips titles whose match you pinned.
- Music lookups treat "Foo" and "foo" as the same name, and a name whose lookup keeps
failing is parked like one that was not found.
- Re-filing a show also removes its profile under the old category. If the weekly audit
fails partway through a requeue, the finding keeps its one automatic retry.
- The anime mapping cache keeps every season's AniDB entry.
- The Spotify backlog and the wish list mark artists you deleted.
- Household members can wish for music too (when music runs through Plex without
Lidarr): a **Wish** button on music recommendations and in their Spotify backlog. The
wanted list shows who asked. The owner removes any wish, a member takes back their
own, and members have a small hourly budget and a cap on open wishes.
- Each person's Recommendations page shows when their own recommendations were built.
**Members can't overload the box or see the admin's network.**
- Rebuilding a taste profile, forcing a live arr refresh and re-fetching metadata from chat
now have small per-user budgets. Only one taste rebuild per user runs at a time.
- An admin can no longer deactivate themselves or the last active admin. Before, that
locked everyone out of the admin side until someone edited the database.
- Members no longer get root folders, Plex library paths, arr addresses or raw error
text. The detail goes to the server log.
- The model-update review shows judgements the judge didn't answer, and says "no verdict"
when most of them failed.
- A taste rebuild where most embeddings fail now keeps the old profile and says so,
instead of quietly writing a skewed one.
- Large Spotify histories no longer freeze the app for a few seconds during the Last.fm
genre top-up.
**When something can't be read, Curatarr now says so.** Several checks used to read an
unreadable value as "all clear". Now:
- an unreadable game flag counts as a game;
- a server-address check whose name lookup times out is refused;
- an unreadable OMDb usage count leaves no budget for the day;
- the music pipeline's Stop button works even when its flag can't be read.
Screens that showed a failure as if it were real data now say what couldn't be loaded:
- Sonarr/Radarr history on the deletions page;
- enrichment coverage;
- arr profiles in library settings;
- chat history.
A corrupt taste profile is flagged on the deletion run instead of being quietly
ignored. The enrichment status counts are no longer shown to another user for a few
seconds. Two people logging in for the first time at the same moment can no longer
both become admin.
**Fewer pauses from game checks, web requests and large imports.** The "is a game
running?" check (`nvidia-smi` plus a scan of every process) ran before every AI request
and for every enriched or judged title. It now answers from the last result, refreshed in
the background every 30 seconds. Every web request also loaded the system's certificate
list again (about 50 ms each); that now happens once at startup. Large Radarr, Sonarr and
Lidarr libraries, the Spotify import, the anime database and the image cache are read
and written off the thread that serves pages. The test run now fails if any code makes
that thread wait on the database.
**Searches and enrichment no longer pause the server.** Vector-store calls (search,
recommendations, the facet index, every enriched title's write, the embedding
migration) now run on their own thread instead of the one that serves every page. The
vector store opens once at startup instead of on the first search. The monthly Discogs
import unpacks and reads its multi-gigabyte download off that thread too.
**Every database file off the event loop; foreign keys enforced.** The lyrics, cache
and offline-index files are now watched by the same check as the main database. That
turned up the lyrics sync, which held its file locked across every Plex request; it now
holds the lock only while writing. The enrichment cache waits 15 seconds for another
writer instead of 60. The database now enforces that every row belongs to an existing
user. At startup, rows from the past that name a missing user are repaired: a link that
can be empty is cleared, and anything else is kept in `fk_orphans` before removal.
**The server no longer waits on the database.** Every page, stream and background job
shares one event loop, and 561 database calls ran on it directly: while another program
(the music enricher, a backup) held the database, the whole app could freeze for up to a
minute. Those calls now run on worker threads. A built-in check logs any new one (and lists
them at `GET /api/system/loop-guard`, admin only), and a test stops them coming back. The
database now gives up waiting after 15 seconds instead of 60. Two crashes this turned
up are fixed: the Deletions list failed when served from its cache, and proactive
messages were never generated.
**No silent error handlers left.** The last 277 handlers that swallowed an error
(`pass`, or a DEBUG line nobody sees) now each do one of four things: narrow to the
error they expect, log a real failure as a warning, keep outages quiet while bugs are
logged, or say in the code why staying silent is safe. Where one guarded a decision it
now fails closed:
- a half-broken embedding migration can no longer pass its own quality gate;
- the deletion judge defers a title when the owner's taste profile or its format check
can't be read;
- a corrupt notification setting no longer switches every opt-out back on;
- OpenSubtitles pauses when a download can't be counted against the daily budget;
- background learning steps back when it can't check whether a chat needs the GPU.
A test now refuses any new silent handler that doesn't give its reason.
**The deletion judge fails closed.** When the judge could not read the
watch history, its facts said "not watched", a made-up negative for the
household pillar; such a title is now deferred. Verdicts are checked
against a schema (all four pillar findings, a verdict that agrees with the
pillar it names) and a malformed answer gets one repair turn; a second
failure leaves the title undecided instead of proposing it. Markup is
stripped from the parsed answer, no longer from the raw text, where a `<`
in one finding and a `>` in the next deleted everything between them. A
CUT on a title another household member has watched or played becomes
Your call, whatever the model said. The run stops when a game starts or
after two outages in a row, and the Deletions banner names why titles
could not be judged instead of "the model didn't answer".
**Prompts fit the window on purpose.** The judge's evidence is assembled in
priority order and trimmed to the room its system prompt leaves (the title,
owner and household lines are never cut); an oversized prompt is refused
before the call instead of Ollama cutting the constitution off, and chat
drops its oldest turns to fit. `LLM_PROFILE=small` uses an 8k window and
gives the judge a compact checklist version of the constitution;
`CURATOR_NUM_CTX` and `CURATOR_NUM_GPU` set the window and the GPU layers
directly. The defaults are unchanged (16k, every layer on the GPU). The
baked models carry the window the calls ask for, so a call that leaves it
out no longer reloads the model; run `build_models.py` once to pick that up.
**The AppSec review of 2026-10-10** (`docs/security/appsec-audit-2026-10-10.md`).
The first-run exemption for a local browser now wants a browser that
addresses Curatarr as localhost: a DNS-rebound page or a reverse proxy on
the same machine needs the setup code. "Test connection" sends a saved Plex
token or arr key only to the saved address, so a new address needs its own
key, and service URLs must be plain http(s) base URLs (no credentials, query
or fragment, no link-local or cloud-metadata address). A failed test answers
with a classified sentence and the HTTP status at most: never the page it
got back, never refused-versus-timeout (a port scanner's question), never
exception text (CodeQL). A delete with a malformed arr id or Plex rating key
is refused before any request; the image proxy serves raster images only,
under a sandbox CSP; the arr profiles endpoint is admin-only. The encryption
passphrase (PIN) is gone: it encrypted nothing, and its stored hashes are
dropped at startup with secure_delete.
**One writer for SQLite** (`docs/SQLITE_ASYNC_CONCURRENCY_AUDIT.md`). A fresh
install lost four columns of the deletion proposals table on its first
start, and every Deletions query failed until the next restart; the table
rebuild now creates the table from the model and keeps every column both
versions share. The music matcher held SQLite's write lock across an await,
so a writer on the same loop (the game watcher) could freeze the app for
the 60-second busy timeout; it now matches in memory and commits each chunk
in a short write session (BEGIN IMMEDIATE) on a dedicated writer thread.
Shutdown cancels background tasks with a time limit, drains the writer and
checkpoints the WAL before Syncthing gets `data/` back. Crash cleanup on
Linux and macOS wrote to the wrong database file (the path lost its leading
slash).
**Less waiting on the event loop** (`docs/POST_REFACTOR_INTEGRATION_AUDIT.md`).
The metadata cache keeps one connection per thread instead of opening a new
one, with its PRAGMAs and schema check, for every lookup; the deletion
scorer reads all candidates' ratings in one query, and a cache handle
dropped on an error no longer leaves its transaction open. Deletion
scoring, the taste rebuild and memory-conflict checks do their database
reads, ChromaDB reads and NumPy work on worker threads, so the UI and the
Activity stream stay live while they run. The game watcher's `nvidia-smi`,
process scan and Ollama check run off the loop, and it writes its state
only when a value changes (it wrote `game_active` every 30 seconds).
`set_state` is one upsert statement: two first writers of the same key no
longer fail on the primary key. A chat reply no longer holds a database
connection while it streams. Column migrations run in one transaction and
stop the start with an error if a step fails, instead of logging it at
DEBUG; `update_db.py` refuses to run while Curatarr is up and backs the
database up first. A "Your call" card that the household rule produced
says so in its tooltip.
**Failures that say so** (`docs/SILENT_FAILURE_SWEEP_2026_10.md`). A sweep of the
code's error handlers, after the taste-cluster read that failed silently for months, found
a second one like it. The memory extractor read its thread's subject after the database
session had closed, so no discussion thread got one, and a verdict from a "how was it?"
thread never reached the taste profile. Deletions now stop instead of judging without a
protection they could not read: a Keep cooldown, the curator's stances, recent plays,
Plex star ratings, listening depth or the learned keep-reasons. An artist whose listening
history could not be read is no longer "never played"; it is deferred. Game mode no longer
switches off when a status probe or the flag write fails, and an unreadable game flag
counts as a game. Each stale lock is cleared at start on its own. Service addresses
spelled as a decimal, hex or IPv6-mapped number no longer get past the link-local check.
A Discogs download cut short keeps the complete styles database, and a MusicBrainz outage
no longer parks artists as "no match". In the browser, badge, banner and hover colours
are back (four colour tokens referred to themselves); a bulk delete whose progress can't
be read says so; a failed start-up check no longer opens the Setup wizard.
**…and the follow-up.** Signing out on another device, or a deactivated account, now
ends the session in every open tab at once, with the reason on the sign-in screen. Before,
the views showed a Retry that could never work, and the bell and Activity kept failing in
the background. Activity says when its live stream is down and retries with growing gaps.
The deletion judge defers a title when the owner's feedback, keep-reasons, principles or
verified facts could not be read. A Last.fm, OpenSubtitles or Plex outage, a spent
subtitle quota or a rejected key is retried, not recorded as "no genres" or "no subtitles"
for good. A profile whose vector never reached the vector store is embedded again on the
next pass. A failed save after a delete no longer fails the rest of a bulk delete, and a
Plex artist delete that can't be confirmed is listed as unconfirmed rather than failed or
done. "Test connection" refuses a host name that resolves to a cloud-metadata address,
Spotify uploads are bounded, and sign-in errors no longer echo library or plex.tv text.
Re-attributing history to household accounts runs off the event loop in short writes;
a large Plex sync commits as it goes; the weekly VACUUM waits for long jobs and skips files
with little to reclaim; and a music enricher running outside the server keeps its claim
across a server restart. A new test keeps the number of silent error handlers from
growing.
**…and the rest of the list.** 80 endpoints that only read or write the database
now run off the event loop, so a slow query no longer freezes the UI and its live streams.
Failing titles go to the back of the enrichment walkers' queue. Setup refuses an address
that resolves to a cloud-metadata host. A failed read no longer turns into a confident
answer:
- the vector store reports dimension mismatches;
- an unreadable memory flush stops "+ New" from deleting the chat (you can still clear anyway);
- a rebake that failed is not shown as an installed model update;
- a GPU check that times out counts as busy;
- an empty download never replaces the anime mapping;
- a Plex outage keeps playlist and collection pushes due instead of deleting the shelves.
The views say when a request failed instead of showing stale or empty state, deleting
explains a lost reason, and "Why?" closes with Escape.
**Model updates are noticed, and tested before you switch.** A bake runs the weights it
was built from, so a newer model on the registry went unnoticed. A daily check compares
each bake's weights with the registry; a difference shows up in the bell (now also a
System section for notices like this) and as a bubble on Settings. The admin can then
test the update: the current weights are pinned, the new ones pulled and baked like
production, and both judge the same ten library titles and answer three chat questions.
The result shows changed verdicts, time per call, speed, VRAM and GPU share. The current
model then reviews both sides blind, in both orders, and recommends update, stay or
either; more failures, a spill to the CPU or a slowdown below 75 % overrule an "update".
"Take the update" rebuilds the bakes, "Stay" goes back to the pinned weights. A test
that fails or is cut off by a restart rolls itself back and deletes the test bake. The
first live tests shaped the measuring:
- the card is quiet during the test, with every other model unloaded;
- VRAM comes from `nvidia-smi`, since Ollama's `/api/ps` reported 2 GB for the new
gemma4:31b weights that took 22 GB (`nvidia-smi` also runs on Windows under
`--reload` now, where the setup wizard's GPU probe had failed with a blank error);
- speed is measured through `/api/chat` like every curator call (`/api/generate` showed
a third of the real speed), old and new interleaved, and a side whose readings spread
more than 35 % decides nothing on speed;
- "Review again" and "Run the whole test again" (on titles no earlier run used, spread
over the categories) replace a result made with the earlier measurements.
The summariser gets the notice but no test yet; the embedding model is never offered,
since new weights would mean embedding the library again.
**A hardware bench, and a setup wizard that knows real sizes.** `tests/benchmarks/hwbench`
measures which curator runs how well on which card size (4 to 24 GB, smaller cards
simulated on the 4090), speed and quality apart, with production prompts. The older
benches had drifted from production and now import its constitution and persona. The
wizard's model sizes match the installed files (gemma4:26b was listed at 15 GB for an
18.6 GB file, so 18 GB cards were told it fits), the GPU probe reports free VRAM and
what else holds the card, and fit is judged on the VRAM measured per context window.
**Taste per series, scoring that survives bad numbers, and memories that are replaced
instead of deleted.**
- A show's profile counted once per played episode, so a 300-episode sitcom outweighed
a 6-episode miniseries about 67 times. Shows and anime now count once per series,
weighted by finished episodes. An episode in progress is no longer a "drop"; a series
is one only when nothing was finished, at most two episodes were started, all under
40 %, and it has been idle for 30 days.
- Deletion scoring: a NaN in the taste data scored a title as maximal clutter, and a new
user's whole library was shortlisted on no evidence. No taste evidence now scores just
under the shortlist bar, and a category with neither taste nor genre stats is skipped
with a reason. Keep-considerations cover all 60 judged candidates (was 30).
- The embedding migration could delete the live vector store when its old source was
already gone. It now refuses, and a rollback restores the profile that was live.
- A contradicting memory no longer deletes the old one whatever its weight. The old one
is marked replaced (recoverable, no longer retrieved), and a much weaker
contradiction counts as nuance. Memory retrieval no longer favours memories just for
being retrieved often, and keywords match whole words ("whatever" is not "hate").
- Adopting a learned principle that contradicts an existing one retires the old rule,
so the judge is no longer told to apply two opposite rules.
**A pool of deletion proposals, ten at a time (2026-11-01).** After a restart the Anime tab
showed 9 proposals where a run had written 30: the list was read as the top 30 across all
categories and each tab sliced that in the browser. Each tab now reads its own proposals from
the server, ten at a time, with "Show more" for the next ten and the header counting the whole
tab. Protected titles are left out before a page is cut, so a page is never short.
- Each category keeps a pool of up to `DELETION_POOL_SIZE` open proposals (50 by default). A
run keeps every stored proposal that is still a candidate and judges only as many new titles
as the pool lacks: a full pool costs no model call, three resolved cards cost three. What is
no longer a candidate (watched, kept, protected, gone) is retired. A proposal older than 30
days is judged afresh. One run judges at most 180 titles, so an empty pool fills over a few
runs. This replaces the split install's reserve of 30, which every run re-judged in full.
- Tests: `tests/test_deletion_pool.py`, `tests/test_scoring_numerics.py`.
**A wish goes to SoulSync's wishlist (2026-11-01).** Without Lidarr, "Wish" on the Spotify
backlog only filed a wish the owner then fulfilled by hand in SoulSync. The owner's wish now
puts the tracks they streamed by that artist on SoulSync's wishlist, most played first, at
most 100 (`POST /api/v1/wishlist`, one entry per track). SoulSync's Auto-Process Wishlist then
fetches them. A banned artist is refused by SoulSync's own blocklist gate. Curatarr still never
calls the download trigger.
- A household member's wish still waits: the Wanted list gives the owner "Send to SoulSync" for
it and shows what SoulSync did (new, already there, refused).
- The SoulSync API key needs the admin scope; a read-only key is reported as such.
- Without Lidarr the backlog page no longer talks about Lidarr: "Not in the library yet" filters
on the music server's index, and the Lidarr-cache warning is gone.
- Tests: `tests/test_soulsync_wishlist.py`.
**Deletion runs no longer wait for the node forever (2026-11-01).** On a split install the
deletion verdicts came only from the node's large model; with the node away for weeks there
were no new proposals at all. Once the node has not been free for `JUDGE_FALLBACK_HOURS`
(72 by default; -1 always waits, 0 never does), the server judges with its own model.
- The server keeps the time the node last took work, so a restart does not reset the clock.
A queued Analyze starts by itself once the server takes over, and its note says when that
will be.
- Each proposal records which profile judged it (`judged_on`). When the node is back, its run
re-judges the proposals the server's smaller model made; the node's own verdicts and older
rows stand. The run banner says when the server judged without the node.
- On the NAS (16 GB, the large profile) the server's model found 7 of the node's 8 cuts
on the same evidence, but argued two titles out of their stature (four Emmys, a Grammy).
Replayed over 20 benchmarked judges, nearly every cut of a title with a major honour was
one the large model keeps. So no judge cuts such a title any more (Emmy, Grammy, Oscar,
BAFTA, Golden Globe, the National Film Registry and the like): it becomes "Your call",
and the card says why. An RT score or a count of minor wins does not count.
- Tests: `tests/test_judge_fallback.py`, `tests/test_deletion_pool.py`.
**The setup saves the curator profile (2026-11-01).** The wizard showed the bench's package
for the card (`LLM_PROFILE`, `CURATOR_NUM_CTX`, `CURATOR_NUM_GPU`) only as lines to copy into
`.env` by hand; an install that missed them ran with a 24 GB card's defaults. The wizard
now fills a "Curator profile" section from the package and saves it, and the first bake
uses that window. Settings > Integrations > Ollama & models shows and changes the same three
values (validated: large or small, 0 or 4096-131072 tokens, -1 to 999 layers). A change asks
for Rebuild models and a restart, and the rebuild bakes the new window, not the one the
running process started with. The Ollama server's own settings (KV cache, flash attention)
are still shown as lines to set on that server.
- The values follow the curator actually chosen. The bench measured several packages per card
size (on 16 GB the MoE runs large / 16384 / -1, gemma4:e4b small / 8192 / 99), and picking
another curator in the wizard takes that model's values. A model the bench has not measured
at that size keeps the fields and says so.
- Settings > Integrations > Ollama & models shows the bench's advice per machine (2026-11-02).
It reads the card itself: this server's through nvidia-smi, the node's from its agent, and
the node's last reported size while it is away. A size can still be picked by hand when
nothing reads it.
- The bench's package for that card stands next to what is set: curator, profile, window
and layers. A badge marks each value that differs, and "Use the bench's values" fills
them in, ready to save. The speed and quality the bench measured and the Ollama server's
own settings (KV cache) are named too.
- The model fields are lists of the catalog's choices for that card, marked installed,
too big or the bench's pick. The value set now always stays selected. Picking a curator
the bench measured there brings its own profile along, as in the wizard.
- A split install gets a second card for the node's own models and profile (`PC_BASE_*`,
`PC_LLM_PROFILE`, `PC_CURATOR_NUM_CTX`, `PC_CURATOR_NUM_GPU`), which were `.env` only
before. Rebuild models there bakes on the node while it is free.
- Tests: `tests/test_setup_coverage.py`, `tests/test_model_catalog.py`, `tests/test_host_advice.py`.
**AMD cards are read too (2026-11-02).** Every card reading asked nvidia-smi only, so on an
AMD card the setup's detection, Settings' bench advice, the Nodes view, the "another
program holds the card" gate and the PC agent read nothing. Where nvidia-smi is missing,
Curatarr now reads the amdgpu driver's own files on Linux, which a container sees too when
it has the host's /sys. Where `amd-smi` is installed it asks that. The bench measured NVIDIA
cards only, so for an AMD card the advice goes by memory size and says so. Which program
holds the card is still known for NVIDIA only. Not tried on AMD hardware yet; the readers are
checked against the drivers' and amd-smi's documented output.
- Tests: `tests/test_amd_gpu.py`.
**The lyrics run says why a text did not come (2026-11-02).** The nightly run logged a bare
failed count: 1265 failed beside 1912 fetched on 2026-11-01, 3390 beside 320 on 10-30. A stale
stream key, a busy server and a Plex restart all read the same. Each failure is now counted by
its cause (HTTP status or the error), and the run line carries them as `failed_by`. Failed
texts are tried again on the next run, as before.
- Tests: `tests/test_lyrics_fetch_causes.py`.
**MusicBrainz finds the artists it knows under another name (2026-11-02).** 1,676 Spotify
artists sat as "musicbrainz: no match", 1,108 of them without a genre on any play. The name
search asked for the exact name only, did not escape special characters and took the first
hit. Of the 40 most played misses it found 1. It now asks for each name and alias, escaped,
including the parts of "X fka Y", "X a.k.a. Y", "X | Y" and "X (Y)", and takes only a hit whose
name, sort name or alias matches. That found 12 of the 40: Turbonegro as Turboneger, Tim Berg
as Tim Bergling, "Axwell /\ Ingrosso" as Axwell Λ Ingrosso, Ferdinand fka Left Boy.
- The resolver asks the most played artists first. Artists played once wait until a second
play: 881 of the 1,676.
- The misses of the old search get one fresh look, once; their attempt count is kept.
- Plays Last.fm had nothing for (no track tags, no artist tags) take MusicBrainz's genres. That
happens when the MBID is found, and in a new step after Last.fm for the 7,555 such plays
whose artist already has one. An artist whose MusicBrainz profile has no genre either waits
like a miss.
- Tests: `tests/test_music_artist_match.py`.
**The agent's tray shows when Curatarr works on the PC's card (2026-11-02).** The web logo's
diamond flares ruby while a model is at work, but the PC agent's tray icon stayed amber however
hard the NAS used the card. Curatarr's poll now says whether it has a call on the PC's card right
now (`X-Curatarr-Working`). While it does, the tray turns the diamond of its mark ruby, for the
modern and the classic icon, and its tooltip says so. With no poll for 15 s the icon goes back by
itself. An older Curatarr sends nothing, and the icon simply stays as it is.
- Tests: `tests/test_tray_working.py`.
**Card tags that tell titles apart.** A deletion card showed the arr's first
four genres, so nearly every anime read "Action, Animation, Anime, Drama".
Cards now take the genres from the enriched profile (AniList's for anime:
Ecchi, Mecha, Slice of Life), drop what the tab already says, put the
genre that is rarest in your library first, and add up to two short
themes in a quieter chip ("Anthropomorphic warships", "Drug heist"). Music
cards, which showed none, get theirs from the artist profile.
**Chat streams that don't hang or land in the wrong thread.** A stalled reply could lock
the chat box for good. The server now sends a keepalive at least every 15 seconds while
it waits for the GPU, loads the model or thinks, and the browser gives up after 45
seconds of silence. "+ New" inside a discussion no longer deletes the free chat, and a
running reply is no longer saved into the next thread. Error text is shown but never
saved as a curator reply, your message and the reply are saved together once the reply
is complete, and a dropped reply keeps its text and says it was not saved. The chat
loads each thread's saved history, so it is no longer blank after a reload.
**Plex reads no longer delete watch history, and outages are not "not found".**
- The orphan clean-up compared episode and track plays with the top level of each
library (shows, artists), so every TV, anime and music play looked orphaned and one
click deleted all of them. It now checks the right item types, deletes nothing when a
library answers empty or with an error, and asks before removing more than a fifth.
- A single 404 from Plex deleted that library's settings. A library is now removed only
after Plex's own library list has left it out three syncs in a row.
- With MusicBrainz and Last.fm both down, an artist was cached as "no such artist" for a
week. An outage now caches nothing, and the music enricher moves past the row and
stops after ten outages in a row.
- The music pipeline's "running" flag is a lease with an owner: two enrichers can no
longer run at once, a restart no longer takes it from a running one, and a crashed
one is taken over without a manual reset.
- Lidarr's artist list (about two minutes on 5,000 artists) always timed out. Listings
get a longer budget (`ARR_LISTING_TIMEOUT_S`, 240 s), and chat and the judge no longer
download the whole list for every message and candidate.
- Windows: no console window flashes up during games, a logoff or shutdown stops
Curatarr cleanly, Stop under `--reload` frees the port, and `.stignore` is no longer
rewritten at every start (a power cut then could empty it).
**The deletion scan reads ratings safely.** One profile whose rating read
"N/A/10 (N/A votes)" stopped every scheduled scan: the summariser had echoed
its prompt line, and Jikan/MAL put the content rating ("PG-13 - Teens 13 or
older") in the same field. Ratings are now read as a number or not at all,
profiles store the source's score instead of the model's echo, and the
prompt says "unknown" when there is none. Profiles already holding text
are picked up by the audit's re-enrichment.
**The setup wizard recommends a measured curator.** The bench's results ship as
`src/services/model_matrix.json`: for every card from 4 to 24 GB, the curator package
(model, profile, context, KV setting, measured speed and quality score) and the VRAM it
takes per context window. gemma4:31b-it-qat on 24 GB; gemma4:26b-a4b-it-qat on 6 to
20 GB, where every 31b is offloaded and too slow; gemma4:e4b-it-qat as the alternative
everywhere. Each package states the system RAM it needs, and a host short of it gets
the alternative that fits (on a 16 GB host with a 4 or 6 GB card, e4b), with the reason
shown. After the bake, a one-minute mini-bench checks the curator on this machine with
production calls (two judge verdicts, one card note) on titles of a fictional owner,
never your data: ok, slow, spilled or unreliable, each with advice. `docs/BENCHMARKS.md`
has the October hardware matrix.
**Judge: findings with reasons, two fact guards.** A pillar finding that is only a
label ("NONE", "PASS") goes back for the repair turn and has to say something about the
title; one model did that on 11 of 40 bench titles. Two errors every model made on the
examples are corrected in code after validation, and listed in the verdict's `_guards`:
the downscale flag follows the file's measured size class, and a keep that rests only
on an owner signal goes to the owner's review instead, since a signal never forces a
keep.
**Dependencies.** aiohttp 3.14.5 for GHSA-2g87-pp6c-29x5, GHSA-5wr8-gcqf-gqf3
and GHSA-494j-54xm-rwp3 (fixed in 3.14.4). Curatarr uses aiohttp only as the
client for the arrs, never as a server, so the request-timeout and request
flooding findings did not reach it; the update keeps the dependency scan green.
Pillow 13.0.0 fixes 38 advisories in 12.3.0. Curatarr only draws the tray
icon with it, which renders unchanged. certifi moves to 2026.10.19, and the
OpenTelemetry family (via ChromaDB) to 1.45.1, all for their advisories.
**A household member's plays reach watch history.** The Plex sync listed
watched items with the admin's token, and Plex answers "watched" for the
token's account: a title only a member watched never showed up, so none of
their plays reached Curatarr and the household rule could not protect what
only they watch (a 14-episode show was proposed as a Cut). Members' plays
now come from their own Plex play history - the whole history on the first
sync, incrementally after that; accounts without a Curatarr login are never
filed under the owner.
**Deletion scoring counts the household's taste.** The taste part of the score (up to
80 points) measured distance from the owner's profile alone, so a show another member
would enjoy and has not watched yet read as clutter. Each member's fit now lowers the
score, by up to half the taste points for a full profile and barely for a thin one.
"Why?" shows it as "Close to <member>'s taste", and a very close fit reaches the judge as
a household line, weaker than an actual watch.
**Swipe.** A new view walks the library titles closest to your taste that
you have not played in Plex, one card at a time: "Not seen" moves on,
"Seen it" asks for 1-10 stars. Seen-and-rated titles join your taste
profile (10 stars pull it toward the title, 1 star pushes it away), so
what you watched on Netflix or in the cinema finally counts. Every ten
answers rebuild that category's profile in the background. Keyboard
(N, S, 1-9/0, U to undo) and drag gestures on phones.
**Swipe, the real thing.** The view now uses the whole screen: a large
card (poster at two thirds of its height, facts beside it) on a deck of the
next two, a blurred copy of the poster behind everything, your progress on
the left and what you just rated on the right. Five reactions replace the
row of ten numbers - Not seen, Disliked, Meh, Liked, Loved (stars 2 / 5 / 7
/ 10 in the same store, so the taste weights are unchanged); exact stars
are still one key (1-0) or the Exact button away. Drag in four directions
(← not seen, → liked, ↑ loved, ↓ disliked) or flick: the card tilts from
where you grabbed it, a stamp and a glow grow toward the decision, phones
buzz at the point of no return, and the card flies off the way you threw
it while the next one rises. Buttons and keys play the same motion.
- Each card says why it is there: the taste cluster it sits nearest ("Near
your taste for Heat, Ronin"), the genres and themes your profile favours
marked in amber, and the enriched profile's facts - runtime or seasons,
content rating, score, Rotten Tomatoes, director, cast, mood.
- Loving a title pulls its nearest unanswered neighbours to the front of the
stack ("Because you loved ...").
- Undo flies the card back in from where it left instead of reloading the
stack; a ring counts the answers to the next taste refresh and turns
green when it starts.
**Swipe: Want to watch.** Most cards are titles you haven't seen, and "Not
seen" threw away whether one interested you. "Want to watch" now sits next
to it and puts the title on your own Plex watchlist (the same write chat
makes for "I want to watch that"; the card says if plex.tv found no clear
match). It is a plan, not a verdict, so it doesn't touch your taste profile.
The two unseen answers get the two easy swipes (← not seen, → want to
watch; ↑ loved and ↓ disliked as before; Liked is a tap or L). Exact stars
moved off the button row to a long press on a seen reaction (or keys 1-0),
so the row stays at six. Only seen answers count toward the next taste
refresh, and a full stack loads its posters in one round.
**Vibration you can turn down.** Settings → Account → This device:
Off, Light (the new default: one pulse at half length) or Full. Saved in
this browser only; Android phones, iPhones don't let web apps vibrate.
The swipe card also drags from its text now, not only from the poster.
**The curator in the middle.** The phone tab bar is For you · Swipe ·
Chat · Deletions (members: History) · More, with Chat as the raised
button; Swipe is an ordinary tab now. Library and Report moved into the
drawer, and the drawer has one way in: More (or a swipe from the left
edge) - the second menu button at the top left is gone.
**A library you can browse.** TV Shows, Movies and Music are a poster
grid now, on the desktop as on the phone: title, year and size under each
poster, a small amber dot where the synopsis is missing or still raw.
Tapping a title opens its details - synopsis, size, added, status, and for
admins the root folder and Re-enrich (Metadata / Summary / Both), which
used to sit as a menu on every row. Search comes first in the toolbar.
**Fewer random memories on deletion cards.** "Matches something you said
you value" no longer shows a memory that only stood out of a tight cluster
of unrelated ones (a car-nostalgia note on a French comedy). An
embedding-only match now needs a cosine of at least 0.34. That is just
above the measured noise, and real matches sit at 0.35 and up. Matches
on a shared word work as before.
**Curator examples.** `docs/CURATOR_EXAMPLES.md` shows the three recommended curators
judging the same five titles of a fictional owner: the verdicts agree, the reasoning
rests on different pillars. The setup wizard shows each model's sample under "how this
curator judges".
**Smaller fixes.**
- Post-enrichment questions about a binge crashed whenever a series qualified: the
plays were grouped by year instead of by day.
- The game badge reads the game watcher's last answer instead of probing the card on
every poll (about 2.3 s each).
- The selection bar sits flush at the bottom of the view.
- "Install app" behind Cloudflare Access shows Curatarr's icon instead of a letter.
- The chat's stat boxes work from the keyboard; the unknown-process scan and several
count queries are cheaper.
**Cloudflare Access at the origin.** Publishing Curatarr under a public
name through Cloudflare and a reverse proxy no longer depends on nobody
finding the proxy directly. Requests under a `CF_ACCESS_HOSTS` name must
carry a valid Cloudflare Access token (RS256, the team's keys, the app's
audience), or they get a 403. Without team or audience set, those names
refuse everything. The LAN address is never checked, so local use never
depends on Cloudflare. A Plex login started on the public name returns
there.
**Sign-in on phones, and slow first tokens.** After a Plex sign-in, the
tab plex.tv sends back no longer shows the login again. It reloads
signed in as soon as the other tab finishes. A chat reply whose first
token takes longer than 45 s (model load, long prompt) is no longer
dropped as "the connection went quiet": the keepalive now also runs
while Ollama has not answered at all.
**Container image.** `ghcr.io/randomname653/curatarr` is built from the
hash-checked lock on a digest-pinned Python 3.12 base. It runs as a non-root
user and keeps everything, the `.env` included, in one `/app/data` volume.
CI builds and starts it on every PR, and every push to `main` publishes it.
`deploy/docker-compose.yml` is ready for a TrueNAS custom app. Ollama stays
outside (`OLLAMA_ENDPOINT`). `CURATARR_ENV_FILE` moves the `.env` on any
install.
**The gaming PC reports itself.** With Curatarr on a NAS, the PC's tray
runs a small agent (`PC_AGENT_TOKEN`) that reports its programs and GPU, so
game detection keeps protecting the PC. The NAS polls it every 5 s. A PC
that shuts down says so at once, and one that stops answering for two polls
counts as gone. No work is sent to it then, and no eviction is attempted on
it.
**Two machines.** `OLLAMA_PC_ENDPOINT` adds the PC's card as a second
host. While the PC is on and free, chat, background curator work and
enrichment run there with the PC's own profile. Otherwise they run on the
main host (the NAS, small profile). Deletion runs wait for the PC instead
of judging with the small model, and embeddings stay on the main host. A
game on the PC takes it out within seconds and evicts Curatarr's models
from its card. A call on the PC ends within a second of it leaving (a
shutdown, a power cut or a game) instead of hanging until a 5-10 minute
timeout:
- a verdict counts as an outage and decides nothing
- an enrichment item stays due
- a chat reply that has not started is answered on the NAS, one that has
started ends with a note and is not saved
While the PC is free, a deletion run goes three times deeper, so the
proposals last while it is off.
**Deletion verdicts are not paid for twice.** A verdict is stored under a
fingerprint of everything that decides it: the evidence, the principles,
the bake and its weights, the host's profile. The next run reuses it while
nothing changed, does not count it against its cap, and reaches further
down the list. Outages are never stored. Pitches are kept per language.
**Bakes per machine.** `build_models.py --host main|pc` builds the same
bake names on either machine, from a base that fits its card (`BASE_*` /
`PC_BASE_*`) and with that machine's window. On a split install the
pitcher goes to the PC only and the embedder to the main host only.
`--suggest <GB>` prints the bench's package for a card. `--check` covers
both machines over HTTP (it works in the container) and records which
embedding weights the stored vectors come from. A host where the same
model name carries other weights is reported before it can write vectors
that do not compare.
**The PC after the move.** `PC_AGENT_ONLY=true` makes the PC's tray run
only the agent: no server and no database. "Open Curatarr" opens the NAS,
and a logoff, shutdown or Quit tells the NAS first. A Curatarr started on
that PC anyway refuses to run, so the same Plex and arrs are never
synced twice. `docs/NAS_MOVE.md` walks the move step by step, rollback
included.
**The container builds its own models.** With `AUTO_BUILD_MODELS` (set in
the image), the main host's missing bakes are pulled and built in the
background after start, the step start.bat does on the desktop. The server
answers at once. The log names what it builds and whether the embedding
weights match the stored vectors. A gaming PC's missing bakes are only
named, never pulled onto it unasked. The Settings "build models" button now
follows the same per-host plan, so it builds no pitcher on a split
install's NAS.
**Version and "Update available" under the sidebar.** The footer shows the
version and the commit that runs (the image carries it, a checkout reads
HEAD). For admins, Curatarr asks GitHub every hour, anonymously,
whether a newer build is published: the last successful image on main for
a container, the newest main commit for a checkout. Only a build that is
actually ahead counts, so a dev branch is never told to update. The hint
says how to update. The compose file carries `pull_policy: always` as an
option (commented out): with it, TrueNAS's Edit → Save pulls the new image.
`UPDATE_CHECK=false` turns the check off.
**Both cards at once.** On a split install the NAS's card runs enrichment
while the PC's runs chat, background curator work and the deletion run. The
model queue is now one queue per host: a chat on the PC no longer pauses
enrichment on the NAS, and a deletion run on the PC no longer waits behind
a taste summary on the NAS. On one card everything waits as before. A caller
queued for the PC when a game starts moves to the NAS's queue; the deletion
run stays queued for the PC. `ENRICH_ON_PC=true` sends enrichment to the PC
while it is free.
**New games are caught by what they do to the card.** The PC agent reports
each program's share of the card's 3D engine (what Task Manager's GPU column
shows). An unclassified program that draws 30 % or more for about 10 seconds
takes the PC out at once. Before, a game nobody had classified waited out
the 45-second GPU-pressure hold. Ollama's own runners and programs answered
"not a game" never count. The "is this a game?" question is about the PC's
programs again: a split install had been asking about the NAS container's.
It now also waits in the bell until answered, card-drawing programs first.
The PC's checkout needs this version for the agent's new reading.
**The Windows install updates itself.** The tray has "Check for updates" and
"Update now": a fast-forward of the checkout to main, then the usual restart
(its preflight installs what the lock asks for). Nothing is merged or
overwritten. A branch, local changes or local commits stop it, and the tray
says why. The PC agent checks every hour and reports where it stands, so
the NAS's bell says when the gaming PC runs an older Curatarr. "Update the
PC" there has the agent update and restart. The NAS sends only the token,
and the agent fetches from its own origin.
**Nodes have names.** The second machine is a node, named like a Tdarr node:
it reports a name of its own (`PC_AGENT_NAME`, else its computer name), and
Settings → Maintenance → Compute nodes can rename it. That section also shows
its state (free, busy and why, away), when it last reported, and whether it
runs an older Curatarr, with an Update button. The UI, the bell and the log
use the name; nothing says "the gaming PC" any more.
**An analysis waits for its node.** "Run analysis" while the node is off,
busy or playing is queued, and starts by itself on the first poll that finds
the node free. The stored proposals stay on screen meanwhile, with a note.
A game started on the node during a run now stops it with the node's name
and the reason. Before, its calls kept failing as outages until the breaker
tripped. The Keep working line only promises "stays awake" where the server
can keep its machine awake.
**A run that did not judge everything no longer wipes proposals.** "Run
analysis" for all categories replaced every stored proposal, including those
of categories that waited for the node. One night a game started mid-run,
and three categories' proposals were replaced by one category's. Now a
category judged to the end replaces its own proposals. A category that
waited or stopped replaces only the titles it proposed again. The scheduled
scan follows the same rule.
**Music enrichment no longer spins on credit lines.** Plex's track artists
include credits ("A & B", "A feat. B", "A vs. B") and stray tags ("00",
".dev") that MusicBrainz cannot know. 46 of every 50 music items were such
misses. Each tick aborted music as "upstream degraded" and refunded the
attempts, so the same 50 came round again all night. The full name is still
looked up first, so "Simon & Garfunkel" is found as itself. A credit that
misses now keeps its attempt, waits on the normal backoff, and no longer
counts toward the outage breaker.
**Ollama without a second copy of the model in RAM.** On the NAS, Ollama
loaded the 8B summariser without memory-mapping and held 4.9 GB of anonymous
memory next to the 6.1 GB on the card. With `use_mmap` it held 0.45 GB; the
rest became file pages the kernel can drop. `OLLAMA_USE_MMAP` (this server's
Ollama) and `PC_OLLAMA_USE_MMAP` (the node's) set it, and every call to
that host carries the value. A differing runner option would reload the
model, so curator calls without an explicit host now take the options of
the host they actually go to. Unset, Ollama decides as before. Leave it
unset for Ollama on Windows with CUDA.
**Update checks hourly.** The server asks GitHub every hour instead of every
six (1-2 of the anonymous 60 requests an hour), and again 30 minutes after a
failed check. The node checks hourly too, with a plain `git fetch`. The
sidebar re-reads the answer every 15 minutes.
Compute nodes no longer says "Up to date" when the node's check failed or
has not run yet: it shows the reason, and when the node last checked.
**Needs attention and maintenance that could never finish.**
- A wrong-entity finding no longer fires when a shared TMDB, TVDB or IMDb id
says it is the same work. 39 of the owner's 65 were translated titles
("Mad Max 2" / "The Road Warrior") or regional years (The Evil Dead
1983 / 1981). Anime keep the title check, but against AniList's own names
and synonyms. The 26 that remain are real: an AniList entry for another
work (Golgo 13, the 2008 series, filed under the 1983 film).
- Anime that AniList's search misses under their English arr title resolve
through the offline snapshot's names. This needs exactly one match within
a year, and never goes through the drifting AniDB ids. 10 of 19 "not
found" titles resolve this way.
- Wikidata's top-up reached zero outstanding: an anime row was looked up
under the wrong category, and a title with one stamped row of two never
wrote the other.
- A lyrics profile that fails waits on a backoff of 1, 2, 4 days and so on,
instead of taking the same third of every tick's budget for good.
- "No id in the arr" no longer applies to Plex-index artists (Lidarr off).
Plex has no id to give, and music resolves by name. These were 336 of the
owner's 426 Needs-attention rows: collaboration credits ("Armin van Buuren
& Vini Vici") and stray tags ("00", ".dev") among them. A row without an
error is explained by its own state; "Never processed" no longer reads
"a full profile was written".
**A slow Lidarr no longer stalls the app, or is asked twice for nothing.** A full library
listing had 90 s for the whole body, and Lidarr's artist list takes about 124 s on 5,211
artists, so it failed every time, twice, for 182 s of Lidarr load and no library. Listings
now get a read budget (`ARR_LISTING_TIMEOUT_S`, default 240 s), 10 s to connect, and one
attempt. A `Retry-After` in HTTP-date form no longer raises. The discography line and the
album dossier stop downloading the full artist list (15-30 MB) for every chat turn and every
judged candidate. Tests: `tests/test_arr_resilience.py`.
**Docs.** README and USAGE walk through a reproducible first start (the
prerequisites, the Windows install in three steps and the line that proves
Curatarr is up), troubleshooting is named by symptom (LAN access, the Plex
redirect, Ollama down, port 8000), USAGE lists the small-GPU settings, and
CONTRIBUTING holds the lock workflow and a dev setup that starts.
**Docs, checked against the code.** The README said "there is no Docker image";
it now installs and updates the container next to Windows and Linux/macOS, lists
the Wrap-Up, the phone app and the NAS setup among the features, links an
interactive architecture diagram (GitDiagram), and names every outside service
in Privacy, including ListenBrainz and the update check. `.env.example` gains the
fourteen settings it was missing (`UPDATE_CHECK`, `GPU_PRESSURE_GATE`, the CPU
lane, `IMAGE_CACHE_MAX_MB`, `LISTENBRAINZ_TOKEN`, the mmap and node keys, and
others) and a list of process-environment variables (`CURATARR_ENV_FILE`, `TZ`,
`CURATARR_LOOP_GUARD`). It no longer calls the unused `BACKUP_*` models
fallbacks. `tests/test_env_example.py` now keeps it complete, keeps its values
at config.py's defaults, and keeps it loadable: a blank boolean there would
stop the server. `docs/README.md` sorts the docs into guides, references,
design records and dated audit reports, and each audit report says it is a
snapshot. `scripts/capture_screenshots.py` captures the README screenshots at
fixed desktop and phone sizes with the account name replaced; the shot list
and a privacy checklist are in `docs/screenshots/README.md`.
## 2026-10-10 — v1.1.0-beta: enrichment that heals itself, deletions that explain themselves
Five weeks after 1.0.1: the enrichment pipeline heals itself and says what
it is waiting for; deletions explain themselves (Cut or Your call with a
Why?, Keep and Delete dialogs that say what they do, deleted titles that
come back are shown); views can be linked, bookmarked and read at accessible
contrast; MyAnimeList's official API replaces Jikan; SoulSync's follows and
likes shape music taste and its blocklist keeps deleted artists gone; and
the custodian keeps the GPU busy while you are away. In detail:
The Knowledge Base said "93 % enriched" while the libraries reported 46 to
92; the "Not findable" column was always zero because it read a column
nothing wrote; a fresh miss counted as enriched for three days; a title no
source knew was re-fetched every three days for ever; an API outage looked
like hundreds of unfindable titles; and the weekly audit purged and
re-fetched the same wrong-entity profiles every week without anyone seeing
it. SoulSync's healing mechanics (MIT) were studied and the portable parts
ported.
**One honest state model.** One classifier for the KB tile, the breakdown
endpoint and the producer, with the definitions served to the UI. States:
enriched (incl. provisional), dead cache, rule-based, awaiting LLM, retry
due, not found (waiting), queued, processing error, ignored, never
processed. The Glance tile and the watch-history table no longer count
sentinels as enriched.
**Retries that back off instead of hammering.** Not-found rows carry an
attempt counter: two quick tries, then 3, 6, 12, 24 days, then monthly —
for ever (nothing is given up on). Existing rows are backfilled once on
upgrade, dated from their own last write. An upstream outage (429/5xx on
any source) is recorded as *unavailable*, keeps the item due and writes
nothing; a category that aborts on the outage guard gets its attempts back.
**Why not 100 %, per item.** Every count in the KB table opens the list
behind it, each item with its attempts, next retry, per-source outcomes
(TMDB ✗, OMDb not asked — no IMDb id, …) and a one-sentence reason. A
**Needs attention** panel (badge in the nav) lists what the pipeline
cannot settle alone: found under the wrong year, refused as too far off,
matched with middling confidence, not found after two or more tries,
audit findings (wrong entity, shared id, pin contradicted), or no external
id in the arr at all.
**Owner tools.** Search & pin from the arr's own lookup (tvdb / tmdb / imdb /
mbid), TMDB or AniList, or a typed id of any kind — from the KB page and
from deletion cards, category resolved server-side. "Not this one" excludes
a wrong candidate for good (all four title-search resolvers skip it). Retry
now, Ignore / Un-ignore (an accepted gap leaves the open count), Dismiss
finding.
**Match confidence.** Title-search resolutions store which authority
resolved the entity and a similarity score; middling matches are accepted
but flagged, far-off ones with a disagreeing year are refused instead of
stored as a wrong profile.
**Findings inbox.** The audit's verdicts persist as rows with a lifecycle
(pending refreshed in place, dismissed silent, resolved re-raised after a
week if still there). Each finding gets exactly one automatic re-resolution;
a second detection escalates it to a human. Pinned items are skipped by the
entity check unless the profile contradicts the pin.
Also: the LLM-failure path crashed the status writer, so "Processing
failed" was never recorded; fixed. Migration is automatic (new columns, a
new table, one backfill).
### Security, second angle: what a household member can make the server do
The first pass asked who can call what. This one asked what a logged-in
*member* (or a stolen member token) can make the server spend, what text
from the outside world reaches the models, and where an external body
touches memory or disk. Verified with a probe instance and a minted member
token before and after.
- **Per-user budgets on the endpoints that turn one request into GPU or
API work.** Chat: 30 messages per 5 minutes and one reply in flight per
user (a second concurrent request gets 409, the guard self-expires so a
dropped stream cannot lock anyone out). Semantic search 20/min, arr
lookups 30/min, recommendation generation 2 per 10 minutes and one at a
time — including the `GET /api/recommendations/?refresh=…` path that
bypassed the refresh endpoint's guard — music pipeline starts, sync
triggers, memory flushes. The global curator gate serialised the GPU; it
never stopped one caller from owning the queue.
- **One Plex sync at a time.** The hourly cooldown was read from a stamp
written only when a sync *finished*, so triggers inside one window ran
full syncs side by side. Same state lock as enrichment and the music run.
- **Bounded bodies.** OpenSubtitles downloads are capped while streaming
(4 MB, the provider leg already did this) and the CPU-bound subtitle
metrics moved off the event loop — an oversized file used to pin the
whole app for every user. The image proxy enforces its 5 MB cap while
reading instead of after buffering. Spotify zip members are read through
a bound (50 MB each, 400 MB per archive) and the import directory is
anchored to the project root instead of the process CWD.
- **Third-party text is data, not instructions.** Overviews, reviews,
Wikipedia extracts, bios and the shared verified-data block are fenced
with explicit markers and scrubbed of markup, chat special tokens and
role markers before they reach a model; every system prompt that receives
fenced text carries the rule. Studio and director notes cache for a year
instead of a decade. Model output is stripped of tag-like markup before
persistence; Plex shelf names and blurbs are plain printable text.
- Request shapes: chat messages max 8 000 characters, music pipeline
batch ≤ 2 000, list sizes capped on the recommendation and library
endpoints.
### UI grammar — one visual language down to the deepest view
The healing UI "worked but looked rudimentary; the deeper you dig, the more
ad hoc the buttons get". Three inventories agreed: five recipes for "a row
with actions", three tab systems, four ways to confirm or report (native
alert/confirm/prompt, hand-built countdown overlays, button-text mutation,
and a `showToast` that was called but never defined — the coverage banner's
"Enrich library now" silently failed for months), some 830 inline styles.
Patterns were taken from UIs that are genuinely usable — Sonarr's toolbar
and mass-editor footer, Plex's Fix Match dialog and three-actions-plus-
ellipsis, Jellyfin's progress-on-the-row, SoulSync's bulk bar that appears
only with a selection (MIT) — and applied as one grammar; Curatarr keeps its
own look.
**Foundation.** One toast, one modal, one confirm dialog (the 3-second
countdown and the reason field kept), one "More" menu, one pager, one empty
state, one inline status, one dirty-form guard; `.toolbar`, `.section`,
`.select-bar`, `.banner`, `.chip`, utilities. Dead CSS removed.
**Knowledge Base.** Five tabs (Overview · Needs attention · Maintenance ·
Music pipeline · Profile browser) instead of nine stacked panels; every
number is a link to its list, the list is a section with the state
explainer, a pager and Close; Needs attention filters by reason (server-side
`reason=`); rows carry Search & pin · Retry now · Ignore with Open-in-arr
behind More; the match picker is a dialog shared with the deletion cards.
Two faults the seeded probe exposed: the page returned 500 on the first
pending audit finding (a session closed too early), and the sidebar badge
counted by attempts alone while the page used the full reason set — both
share one rule now.
**Deletions, Curation, Recommendations, Report.** Toolbar + selection bar;
cards with Delete · Keep · Discuss visible and Reevaluate, Fix match, Open
in arr behind More; the note saves itself; Keep uses the note as its
reason; bulk results as toasts. Curation sections with real empty states and
confirmations; recommendation lanes as chips; the report in sections.
**Activity, Admin, Settings, Libraries, arr browser, Reclassify.** Task
rows with the status badge and Cancel at the row end; a table for the
history; Disable behind a confirmation; Settings as sections whose Save
enables once a field changed and whose status sits next to the buttons;
Maintenance with its own status lines; one Re-enrich menu per library row
instead of three stacked buttons; Reclassify with a selection bar.
Nothing in the backend changed except the `reason=` filter on
`/api/enrichment/unmatched` and the two fixes above; a browser reload is
enough.
**Code scanning.** CodeQL alert #54 (`py/bad-tag-filter`): the frontend
syntax test extracts the inline scripts with `html.parser` instead of a
tag regex, so upper-case tags and end tags with attributes are the
parser's business, not a pattern's. Alert #53 (clear-text storage, the
`.env` writer) is by design — `.env` is the secret store, see SECURITY.md —
and is now suppressed on the line itself instead of re-dismissed each time
the writer moves; reading it again did find a real gap: the temp file was
created with the directory's default rights and narrowed only after the
rename. It is now owner-only from the first byte.
Bandit #52 (B608, the recommendation pitch prompt): the `# nosec` marker sat
inside the f-string and reached the model as prompt text; it is gone, and
the mission line no longer reads like SQL to a pattern matcher.
Dependencies: pydantic 2.13.5; the workflows moved to github-script 9 and
upload-artifact 7 (Dependabot #71–#73, compatibility read before merging).
**The weekly LLM sweep gets a memory.** Its first Monday after the release
produced 733 medium+ findings and filed the ten alphabetically first as
issues (#74–#83) — every one a re-report of a finding closed as by-design
four days earlier, because the model rewords a finding each week and the
step only knew open issues by exact title. Now: the sweep covers the
server code only (owner-run CLI tools, `scripts/`, the test suite and
vendored code are out — their findings were by-design without exception);
findings are ordered worst-first, deduplicated against every security
issue ever filed, and filtered through `.github/llm-scan-accepted.json`,
where the owner's documented decisions live so the next run can read them;
issues are filed only from `ISSUE_MIN_SEVERITY` up (critical by default),
while the whole picture lands as a digest table on the run page plus the
report artifact. The ten re-filed issues are closed with this change.
**Dependencies keep up with the pins.** `start.bat` proved that packages
were present, not that they matched `requirements.txt`, so a weekly
Dependabot bump passed unnoticed; it also imported `Crypto`, which is
neither pinned nor used, so a fresh install ran pip on every start. Both
launchers now compare the pinned versions with their interpreter before
the first import (`src/deps_check.py`, stdlib only) and install what is
missing or outdated. The server never installs; it logs the comparison at
boot and shows it to admins in Settings → Maintenance, with the command.
**The frontend is three files.** `frontend/index.html` (7,376 lines) split
into markup, `css/app.css` and `js/app.js`, the latter loaded as one ES
module — no framework, no bundler, byte-identical content. The 130
functions the inline handlers name are exported to `window` explicitly and
a test pins that list; a strict-mode audit found no implicit globals; the
three files are served with `Cache-Control: no-cache` so an update never
mixes old code with new markup. Behaviour is unchanged; this is the first
of three steps (next: modules per view, then event delegation in place of
inline handlers). Extraction by Jules from a written brief, finished here.
**Modules per view.** The 6,014-line app module became an entry file plus 25
modules cut along the file's own section banners, with `state.js` holding
the 20 variables more than one module shares; no framework, no bundler,
every function moved once and unchanged. Review of the agent's PR found
and fixed three classes of runtime error the tests could not see: six
initial values defaulted to `null` where the code expects `[]` or `{}`, six
functions that inline handlers call in second position (Curation section
toggles, the deletion checkbox) missing from the window export block since
the first split, and handler strings naming a former global variable or
module state. The hygiene test now checks every identifier in handler code.
Also merged: `aria-hidden` on 22 decorative icons, the image proxy
validating the URL with the same parser that fetches it (no parser
differential), and the history sync status computed in one grouped query.
**The curator's duplicate claims are true again.** Technical profiles were
kept per Plex rating key for ever, so every re-add or library move left a
phantom copy behind: 1,373 titles looked duplicated, and the curator told
the owner he kept two copies of a series that exists once. The tech sync
now prunes profiles whose rating key Plex no longer has (only for sections
it fetched completely), the redundancy audit collapses identical files
under different keys, and the curator is told it executes nothing itself:
a declared intention to watch is acted on by the app after the reply, and
the real outcome (protected; watchlist added or refused; downscale flagged
or not) now arrives as a notification instead of a claim.
**Event delegation.** The 217 inline handlers — 109 in the markup, 108 in
the view templates — are data attributes dispatched by one listener set in
the entry module; a registry names what an attribute may call, arguments
travel as JSON, handlers that carried a condition or two statements became
one-line functions, and the helpers that used to build handler strings
(retry buttons, menus, pagers, empty-state actions) take attribute text
instead. Nothing runs in the global scope any more: the window export block
is gone and the Content-Security-Policy's `script-src` is `'self'`, so
injected markup cannot execute code. Both halves converted by Jules from
written briefs; the second arrived without the imports the templates need
and with variables passed as strings — finished here and pinned by tests.
**Three small ones from a screenshot.** The metadata audit failed its run
whenever it met the same finding twice in one pass (the app's sessions do
not autoflush, so the upsert could not see the row it had just added); it
sees it now. A failed custodian step put the whole database error, statement
and parameters included, into the status pill; one line now, the rest in
the log. The Activity view rebuilt every task row on every progress event
and replayed the card animation each time; rows are patched in place and
renders coalesce to one per frame.
**The curator reads lyrics.** SoulSync leaves .lrc/.txt sidecars next to
the tracks and Plex serves them as lyric streams; a collector keeps the
plain lines in its own cache (13,390 of 18,648 tracks on the owner's
server carry one) and re-checks every run so late sidecars land, and a
profiler condenses a sample per artist into a lyrics profile: subjects,
themes, languages, an explicit flag, motifs, tone, up to three verbatim
lines. The music summariser makes lyrical claims only from that profile
(before, it guessed "lyrical themes" from a biography), the verified block
shows it with its basis, the album dossier adds coverage and one line, the
constitution caps quotes at two short lines. Raw lyrics never reach a
prompt or the UI.
**Smaller things from the second September round.** The Knowledge Base
status counts its history in one grouped query instead of two per category.
Existence checks at boot and login select an id instead of counting rows or
building an object. The image proxy fetches the URL it validated rather than
the raw string it was handed. Dependencies: uvicorn 0.53, SQLAlchemy 2.0.54,
PyJWT 2.14, and the CodeQL and OSV actions on their latest pins. The push
showed what a partial Dependabot round does: osv-scanner 2.6.0 rejects the
`--skip-git` flag the workflow still passed, and `codeql-action/init` sat a
version behind `analyze`, which CodeQL refuses to run. Both fixed;
`test_workflow_pins` keeps every action family on one commit and the
Dependabot config groups a family into one PR. Two more Bolt merges: the Spotify statistics on the Libraries page come from one aggregate query instead of five, and create_user checks for an existing user by id. The first OSV run that
really scanned (the old reusable workflow had answered "No issues found"
from a missing results file) listed the four chromadb server advisories
SECURITY.md already explains, now excepted in `osv-scanner.toml` and
guarded by `test_osv_config`, plus floor versions of transitive packages
no install gets, so the scan now resolves the direct pins only.
**GPU work no longer holds up the rest.** The data custodian ran every
maintenance task in one line: a scheduled deletion scan held the GPU for
almost two hours, every tick in between was skipped, and the editions walk,
which needs no model at all, never ran. Tasks that drive a model now run in
one queue and everything else in a second, side by side. The few
dependencies across them are explicit: the deletion scan and the post-sync
taste run wait for the Plex sync so the recently-watched veto sees last
night's plays, and the playlist pushes defer until the recommendation
refresh is through instead of blocking. The Plex sync's model half (taste
vectors with the curator's summary, the recommendations cache) moved into
the model queue, the ARR prefetch, which runs the summariser, is filed as
model work, and the Knowledge Base names what each queue is on.
**One Python per start.** `start.bat` ran whichever `python` came first on
PATH, and PATH differs between consoles: one start ran on PlatformIO's
Python, the next on Python 3.12, each raised only when it happened to run,
and both environments are shared with other tools whose own pins the raises
broke. The launchers now pick a venv in the folder, else `py -3.12`, else
PATH, print the choice, and every start logs it; USAGE explains how to give
Curatarr its own venv. A fresh venv is filled with the tested lock, exact
and hash-checked, instead of whatever requirements.txt resolves to that
day, and an empty environment no longer reports that it matches the lock.
**Smaller.** The user pill in the top bar opens Settings → Account and is
reachable by keyboard (from Jules' #129), and the sidebar marks the open
view however it was opened. The classifier's action-line splitter is linear
again after CodeQL found the first version quadratic on long runs of spaces.
The test battery logs to `data/logs/tests.log`, so the app's own log shows
only the app.
**Movies are not anime because their title says "to".** A title guess
decided for every movie whether it was anime, and "to", "no", "de" and
"san" counted as Japanese particles: 195 movies, *Back to the Future*
among them, went to the anime catalogues instead of TMDB and 120 never got
a profile. With Jikan unreachable each of them failed as an outage, so
five in a row ended the movie lane of every run within a minute while
2,000 movies waited behind them. The library's own filing decides now
(the Anime section, Sonarr's anime type), and only an outage where no
source answered at all counts toward ending a lane.
**Keep working while you are away.** Knowledge Base → Maintenance has a
**Keep working** button with an hour picker. Until then the custodian looks
for due GPU work every two minutes instead of every thirty, with the large
budgets, and asks Windows not to idle-sleep, which it otherwise does after
15 minutes without input. Beside the other GPU work the enrichment
pipeline keeps going in large chunks, so a task that mostly waits on the
network no longer leaves the card idle. It ends at that time, on **Stop**,
or by itself once nothing is due. The enrichment cycle also stays due while
its budget leaves items behind, instead of resting a day after 400 of 2,300.
**The Knowledge Base no longer waits for Lidarr.** Its overview needs the
library lists of Sonarr, Radarr and Lidarr, and once the 15-minute cache
window had run out the next page load fetched them itself. Lidarr's artist
list takes a minute, or two 90-second attempts when it hangs: 185 to 198
seconds per load on 2026-10-06. An expired list is now served at once and
refreshed behind the request; only a forced refresh or a first start
waits. An arr that is not configured no longer serves its old list, so
switching Lidarr off moves music to the Plex index at once, and the
SoulSync catalogue sync skips its nine-minute walk without Lidarr.
**Proposals of a switched-off service are retired.** With Lidarr switched
off, its open deletion proposals could never be carried out: a delete
reported "Lidarr is currently unreachable" and parked the proposal in limbo
for good, and the daily scan only replaces pending ones. A delete now says
the service is switched off and retires the proposal (nothing is deleted),
and the scan retires the rest; music comes back from the Plex index.
**Deleted titles that come back are shown.** An arr list, a request or
SoulSync's playlist sync can put a deleted title back. A daily check looks
every deleted proposal up in its library, by TMDb, TVDb or MusicBrainz id,
or by a unique title added after the delete (same-named films are common),
and the Deletions view lists the returns under "Back after deletion" with
Delete again and Keep.
**A deleted artist is blocked in SoulSync.** SoulSync's playlist sync
compares against Plex, so a liked song of a deleted artist went back on its
wishlist at the next sync. Deleting music now puts the artist on SoulSync's
blocklist, reads the entry back and warns when it did not hold; a ban that
did not hold is retried once a day, and the delete dialog says the artist
will be blocked. The warning is a fixed sentence, an exception's text stays
in the log (CodeQL). This is Curatarr's only write to SoulSync; it blocks
downloads and never starts one.
**The image proxy keys its cache by the parsed URL**, the one it fetches,
so two spellings of one URL share one cache file. Four automated PRs had
called the raw-string key an SSRF parser differential; it was not one, the
host check and the fetch always used the same parsed URL.
**SoulSync follows and likes shape music taste.** An artist followed in
SoulSync counts like a 5-star rating in the music taste vector, even before
the first play, and is never proposed for deletion; a Spotify like still on
SoulSync's wishlist counts like 4 stars. SoulSync's retry batches and
discovery mixes stay out.
**A restart no longer hangs on the custodian.** The enrichment run swallows
a cancel and returns, so when a restart caught the custodian in an
enrichment step, its queue went on with the next tasks after "shutting
down" and the old process never ended; on Windows the reloader waits for it
without a limit. The queue now stops before its next task once it has been
cancelled.
**Fewer "is this a game?" questions, and Steam games recognised again.**
Every running program nobody had classified got the question, so the "not a
game" list had grown to 530 names, 95 of them versions of the same seven
installers and updaters (68 InstallShield extractors alone). One answer now
covers every version of a program, installers, updaters and crash handlers
are never asked about, and neither is anything holding less than 500 MB. Steam's
overlay, the best sign that a game runs, had moved to a 64-bit process the
detection did not know.
**Clean genres from SoulSync.** SoulSync hands out its artist genres as a
JSON list cut at the commas and mixed with clean values (`'["Comedy'`,
`'Comedy Rock"]'`), for 100 of 120 artists in a sample. Since SoulSync's
fields win the artist-profile merge, the pieces reached the profile, its
embedding text and the summariser's input. Curatarr now strips the brackets
and quotes from each piece; similar-artist names keep quotes of their own.
**MyAnimeList instead of Jikan.** Jikan, the unofficial MyAnimeList
scraper, has failed since 2026-08-28. Anime that AniList could not match
waited on it in every run, and anime reception, which wanted MAL's scores
and reviews from it, stood still for every title with a MAL id. With a free
MyAnimeList client ID (Settings → Integrations) Curatarr now asks the
official API for scores, genres, the content rating and the title search.
Without one it still asks Jikan, but no longer than three failures in a
row before an hour's pause; reception then goes on with the AniList reviews.
**Dependencies.** PyJWT 2.15.0 (GHSA-42vr-xj54-vc7v; Curatarr only verifies
signed tokens, so the pre-verification path was never used), oauthlib 4.0.0
for two provider-side advisories (it arrives only through kubernetes, which
the embedded ChromaDB never loads) and multidict 6.9.1.
**No inline styles.** The Content-Security-Policy's `style-src` is
`'self'`: the 278 inline style attributes in the markup and the module
templates became classes (a utilities block named by property, a handful
of named boxes), the eight computed values (progress widths, poster sizes,
bar colours) travel as `data-style` and are applied from script by
`ui.hydrateStyles`, and every element that started hidden uses the
`hidden` attribute. The frontend-hygiene ceilings for inline styles are
zero now; a `data-style` ceiling of eight takes their place.
**The audit's residuals, closed.** A household member's Activity view
now lists only the tasks that carry their user id, the task stream the
same, and the library status tells a member which arr services exist
without their LAN addresses, root folders or test results. OMDb calls are
counted per day and stop at the budget (`OMDB_DAILY_LIMIT`, the free
tier's 1,000) instead of running into refusals until midnight. The
protection classifier's action line splits only before a known field
label, so a title with a pipe in it is protected as one title. The image
proxy resolves a whitelisted name and refuses private, loopback and
link-local answers before every upstream connection.
**What the review left open, closed.** A Plex library answering an error
no longer moves the sync watermark past its plays (the next sync asks for
them again; the result names the library); the library router's
"re-fetch metadata" clears embedding caches under the key format they
actually use (the bare `emb:{id}` it deleted matched nothing since the
model tag joined the key); the Plex PIN nonce travels as a header instead
of in the URL; a parked proposal says "parked" and why, not "delete
failed — unreachable"; the proactive and source-upgrade jobs report a
failure as a failure. `python -m src.deps_lock --compile --upgrade` is the
deliberate lock refresh that moves every version, plain `--compile` keeps
what the lock holds. One model check for every launcher: start.bat asked
`ollama show nomic-embed-text` by name and printed "models missing" on an
install that runs v2-moe from the stored profile; start.bat, the tray and
`build_models.py --check` now share one list, and an Ollama that does not
answer is reported as that, not as every model missing.
**Seven hardening pull requests, reviewed and merged (#122 to #128).** A
review of the auth, deletion, sync, chat and enrichment paths: model
output can no longer fire app actions (DOMPurify kept `data-on-*` and the
dispatcher ran them without a click); the setup gate honours logout and
deactivation, `.env` values with line breaks are refused, wrong setup
codes and PIN polls are budgeted, a Plex PIN is bound to the browser that
asked for it; a deletion re-checks Keep and protection right before the
DELETE, is claimed atomically, verifies a timed-out delete by GET and
parks the unclear case in limbo, Plex-music proposals get a real probe,
and the protection classifier acts only on titles the user meant; the
Plex sync's watermark is the fetch time, failed or busy jobs are no longer
stamped done, the deletion-proposal indexes lost by the AUTOINCREMENT
rebuild are recreated, sqlite work leaves the event loop; the GPU slot
survives a cancelled eviction, a double-submit is refused before any
work, verification answers land, background polls are not impressions;
re-enrichment replaces vectors (Chroma's `add` kept the old one
silently), stored vectors are checked against the item, cache wipes are
scoped, one backfill worker per source. Python 3.12 is the stated floor.
Finished here: a job that merely waits (game, running enrichment) leaves
no Activity card, and the chat stream's error line no longer carries the
exception text.
**47 packages raised in the lock** by Dependabot's first weekly round on
`lock/`, filelock 4, websockets 17 and protobuf 7 among them. One of its
bumps was impossible: Dependabot edits a uv-compiled lock line by line, no
resolution behind it, and moved pydantic-core to 2.49.0 while pydantic
2.13.5 pins 2.46.5, so the first CI install of the merged lock failed
(ResolutionImpossible). The lock was recompiled with uv, which keeps every
other version and corrects that one; the launchers install the set on the
next start. Dependabot's version PRs for pip are off from here: the lock
is refreshed with `python -m src.deps_lock --compile`, security PRs still
arrive.
**Watchlist adds that land.** "Sounds promising, I'll put it on my
watchlist" has ended deletion debates since August, and every one of those
adds died with "plex.tv returned 400": Discover's search refuses a request
without `searchProviders`. The search now sends the parameters and device
headers plex.tv's own clients send, the error keeps plex.tv's message, and
the add was proved end to end against a real account (search hit, add,
listed, removed again). `tests/test_plex_watchlist_request.py`.
**Series editions looked at daily.** The custodian task ran weekly, so a
series added to Sonarr or a corrected classification could wait seven days
behind a finished walk; the task now looks every day while each row keeps
its weekly recheck.
**Hash-pinned, everywhere it installs.** The lock is now a universal,
hash-pinned requirements file (`uv pip compile --universal
--generate-hashes`, wrapped as `python -m src.deps_lock --compile`), and
so are the two scanning tools CI installs. Every pip install on a runner
carries `--require-hashes`, the launchers raise a package to the lock
through the same check, Dependabot targets the lock and rewrites the
hashes itself, and `requirements.txt` repeats the lock's versions for the
direct pins. The three "pip not pinned by hash" rows in the Security
tab go with it.
**The tested install is written down.** `lock/requirements.txt` lists
every package the 17 pins pull in, at the versions the test battery ran
with (92 today). The launchers keep it honest at every start: a package
that fell below its lock line is raised, a newer one raises the line,
nothing is ever lowered, and the file is rewritten only when something
changed. The security scanners read it, so an advisory in a transitive
package — anyio's two of 2026-09-18 were the trigger — is visible against
the version that actually runs. Settings → Maintenance shows the
comparison next to the pins. The Deletions entry in the sidebar now
pulses while a deletion analysis runs in any tab, and offers a reload
when one started elsewhere finishes.
**The bell stops repeating itself and stops calling a routine a binge.**
Proactive messages had no memory for their pattern subjects, so the same
genre, the same late-night title and the same three dropped shows came
back every morning, and the first detector in a fixed list always won.
Every subject is now remembered for weeks and the pick rotates towards
the types that have been quiet. Binges are measured from real sittings
(plays chained by their own timestamps and lengths) instead of a
six-hour window counted from whenever the scheduler ran, bulk
"mark as watched" rows no longer pass as viewing, and both the bell and
the chat starters repeat the measured rhythm — "6 episodes in one
sitting (2 h 13 min)" or "an episode or two an evening" — instead of
guessing.
**The curator has more than one voice, remembers what you answered, and
says good morning.** Every proactive message now draws a register —
provocative, curious, dry, warm or analytic — never the same twice in a
row, instead of the one teasing voice every message used to wear. When
you answered an earlier nudge about the same series, song or genre, the
next one quotes your answer and builds on it rather than asking again.
And a new "Morning line" (Settings → Notifications) sums up last
night's watching between five and eleven in the morning, once a day,
and is gone by noon. The chat's watch status for a music artist now says plays and distinct tracks; Plex and Spotify rows carry a track index in the episode column, and the curator had read that as "15 episodes".
**Music that arrives later finds the plays that were waiting.** Matching
an imported listening history against your own library used to be a
one-shot per play: whatever was not in Plex that night stayed unmatched
forever, so a collection that grows album by album could never improve its
own numbers. Every pass now also asks the other question — which of the
tracks that just arrived has history waiting for it — using the track index
that is refreshed daily anyway. A quiet day costs a single query.
**A music lookup that finds nothing is remembered.** Artists that
MusicBrainz has no entry for were asked again every night, for years if you
let them: seventeen minutes of rate-limited traffic a day on one library,
for names no database will ever carry. A miss is now written down and waits
two weeks before the next attempt, then a month, then a quarter, and a
later success clears it. The Music-pipeline tab says how many names are
parked, so a progress bar that stops short of full no longer looks broken.
The pipeline also stops calling itself finished after burning a whole batch
on names that answered nothing.
**The sidebar can be used from the keyboard.** Every navigation item is
a real button to assistive technology, reachable with Tab and activated
with Enter or Space, using the same handler the poster cards and chips
already use.
**Smaller things from the same round.** The endpoint-privacy warning now
parses a URL with the same library that performs the request, so the check
and the connection can no longer disagree. The Knowledge Base overview
gets its music-pipeline numbers in one query instead of five. The proactive
triggers read the columns they need instead of building five thousand
objects per run. And a cached endpoint has to say whether its answer is the
same for everybody or has to be kept per user — the safe default is no
default.
**The GPU is busy, so the work moves instead of stopping.** A graphics
card held by another program used to park every model call, and the chat
still tried anyway and died in a timeout. Both halves were wrong, and the
measurement says why: the big curator model cannot even start while the
card is occupied, while the small summariser model runs a real enrichment
on six processor threads in about two and a half minutes without touching
the card at all. So enrichment, significance, reception, memory extraction,
taste vectors and lyrics profiles now keep going on the processor and your
library stays current; six threads are as fast as twelve, so the rest of
the machine stays with whatever is holding the card. A conversation needs
the card back, and Curatarr says so in its own words instead of leaving you
with a spinner. That work needs memory as well as cores — one run measured
9.7 GB — so it also checks that enough is free before it starts, and waits
instead of pushing the machine into swap; when it waits, Curatarr says
that too rather than promising progress that is not happening. A detected
game still parks everything. The badge in the top bar says which of the
three it is instead of calling everything a game, the 30 s watcher records the state and logs every change, and the
setup wizard asks for the behaviour while Settings → Integrations →
"Sharing the graphics card" changes it later without a restart.
**A busy GPU counts as a game.** An image-generation job held the whole graphics card while the game detector, which knows game process names only, reported nothing: the enrichment ran into summariser timeouts for an hour and the custodian kept starting model work. A GPU saturated by something other than Ollama now sets the same flag every LLM-heavy path already yields to, after 45 seconds of sustained pressure; a host without nvidia-smi is left alone, and `GPU_PRESSURE_GATE=0` switches it off.
**Lidarr is optional.** Three set-ups now work: Lidarr alone, Lidarr with
SoulSync, and no Lidarr at all. Without it the daily Plex walk is the music
index — artists with their MusicBrainz ids, albums, tracks and footprint —
and everything that used to ask Lidarr reads it: deletion candidates (and
the deletion itself, through Plex's own API with a re-read that never
mistakes a kept file for a deletion), the Music page with a Wanted tab,
the curator's discography and album evidence, the Knowledge Base counts.
Wanted artists become wishes you fulfil in SoulSync; the list shows when
Plex has them.
**Which cut do you own, and is there an uncensored one?** A weekly walk
reads every Sonarr series' episode files (quality source, release names,
custom formats) and the AniDB tags of the offline snapshot, by AniDB's own
definitions: "tv censoring" means the Blu-ray/DVD release is the uncensored
cut, "censored uncensored version" means even the disc keeps some
censoring. The curator's verified block says "Edition: 24 episode files,
all from broadcast or web, none named uncensored; AniDB: the TV airing was
censored, the Blu-ray/DVD release is the uncensored cut", the Curation
upgrade list shows those titles ("TV cut — uncensored disc release
exists") with a Search-releases button that asks your indexers live for
releases named uncensored or from Blu-ray. The prose the enrichment
collects was measured first and dropped as a detector: it mentions
censorship for half a percent of the anime, half of those as plot.
**A UI audit, end to end.** Every hint, table header and empty state used
a grey of 2.1–2.6:1 contrast; it now clears 4.5:1 on every surface, and so
does the danger red. Reduced motion is honoured (near-zero durations, so
nothing that fades in stays invisible), touch screens get 44 px targets.
Copy says one thing one way: no env-var names or column names in empty
states, Arr apps instead of ARR/\*arr, the encryption "PIN" is a passphrase
and no longer implies encryption that is not active yet, a setup hint no
longer points at a view that does not exist.
**Keep and Delete tell the truth.** Keep pauses a title for 90 days, and
its dialog can protect it for good. Delete dialogs no longer say "cannot be
undone" regardless: they read each arr's Recycle Bin setting and say where
the files go, name the import-list exclusion, and bulk delete lists every
title. Card actions run Keep, Discuss, then Delete set apart.
**A failed analysis is no longer a clean library.** A judge call that timed
out or answered nonsense used to vanish like a title the model could not
decide. Failures are counted per run and the Deletions view says so in a
banner that stays; error toasts stay until dismissed.
**Views have addresses.** The sidebar is links grouped Discover · Library ·
Curate · System; `#recs` or `#settings/users` can be bookmarked, Back and
Forward work, focus lands on the new view's heading, and the active entry
is announced as current. Libraries and Users were second copies of
Settings panes and are now only Settings → Plex libraries and Settings →
Users (with the Spotify import); Stop server moved to Settings →
Maintenance.
**Cut, Your call, and Why.** The "87 %" on a deletion card was the
shortlist score divided by 100, not a confidence. The card now says Cut or
Your call, and **Why?** lists the strongest signals in that score ("+66 Far
from your watch history", "−12 Community rating 8.0/10"). Stored per
proposal; `update_db.py` now runs the column migrations too.
## 2026-09-05 — v1.0.1-beta: the security pass the release deserved
A patch release one day after the first tag, and it exists because the
first tag was followed by the first real *boundary* audit — who can call
what, with whose identity, before and after setup — rather than another
review of code. Two parallel audits plus a live probe (a fresh clone booted
with no `.env`, poked unauthenticated from localhost and from a LAN
address) found things no scanner had, and every fix here was re-verified
against that live instance. No deployment other than the reference
household is known, and that one was checked and unaffected; nobody is
logged out by the upgrade.
**Security fixes**
- **Forgeable sessions after an ordinary admin action.** Reconfiguring an
arr service through the Library panel handed `write_env` the `SecretStr`
*objects* for `JWT_SECRET`/`PLEX_TOKEN`; f-strings render those as
`**********`, which would have been persisted as the secret and reloaded
live — every token signed with a ten-character constant. Fixed at the call
site and in `write_env`, which now unwraps secrets and refuses to write a
mask at all.
- **Any plex.tv account could log in, and the first one became admin.** New
accounts must now be known to the configured Plex server (its own
`/accounts` list — the same one watch history is attributed with), and the
first account must be the server owner. Fails closed for new accounts
only; `PLEX_LOGIN_REQUIRE_MEMBERSHIP=false` is the escape hatch.
- **Deactivating a user and signing out were cosmetic.** `is_active` was
never checked per request and the rolling refresh renewed tokens from
their own stale claims. Every request now consults the live account row
plus a per-user token version (JWT `ver` claim; new column, automatic
migration): Sign out revokes on every device, deactivation is immediate.
- **Household members could read each other's current chat topic.** The
anchor cache was keyed by thread id alone while everyone's free chat is
thread `general`; `/correct-anchor` also let anyone clear it. Keys are now
user-scoped.
- **First-run setup was open to the whole LAN** until the first admin
existed (by necessity — nobody can authenticate yet). A one-time setup
code, printed to the console and log at startup, is now required from any
non-localhost device; the wizard prompts for it.
- **The OpenAPI spec was public** at `/openapi.json` despite `ENABLE_DOCS`
being off — the gate only hid the Swagger UI. Gated together now.
- A Content-Security-Policy and Permissions-Policy on every response; the
`server` banner is gone; the unauthenticated poster cache has a size
budget; force-resync is admin-only; the shared music pipeline can be
stopped only by its starter or an admin; PIN comparison is constant-time;
`/plex/pin` is rate-limited; a wildcard CORS origin disables credentials;
`python-jose` replaced by PyJWT (drops the python-ecdsa advisory).
**Setup and robustness**
- `write_env` no longer regenerates `JWT_SECRET` on every wizard re-run
(which logged the whole household out), keeps tuned sync/binge values,
writes atomically, narrows the Windows ACL of `.env`, and rejects blank
Plex fields instead of flipping `FIRST_RUN`. Blank URL values in an
existing `.env` no longer brick startup. The wizard's Build-models step
no longer crashes on a cp1252 Windows console; `start.bat`'s dependency
preflight matches the shipped dependencies again.
- Scanner fleet completed: Bandit, OSV-Scanner and OSSF Scorecard join
CodeQL; all findings triaged to zero (real ones fixed, the rest justified
in code); the Best Practices badge entry exists. Full commit history
swept for leaked secrets: clean.
**Upgrade:** pull, restart. The `users.token_version` column is added
automatically. Existing sessions stay valid.
---
## 2026-09-04 — v1.0.0-beta: the first tagged release
The repo went public on 2026-08-31; this is the first cut considered
stable enough to hand a version number. The days in between were spent
making the public-facing posture real:
- **The scanner fleet is armed.** CodeQL, an LLM security scan
(GPT-5.6, structured outputs — the template version burned 63 minutes
of GPT-4o and reported nothing), Bandit (plain pip install, no
third-party wrapper action), OSV-Scanner (SHA-pinned; its PR job
hard-fails a change that introduces a known-vulnerable dependency),
and OSSF Scorecard with the badge in the README. All findings triaged
to zero: two real CodeQL hits fixed (unescaped regex interpolation),
the marked.js XSS class closed by vendoring DOMPurify around the one
`marked.parse → innerHTML` sink, the rest documented as
false-positive/by-design.
- **The full 896-commit history was swept for leaks** — private keys,
tokens, JWTs, API-key shapes, real IPs, committed .env files: clean.
Every IP ever committed is the documentation placeholder.
- **Seven bot PRs reviewed and merged** (one stripped of a misleading
benchmark), including SQL-side aggregation for the knowledge-base
overview and batch-scoped Spotify dedup that no longer loads the
whole listening history into a set.
- **Music evidence grew reception mass.** ListenBrainz global
popularity (free account token) gives the judge catalogue listens and
ranks a discussed album inside the artist's own catalogue; Lidarr's
mirrored secondaryTypes surface the Live/Compilation/Remix share of
an artist's disk footprint. Found en route and fixed: Lidarr deletion
candidates carry the artist MBID under a key the judge never read —
every automated music scan had been matching by name only.
- **The "fully harvest every API we already call" sweep.** TMDB/OMDb
vote counts, MAL scored_by and AniList popularity now ride the Rating
line (7.4/10 from 51 votes and from 500k are different facts), movies
finally print their runtime, MusicBrainz disambiguation and Last.fm
artist playcount reach the block, OMDb's MPAA rating and TMDB's
production countries are extracted at all.
- **Day-named conversation starters die at local midnight** instead of
surfacing "It is Wednesday evening" on a Thursday.
---
## 2026-09-02 — The UI grows up, and the curator learns to converse
- **A frontend worth looking at.** A full prettification pass on the
single-file UI: emoji sweep, unified big poster cards across
Recommendations / Deletions / Add-New, amber hover language, sidebar
collapse, global search, an at-a-glance panel, a last-played strip, a
curator avatar — and posters finally at w500 instead of a 92-pixel
thumbnail stretched to card size (the 2,526 already-stored URLs were
migrated in place). Poster bytes are content-addressed and now cached
as the immutables they are: view switches stop re-downloading every
image.
- **Clicking a recently-played title starts a conversation, not a
tribunal.** The strip's posters open a chat anchored on the user's own
watch-history row — the episode stated as a FACT from the log ("S2E7,
last night, finished"), knowledge honestly held at series level, watch
depth, viewing pattern, verified data and the measured dialogue line
riding along, and none of the deletion machinery. One thread per work:
tonight's episode and tomorrow's continue the same conversation.
- **Conversation starters with enforced variety, and bubbles that can
die.** The three hardcoded landing-page prompts become a background-
generated pool anchored on concrete facts (a stalled series at 22
plays, an artist at 73 plays in a fortnight), with the anti-sameness
rules in code rather than in the prompt: one form per starter per
batch, no repeated openings, generic candidates dropped, daypart
tagging at generation. The pool decays — TTL, click retirement, an
impression cap for the ignored — and proactive messages inherit the
same mortality: an unclicked bubble now expires instead of squatting
at the head of the queue for days.
- **A used session never expires mid-click.** The JWT carried a hard 24h
expiry and nothing renewed it, so the owner was logged out exactly one
day after each login — observed as a 200 and a 401 six seconds apart
on the same connection. Tokens live 7 days now, silently reissued in a
response header once they are a day old: a session lives as long as it
is used and dies after a week idle.
- **Slow endpoints name themselves, and status stops burning the DB.**
A pure-ASGI middleware logs any request over 300ms to first byte —
and its very first sessions paid for it twice: it caught the session
cliff above, and it showed the Maintenance view's four status
endpoints recomputing full-table aggregates on every 10-second poll,
6-8 seconds of database work per cycle competing with real jobs.
Status payloads are now memoized for 10-30 seconds with single-flight;
numbers that change at walker pace stop being computed at poll pace.
- **The curator stops inventing motives and arguing from the wrong
profile.** Three honesty fixes from live discussions: two umlauts from
a keyboard typo no longer flip the conversation to German (and the
language directive now tells the model what its default is based on,
so it never again explains a switch with the user's taste in German
rap); a movie discussion argues from the movie taste profile instead
of the whole psyche (the keyword fallback handed it ALL categories —
including the music taste it later used as an alibi); and the measured
dialogue numbers now reach every discussion turn — Given (2021) sat in
the DB at 42.7 words/min while the curator told the owner it had no
subtitle data and deduced the pacing from mood tags, wrongly.
## 2026-08-31 — The record and the work
- **A documentary is not condemned for wearing another film's plot.**
Sonarr carried `tmdbId 4054` for *Museum of Life*, a BBC nature
documentary; the correct entry is `40545` — one digit longer. Every
lookup therefore returned a 1999 Japanese melodrama, its plot was
cached under the documentary's own keys, and the deletion judge —
which correctly noticed the plot contradicted the "Documentary"
genre — proposed deleting the documentary *because* its metadata was
wrong. Four guards had to fail together, and all four are fixed:
nothing outranked the arr's mistyped id (TMDB's own tvdb→tmdb index
is now the authority for series — measured across 2,960 series it
disagreed 11 times and was right all 11, five of them literal digit
truncations; an owner's pinned match still outranks everything, and a
TMDB outage never re-points an entry); the monthly refresher
re-fetched from the very blob it was refreshing, so a wrong match
renewed its own TTL forever (it anchors on the live arr record now);
the judge's path called the enricher without a year, starving the
wrong-entity delta-check that had existed all along; and no law said
a misfiled record cannot be a deletion argument — now one does, and a
gate skips such candidates before the judge ever sees them (title
*and* year must diverge: on this library that flagged exactly the 8
genuinely wrong profiles and none of the ~50 harmless
romanisations). The owner's "Fix match" pin also now holds on the
live-enrichment path, where it used to silently revert. The 8
poisoned profiles were purged and re-enrich under the new authority.
- **The last two bot deliveries, sorted.** The dead
`_running_process_names` helper is gone and the GPU/VRAM picker got
its label association; the proposed switch away from psutil's
batched attrs form was declined — on Windows a per-process accessor
opens a handle per process, which is the opposite of the optimization
it claimed to be.
- **The security scanner no longer congratulates itself on scanning
nothing.** An uploaded LLM-security-scan workflow template ran its
first full sweep — 63 minutes of GPT-4o over 182 files — and filed
zero issues while reporting "Congratulations! No security
vulnerabilities were detected." Every single response had been
discarded: the model wraps its JSON in a markdown fence despite the
prompt, the parser choked on the backtick, and the error handler
dumped each file's findings into the CI log and called the file
clean. The template also executed the scanner *from the PR
checkout* (any pull request editing that one file ran arbitrary
code on the runner), interpolated attacker-nameable filenames
straight into a shell, and pinned tj-actions/changed-files@v40 by
mutable tag — the action whose tags were repointed to a
secret-dumping commit in March 2025. Rebuilt: PR runs scan only the
changed files with the scanner taken from the trusted base commit,
filenames travel NUL-delimited so no shell ever parses them, the
git two-liner replaces the compromised action (closing the
Dependabot bump as moot), fenced JSON parses, a failed analysis is
an ERROR in the report instead of a clean bill, and issues are
filed from the JSON report — deduplicated, capped, and only from
the trusted weekly sweep. Full-repo scans left the push trigger
entirely: CodeQL covers pushes for free.
- **The lost hour was salvaged, and the verdict is good news.** The
discarded findings were reconstructed from the CI log's own error
dumps: 317 findings across 158 files, 82 of them Critical or High.
Six triage agents read every one of the 82 against the actual
source: **zero were real**, four were hardening notes (three of
them documented design decisions), and 78 were noise — settings
lookups read as hardcoded credentials, ORM filters read as SQL
injection, a pydantic default read as a secret, a docstring read as
a cryptographic flaw. The one change worth making: the Wikidata
SPARQL query now accepts only canonical `tt`-shaped IMDb ids, since
ids arrive from the arrs and a quote in a corrupt one would rewrite
the query and confidently fetch a stranger's facts — the exact
failure class the entity-authority work exists to prevent.
## 2026-08-28 — No verdict on data we don't have
- **The curator can finally hear the film.** Until now it judged only
metadata *about* a work and had never seen a line of the work itself —
while the constitution demanded it separate premise from execution and
the no-invention rule forbade any execution verdict without evidence. It
was asked to judge what it was blind to. Deletion candidates now carry a
DIALOGUE line derived from their subtitle track: words per minute, how
much of the runtime passes without dialogue, and lexical diversity. The
measured spread on this library is a factor of three — a contemplative
drama at 42 words/min against a dialogue piece at 125 — which is exactly
the distinction the Resonance pillar's litmus had to guess at, having no
rhythm evidence at all.
What it deliberately is *not*: raw type-token ratio is mathematically
length-biased and would rank a long film as less varied by construction,
so the code uses a moving-average measure and a test proves the
difference by doubling a text. Sentiment "narrative arcs" are absent
because the six-shapes result that popularised them was shown to be an
artifact of the filtering, conceded by the tool's own author. And the
numbers describe a *subtitle track* — condensed by professional style
guides, often a translation, timed imprecisely — so the caveat travels
with the figure every single time, and the law states plainly that
sparse dialogue is not thin writing: visually-driven cinema is what the
Resonance pillar exists to protect, not something it may punish.
Forced tracks (signage only) are detected and refused rather than
measured, since one would report a talkative film as nearly silent; SDH
sound cues, lyrics and speaker labels are stripped before counting; and
CJK scripts are refused outright because a words-per-minute figure
without word boundaries is an artifact of the writing system. Language
is read from the text, never the track's tag — this library carries a
track tagged Hindi containing no Devanagari at all. Where no reachable
track exists the line is simply absent: silence beats a guess.
- **ASS subtitles, the format anime actually ships in.** The parser read
SRT and WebVTT, which covers films and western series and almost no
anime: fansubs are ASS, and ASS is not SRT with different punctuation.
Its events are comma-separated records whose field ORDER each file
declares for itself, its timestamps count centiseconds, and a single
file routinely carries parallel tracks — the dialogue, the translated
signs, the opening karaoke. Reading the signs track would report a
talkative episode as nearly silent, exactly the damage a forced track
does, so styles named for signs, songs, karaoke, credits or typesetting
are skipped; `Comment:` events are skipped as they are never rendered;
the ASS line-break escape is treated as the break it is rather than a
word; and an event that was only a vector drawing no longer counts as
speech. The field order is read from each file's own `Format:` line,
because groups do reorder it and a hardcoded index would quietly parse
a style name as a timestamp.
- **A misconfigured URL must not be mistaken for a verdict.** First
connection to a real service caught the failure mode this whole
tri-state exists for: the base URL was missing its path prefix, every
request came back 404 — and a 404 was read as "this title genuinely
has no subtitles" and would have been stamped as checked. One typo
would have marked an entire library subtitle-less, permanently and
silently. Now a response that is an HTML page rather than subtitle
data is transient by definition and logs which URL it came from, and a
bare host gets the default path prefix appended instead of failing.
- **Thai is not a quiet film.** Asking the provider for German returned a
Thai track — 480 cues carrying 74 whitespace-separated "words", which
the metric read as an almost silent episode. Thai, like Chinese and
Japanese, writes without spaces between words, so counting them
measures the writing system rather than the dialogue. CJK was refused
from the start; Thai, Lao, Khmer, Burmese and Tibetan were not, and now
are. A second guard catches whatever the script detector does not know:
a real dialogue track averages four to eight words per cue, and a
fraction of a word per cue means the text is not being read as language
at all.
- **A subtitle longer than its own episode is somebody else's file.** The
coverage check was one-sided: too FEW cues over the runtime meant a
signage-only track and was refused, but too MANY was accepted without
question. A 22-minute episode came back with a subtitle spanning 52
minutes, every word of it divided by the episode's runtime — 243
words per minute, nearly double anything else in the library, from a
file that simply belonged to something else. The stored coverage was
additionally clamped to 1.0, so the mismatch was invisible even in the
data. Both fixed: coverage above 1.3 is refused, and the figure is
recorded as measured.
- **An optional subtitle service of your own — and a bug worth the whole
integration.** Anime is where dialogue evidence is thinnest: only about
a quarter of it carries a subtitle file Plex will hand out, and public
sources have little for the catalogue titles that dominate deletion
candidates. Operators who run their own subtitle service can now point
Curatarr at it. Nothing is bundled, no service is named, and none is
assumed: the module documents an HTTP contract and stays completely
inert when unset — no connection is opened, and an absent service is
reported as transient rather than stamped, so a stranger's install can
never mark its whole library as subtitle-less on the strength of a
service it was never going to have. Order of preference: the local file
first (free, instant, matches the cut on disk), then the operator's own
service, then the public fallback that costs quota.
The integration paid for itself before it was even connected. The
service author found that their track-name filter matched `title`
without a word boundary — which matches *Subtitles*, the commonest name
a dialogue track has. The same pattern was in our ASS style filter: a
fansub whose dialogue style is named `Subtitles` would have had every
line discarded, fallen under the forced-track threshold, and been
recorded as having no usable subtitles — silently, with no error
anywhere. Every alternative is word-bounded now, on both sides.
- **And in a discussion, it can read the dialogue itself.** The batch judge
only ever sees the derived numbers — one feature's dialogue is around
13,000 tokens against a 16k context — but pressing "look deeper" on a
single title now also pulls the cleaned dialogue text into the
conversation, the same trade the Wikipedia deep read already makes. It is
sampled beginning, middle and end rather than truncated, because a blind
head-cut hides exactly the late shift in register a discussion tends to
turn on, and it is labelled for what it is: evidence of register and
texture, not a transcript of record.
Where Plex has no downloadable track — which is most series and a quarter
of the films — OpenSubtitles now fills the gap, matched by the IMDb id
Plex hands over directly, so there is no title guessing. Quota is the
entire design constraint: the anonymous tier allows five downloads a day
and a free login ten to twenty, against up to sixty candidates in one
scan. A daily budget is therefore checked *before* any connection is
opened, and exhausting it reports as transient rather than being stamped —
otherwise a busy day would mark a title as having no subtitles forever.
The local file always wins when it exists: free, instant, and guaranteed
to match the cut on disk.
- **A batch of agent-written tests, kept only where they bite.** Thirteen
bot PRs landed at once; each was reviewed against its brief, run, and
then mutation-tested — the production function it covers was broken on
purpose to see whether the test noticed. Seven new suites survived
(anime mapping, SoulSync album payloads, tasks router, studio and
director note caches, Spotify backoff, watch status, history router),
taking the battery from 57 to 64 suites. Two findings were worth more
than the tests themselves: the watch-status early-exit test passed even
with its guard removed, because the call then fell through to a real
database session whose failure the function's own except turned back
into `None` — a green test quietly hitting the live DB. Its fix had to
raise a `BaseException`, since the obvious `AssertionError` would have
been swallowed by that same except. And the playlist test asserted
nothing about the change it shipped with: removing the threaded
argument left it green at 14/14. Both now fail when they should.
- **Playlist pushes resolve their Plex sections once, not per item.** The
artist and video key resolvers each opened a database session and
re-read `LibraryConfig` on every call. The push now resolves it once
and hands it down; passing nothing keeps the old behaviour, so no other
call site changes. Deliberately not a TTL cache — that config is edited
during onboarding and in Settings, where a stale window is a real bug.
Honest scope: every resolve also does an HTTP round-trip to Plex that
dominates the database read, so this removes redundant work, not
user-visible latency.
- **Spotify: a ban seen on the retry is remembered too.** The first 429
read `retry-after` and persisted a long backoff across restarts; the
retried request's 429 only counted and bailed, so a quota ban that
surfaced one request later was forgotten on the next start and we went
back to knocking — which can restart the 24-hour window.
- **A declared intention to watch now ACTS.** "Sounds promising, I
shall put it on my watchlist" ends a deletion discussion in favour of
keeping — and the backend now does what the curator announces: the
post-turn protection scanner recognises watch-declarations as
keep-directives, protects the title, and adds it to the DISCUSSING
user's own plex.tv watchlist (their OAuth token, so it appears in
their Plex apps — per user, never the server owner's). Resolution is
deliberately cautious: word-multiset title match against Plex
Discover, expected media type, year as tiebreaker — same-name twins
without a year are refused, never guessed. The chat LLM still executes
nothing; a deterministic hook does. En route, an honesty bug died: the
curator's announced "I am flagging X for a downscale" never reached
the downscale work list (discussion keeps wrote no verdict and the
list only showed judge rows). The flag is now decided by the tech
profile — never by the LLM's prose — written as KEEP_WITH_FLAG on the
discussion keep, and the work list is verdict-gated so judge-granted
and discussion-reached flags both surface. The curator's discussion
rules say exactly what the backend does, and that it may announce
that and nothing beyond it.
- **The setup wizard learned what fits your GPU.** The Ollama step now
probes the host GPU on request (nvidia-smi, with a manual VRAM picker
for remote Ollama boxes) and recommends models from a bench-verified
catalog instead of a hardcoded default: entries carry honest fit
verdicts (weights + context headroom — the lesson of a 22 GB model
starving its KV cache on a 24 GB card), already-installed models are
marked as zero-download choices, anything else on the server stays
selectable but labeled untested, and below the verified floor the
wizard says so instead of pretending. After the bake, a warm-up check
loads the curator once and reads Ollama's own GPU-residency report — a
model that silently spills onto the CPU is flagged right there, not on
the user's first crawling conversation. The catalog is enforced against
the benchmark CSV by its own test suite, so a future bench run that
re-roles a model must be reflected deliberately.
- **File size argues the file, never the work.** A Level-2 scan conceded a
title was a documented landmark AND a genuine taste fit — both pillars
keep — then closed with "most importantly, the file is a disaster" and
recommended deletion on bitrate. Root cause was a law conflict we
shipped ourselves: the pillar framework said a bitrate outlier is a
downscale note, never a delete reason, while the injected SIZE CONTEXT
line licensed size as "a fair secondary argument" — and the fresher,
number-bearing license won. The source line now says what size may
argue: a downscale of the file on a keep, never deletion of the work,
never a tiebreaker, and "delete now, re-acquire leaner later" is not a
path to offer — the downscale flag IS the lean path. The Level-2 rules
gained the missing spine: a scan whose own findings concede a keep under
any pillar must move its verdict (re-anchoring on extra-pillar grounds
like file size or unwatched status is a broken scan), a STAGNANT verdict
is a hand-off to the owner's judgment rather than a delete verdict to
defend, and the forbidden-contempt register is banned by shape — fresh
coinages like "digital landfill" violate it the same as the listed
phrases.
- **A declaration closes the talk; a question earns an honest answer.**
First cut of the interest rule treated any spark of curiosity as a keep
signal — dangerous the other way, as the owner pointed out: every
question would become a self-fulfilling keep, and the mandatory steelman
(which is supposed to be strong enough to spark curiosity) would neuter
the funnel it serves. Calibrated: a DECLARED intention ("I want to watch
this") is first-party evidence and closes the deletion talk; a QUESTION
("is this worth watching?") mandates nothing and may honestly be
answered "no, the pitch stands" — but the answer binds the answerer:
a curator that tells the owner a title is worth their time cannot offer
the delete button in the same breath. Unclear which it was? Ask, one
sentence, no pressure.
- **Bot harvest + one real PII catch.** Three waiting bot branches
landed as direct commits: the unused `exe` attribute is gone from the
process scan (an OS path lookup per process, read by nothing),
five unlabeled `<select>`s got aria-labels, and one branch pointed at
actual PII the frozen-directory sweep had skipped — a benchmark comment
still naming the author's Windows user in the very sentence describing
the hardcoded path it replaced.
- **The law is one law at every site.** An adversarial consistency sweep
across the four prompt-law sites found the size-license bug had a
surviving twin: the legacy pitch prompt (the `PILLARS_ENABLED=false`
path — the `.env.example` default, so live on every fresh install) still
told the model to make size "a brief secondary point" in a deletion
pitch; it now carries the same downscale-only doctrine. Also closed:
the analytical-integrity rule listed only three valid reversal grounds,
giving the persona verbatim cover to dismiss the owner's first-party
word as "emotional language" — first-party evidence is now the fourth
ground and explicitly overrides the hold-your-position default; the
neutral three-path close no longer re-lists delete after the owner has
decided or expressed interest; a keep conceded in a thread binds every
later turn of it; ambiguous owner signals read as the stronger one; a
bitrate outlier can no longer demote a hard keep to "gray zone"; and
the always-on size rule in chat carries the doctrine even outside title
discussions.
- **Reordered romanised names are the same work.** A drama with a reachable
13,000-character Wikipedia article scanned as "lean data" because three
individually sound guards composed into blindness: the entity had no
English sitelink (true), the franchise article's manga-first lead failed
the direct path's plausibility gate, and the search returned the article
under a title with the Japanese surname/given-name order swapped — which
the order-sensitive equality guard called a different work. The hit guard
now accepts word-multiset equality (same words, any order) while
different words and cross-medium disambiguators still reject. The fix
heals both the significance scan and the deletion discussion's deep-read
path, which shared the guard; the retrieval version bump re-offers every
title stamped empty under the old rule.
- **The judge defers titles whose data isn't there yet.** The thin-evidence
gate caught "no enrichment at all", but partial enrichment dodged it: a
silently absent significance line read as "no documented stature" and
condemned titles our own pipeline simply hadn't fetched. The cache's
tri-state now reaches the judge — significance never successfully checked
defers the candidate (it re-enters next scan; the pre-judge warm-up
retries the fetch), while checked-and-definitively-empty remains
judgeable, because that IS evidence. When a block is built anyway, the
gap is named as missing data instead of left silent.
- **The fuller plot wins, whoever wrote it.** The verified block preferred
OMDb's "full" plot by source, so a one-line OMDb entry displaced a rich
TMDB overview. The longer of the two API texts is used now.
## 2026-08-25 — The benchmarks go public, and the docs stop lying
- **Disagreement got a register, and unknown categories get learned.** A
discussion showed the previous fix half-landing: the curator now defers
and offers the downscale, but still scolds — "hoarding", "storage tax",
"stop romanticizing mediocrity". Prose bearing-rules lose to an
"uncompromising" persona, so the forbidden register is now named
verbatim, the curator may disagree exactly once before presenting the
paths neutrally, and weak attachment ("I kinda like it") counts as
attachment. Deeper fix: when the owner invokes a retention category the
constitution lacks — camp, "so bad it's good", curiosity value — the
correct move is not to deny the category exists (it is recognised
aesthetics, Sontag onward) but to offer recording it as a learned
curation principle, which the debate-learning loop was built for. The
argument becomes the system learning instead of the system winning.
- **The owner's word about their own life is evidence, not testimony.** A
live discussion exposed the anti-sycophancy spine overcorrecting: the
owner attested three full watches of a trilogy — first-party Pillar-0
evidence the server's history could not contain, since the viewings
predate it — and the curator dismissed it as "sentiment, not curation",
lectured about scrapbooks, and re-offered the delete button after being
overruled. The discussion rules now distinguish the two kinds of owner
testimony: claims about the WORK stay unverified testimony to be named as
such, but claims about the owner's OWN viewing and attachment are
first-party evidence that wins a graceful concession — that is the
constitution working, not sentimentality. Bearing rules ride on every
discussion turn: no contempt, no re-offered deletion after the owner
decides, and an overruled bitrate outlier gets one constructive downscale
offer instead of a parting shot.
- **The pitch can no longer recite the owner back at themselves.** The
first proposal batch after the taste-as-data change proved the pipeline
(adaptation credits, ratings and episode counts argued from evidence; no
confabulated facts across ten pitches) and exposed the last leak: OWNER
TASTE is stripped from the monologue's inputs, but the judge's governing
finding rides along "for reasoning only" — and the model paraphrased its
taste language back as "you consistently demand…", with the no-size rule
leaking as "footprint on your disk". A prose rule cannot enforce this; a
shape check can: recitation and size-talk are detected on the output,
regenerated once with the violation named, and logged if the retry still
fails. Calibrated against two live batches: the first supplied the five
leak shapes, the second — run through the check — got recitation down
from five pitches to one and surfaced the two survivors ("you typically
seek", "occupying space") that widened the patterns. A third batch then
routed around the word list entirely ("you consistently reward", "your
library demands", "your viewing standard") and settled the design: the
check now matches the SHAPE — a second-person possessive reaching a
taste-noun, or you/your-library carrying a claim-verb — instead of
enumerating phrasings, and the retry instruction bans attributing
anything to the reader in any wording. "In your library", "your
attention" and "your time" carry neither form and stay legal, as does
arguing down cited acclaim — the constitution working, never a
violation.
- **The owner's taste left the constitution.** A Jules security pass
noticed what every local sweep had missed: the deletion judge's Pillar-0
text hardcoded the owner's personal taste profile — including verbatim
personal phrasings — into the public code, which was both a privacy leak
and a correctness bug, since every other install inherited that taste as
law. Thought through to the end: taste is per-user DATA, and production
already injects each user's own profile as the OWNER TASTE evidence line,
so the constitution is now deliberately taste-blind and defers to that
line (with an explicit rule that an absent line cannot condemn). Test
fixtures keep a pointed owner — a fictional one, with every case's
decisive lever intact rather than the diluted wording the bot proposed;
the live verdict gate should be re-confirmed once before the next model
swap.
- **Three more Jules deliveries, harvested mid-flight.** The chat send
button and input now disable while a message is processing and re-enable
in a `finally` (no more double-sends, no lockout on error), missing
aria-labels landed on the *arr settings and bulk-delete inputs, and two
approved structural refactors arrived exactly as specified — including
the `_store_taste_blobs` name where the old encryption fiction was
vetoed. Its bundled dev scaffolding (`run_uvicorn.sh`, a verify script)
was dropped at the door.
- **The repo is armed for public operation.** Dependabot config (pinned
pip deps, grouped minor/patch bumps, actions updates), a CodeQL workflow
that stays dormant until the repo is public (schedule + manual trigger;
push trigger documented for arming), issue forms that ask for scrubbed
logs, and a PR template whose checklist is the house rules — battery
green, behaviour changes carry tests, no personal data, rebase onto the
rewritten main. All inert until the corresponding switches are flipped
in the repo settings.
- **Three more Jules branches landed as authored.** The title-match
scorer's regex and stop-word set are hoisted to module level (~25% faster
in the nested matching loops), the SQLite files under `data/` get the
same `0600` treatment as `.env` on POSIX, and the unauthenticated PIN
rate-limiter is capped at 1,000 buckets with amortised eviction — the
one genuinely reachable memory-DoS surface, closed. A fourth branch
duplicated the German-string translation already adopted from its
predecessor PR and was skipped.
- **The taste-vector "encryption" was removed instead of shipped.** The
README offered "an opt-in PIN-based AES-GCM path"; in reality two parallel
implementations existed and neither had a single caller — the table named
`encrypted_taste_vectors` holds plain JSON. The design cannot work here:
the key derives from a PIN the user types, but the vector's consumers are
background jobs that run precisely when nobody is present to type it, and
the watch history the vector derives from sits in the same database in
plaintext anyway. Dead cipher code, its config knobs, its dependency and
its tests are gone; README and SECURITY now say the honest thing — data at
rest is unencrypted, use disk encryption if the disk is your threat model.
- **Spotify listening history imports through the GUI.** Drop the extended
streaming history (single files or the whole `my_spotify_data.zip`) into
the new Setup → Import step or the Admin view, pick whose listening it
is, done — progress in the Activity view, duplicate-safe, and the basic
export is refused with an explanation because it lacks the completion
signal replay counting depends on. The old script survives as a headless
wrapper over the same engine.
- **The setup wizard catches up with the app.** It now asks for everything
the app can use: OMDb — which the wizard's request model silently
DROPPED until now, so a key entered there was never saved — and the
optional SoulSync neighbour. Model pickers fall back to the benchmarked
pair instead of two generations of stale recommendations, and a test now
asserts the schema's three homes (field list, request model, frontend
form) agree, which is the drift that hid the OMDb bug.
- **The five auto-generated PRs the force-push closed were read and
adopted** where they held up: keyboard `:focus-visible` styles, `for=`
attributes on form labels, the rate limiter no longer sleeps while
holding its lock (a convoy that serialized every burst) and refills on a
monotonic clock, the genre-absence detector runs in one pass, and the
last German strings left the UI — a deletion-protection button and the
game-detection toast my earlier sweep missed.
- **The model benchmarks are in the repo.** Five candidates, 740
hand-scored pitches, a JSON stress gate and a 33-case chat bench —
method, verdict and raw data in [docs/BENCHMARKS.md](docs/BENCHMARKS.md),
with the score CSVs, the August report, the owner spot-check and the
harness scripts checked in so the reasoning can be audited or rerun
against your own model. Headline finding worth publishing on its own:
the pipeline winner and the chat winner were different models, and the
pipeline's +0.2 quality at 2.4× speed lost to a sycophancy collapse the
moment the metadata anchor was removed.
- **The docs recommended a model our own benchmark disqualified.** The
README suggested the curator base that the August run timed out into
600-second stalls (VRAM starvation at 16k context on 24 GB); the setup
wizard's defaults were a model generation older still. README, wizard,
config defaults and ARCHITECTURE now all name the benchmarked production
pair, and every command, setting and troubleshooting claim in the usage
guide was verified against the code — one stale claim fixed: the
Syncthing exclusion is permanent, not per-run.
- **Commit identity moved to GitHub's noreply address**, and the history
was rewritten once to scrub the personal address from past commits.
## 2026-08-22 — What the judge is given to reason from
- **Community reception was not slow, it was broken.** One exit from the
reception builder returned two values where every other returned four, so
the caller raised on every film and show. The exception was logged at debug
level, which made a total failure look like a slow backfill for months —
anime left through a different branch and worked, which is exactly why the
gap read as an ordering problem. A second exit returned three. Both fixed,
and a test now asserts that every exit from that function agrees.
- **The distiller was reading the wrong article.** Wikipedia states what a
subject is in its opening sentence; the plausibility check scanned 1,500
characters past it, far enough to reach a passing mention. So a lookup for
the 2013 series accepted the page on the Birmingham street gang that
inspired it, the distiller correctly reported no significance, and that was
stamped as a permanent verdict. The same held for Fargo and Alien, whose
disambiguation pages open "usually refers to" and "most commonly refers to"
while the guard knew only "may refer to". Judged on the lead sentence now,
with the whole family of disambiguation phrasings caught: Peaky Blinders
returns its National Television Award, Fargo its seven Oscar nominations,
Alien its Academy Award. 2,069 titles carried an empty "no significance"
stamp, and the ones that carried text were no safer — where the wrong
article had awards, another work's standing was attached to the title.
- **A failed second fetch is no longer a permanent verdict.** The tri-state
return exists because a transient failure once left a title
significance-less for good. The search call honoured it; the follow-up
article fetch did not, and a rate-limited response fell through to
"definitively nothing". Wikipedia also gained the backoff every other
rate-limited source here already had — it was the only one that answered a
"slow down" by moving on.
- **The stamp now covers how the article was chosen, not only what the model
was asked.** A perfect prompt over the wrong page yields a confident
nothing, so retrieval rules are part of the version hash and tightening
them retires the answers they produced. The just-in-time path tests that
version too; it used to gate on the bare "checked" flag, which is what made
a wrong verdict permanent there.
- **The privacy section said less than the code does.** "Only ids go out"
was already inaccurate: enrichment searches TMDB, Wikipedia, Jikan,
AniList, MusicBrainz and Last.fm *by name*, so those services learn which
titles and artists a library holds. OMDb alone is queried purely by id.
Corrected in the README, ARCHITECTURE and SECURITY, and stated the way it
actually splits: titles go out, behaviour does not — watch history,
ratings, the taste vector and anything typed stay on the machine. The
local-LLM promise is unchanged and remains true.
- **Dead cache rows are collected again.** An earlier pass removed the
expired-row cleanup because nothing called it; the rows kept accumulating,
13,201 of 92,474 of them, on a table two hot paths scan in full. Every read
path already filters on expiry, so they were unreachable rather than merely
stale. A daily custodian tick now prunes them — the lesson taken last time
was to delete the method, the one available was to call it.
- **The same class, hunted across the codebase.** Four more instances of
transient-as-permanent, each fixed the same way: studio and director notes
cached "NONE" for *ten years* when Wikipedia or the summariser merely
failed to answer (394 poisoned rows purged; they re-resolve on demand);
Last.fm negative-cached a 429 as "no such artist" for a week; the
MusicBrainz→Deezer resolver froze a thrown request as "no link" for 60
days; and the chat-memory extractor advanced its cursor past message
windows the model never processed, permanently dropping them from
personalisation. Director names are also matched with romanisation folding
now — the credits say "Shinbou" where Wikipedia writes "Shinbo", and the
exact-name guard permanently rejected the very people it was built to
find.
- **A silent regression, caught three times by its own class.** The
cache-addressing patch used `cache_id` inside functions that never got the
parameter: `topup_franchise` (every call a NameError, swallowed at debug
level) and `build_verified_data`, where the chat's deletion discussion
surfaced it as a 500. Both fixed; `topup_franchise` also gained the
transient guard it never had (an AniList rate-limit no longer stamps "no
franchise graph" permanently). A test now walks the AST of the whole tree
and asserts every function declares the names it uses.
- **Shutdown frees the GPU instead of abandoning a timer.** Stopping the
app inside the curator's 10-60s idle window tore the event loop down under
a sleeping evict task ("Task was destroyed but it is pending!") — and the
eviction it was about to perform never ran, so the model squatted in VRAM
until Ollama's own keep_alive expired, on the machine whose GPU the app
was most likely closed to free. The lifespan teardown now cancels the
timer and evicts curator and pitcher immediately, best-effort.
- **The setup wizard no longer eats hand-added settings.** Re-running it
rebuilt `.env` from its template alone, silently destroying every key
outside it — ten of thirty-three on the reference install, including the
SoulSync connection. Unknown keys are carried over verbatim into a
preserved section.
- **Silence is no longer mistaken for emptiness.** A TMDB 429 and "this
film has no reviews" both arrived as an empty list, so an outage was
stamped as a permanent "no community data" — 1,148 of 4,570 checked titles
carried nothing at all, indistinguishably. Every reception fetcher now
answers three ways (data, definitively nothing, did-not-answer), a source
that did not answer raises instead of stamping, and a busy condenser counts
as not answering. Checked-but-empty entries from before the distinction
are offered exactly once more. OMDb had the inverse bug: "Movie not
found!" — a definitive answer — was treated as transient, so the title
stayed pending forever and was re-queried on every walk; found-nothing now
stamps, quota exhaustion still never does.
- **The article is resolved by identity, and only then by name.** Asked why
only English Wikipedia is used, the measurement answered a different
question: of 120 titles stamped "no significance" that carry an IMDb id, 72
had an English article the name search could not reach — filed under
disambiguators like "The Fall Guy (2024 film)" — while only 6 had solely a
Japanese article and 0 only a German one. The bottleneck was never the
language; it was guessing names. The IMDb id now resolves the exact article
through its Wikidata sitelink before any name is tried, with the entity's
claim verified rather than trusted from search — tt0000000 exists on
Wikidata as someone's placeholder, attached to a real film, so all-zero ids
join nothing. A sitelinked article needs none of the name guards: identity
came from the id.
- **Every name of a work gets a Wikipedia turn.** The library files
"Frieren: Beyond Journey's End" — which is the article's name — while the
enriched record inside says "Sousou no Frieren", and the search ran on the
inner name alone. The exact-match guard then rightly refused to bridge two
different names, so one of the defining anime of the decade was stamped
"no documented significance"; in a sample of the affected class, Wikipedia
had the article for 8 of 13. The library's title now rides along as a
known alias and the direct lookup and the search try each name in turn.
Recommendation lists (`similar_titles`) are explicitly never aliases —
those are other works, and using them would attach a neighbour's fame.
What remains empty after this is mostly honest: a mid-tier seasonal anime
has no English Wikipedia article, and its standing evidence comes from the
reception and on-record sources instead.
- **A walker that could not find the row it was meant to write.** Raw cache
entries are filed under the *library's* title, while the `title` inside them
is the enriched one — so the row for "Frieren: Beyond Journey's End" is
titled "Sousou no Frieren", and "Dan Da Dan" holds "DAN DA DAN". Every
top-up rebuilt its lookup key from that inner title, missed, found nothing
to write and returned "nothing to do". The walker counted the title as
visited and moved on, so it stayed outstanding for good and coverage
plateaued with no error anywhere. Measured: 342 of 400 pending Wikidata
titles were unreachable, and 30% of pending significance titles. The key a
row was read from is now carried into the work list and tried first —
the same lesson as the *arr ids: do not re-derive an identity you are
already holding.
- **The backfill keeps a couple of titles in flight.** Measured per
significance title: 0.6s of Wikipedia and 2.0s of model, strictly
alternating, so the card idled through every fetch and the network through
every distillation. Overlapping them is worth about 23% — which is also the
entire ceiling an offline Wikipedia copy could offer, at 100 GB and a
rewritten retrieval path. Deliberately narrow: Ollama serialises requests
for one model on one card, so a wider pool would only queue, and would press
harder on services that asked us to be gentle.
- **Wikidata joins Wikipedia** rather than replacing it. Wikipedia is prose
and must be distilled; Wikidata is a graph of statements, so the source of
an adaptation and its named awards are looked up rather than summarised —
no model, no key, nothing to get wrong. They sit on separate evidence
lines so it stays obvious which was distilled and which was read.
- **The *arr services already knew the ids we were missing.** A quarter of
the library had no IMDb id — anime is enriched from AniList and never
touches TMDB — and two sources key on exactly that id. Sonarr held the id
for 1,833 of those titles all along, from a call made on every sync.
Harvesting them lifted id coverage from 76% to 96%.
- **Deciding *which* entry a title refers to is the part worth distrusting.**
What an *arr says about its own entry is ground truth; matching a cached
title to that entry by name is not. So an id join comes first and reads no
title at all, the name is only consulted afterwards, and the year holds a
veto over it — franchises reuse names across decades. Anime is looked for
in Radarr as well as Sonarr, because anime films live there while the
series live in Sonarr. Audited against an independent anidb→tvdb mapping,
the name step agreed on 1,788 of 1,796 checkable titles; seven of the eight
disagreements were the mapping pointing at a parent series. The one real
error was a 2013 film wearing the id of the 1978 series of the same name —
the year was in both records the whole time, unused. Claims carry the rule
version that produced them, so tightening the rules offers the old ones for
re-judgement rather than freezing them.
- **Backfill is offered where the gap is visible.** A fresh install has none
of this metadata, and the daily tick fills it at a pace meant for a library
that has been running for months. The Knowledge Base now shows a "Finish
the backfill" panel with coverage per source and a button each — and it
removes itself once a source is well covered, leaving the remainder to the
ordinary ticks. Titles that can never gain a source (no IMDb id, a quarter
of the library) count as settled rather than holding the offer open for
good. `scripts/facts_speedrunner.py` does the same from a terminal, with
`--skip-significance` for a run that never touches the GPU.
- **The source of an adaptation is now fetched.** Only the screenwriter was
ever read, so an adaptation of a celebrated novel looked like an anonymous
genre piece — the metadata said "based on a novel" without ever saying
whose. The novelist, playwright or original author is pulled from its own
credit and named separately from the adapter.
- **Cast lists are no longer mistaken for cultural significance.** The
distiller occasionally returned a verbatim Wikipedia cast section, which
left the archive pillar reading a character list as standing. A prose rule
forbidding it already existed; a shape check now enforces it.
- **Episode counts carry the season they sit in.** Three episodes measured
against a two-season total reports 25% and reads as abandonment; the same
three were half of a self-contained first series. The evidence says which
season the plays are in.
- **Distilled answers now carry the rules that produced them.** A cached
cultural-significance value was a verbatim cast list — but re-running
today's prompt on the same article produced the awards, three times out of
three. The entry was months old, written under weaker rules, and "checked"
meant "never looked at again". Distillations are stamped with a version
derived from the prompt text, so editing the prompt retires the old
answers by itself; the background walker offers stale ones again.
- **Community reception is gathered before the verdict, not after.** It was
left to a background walker, so a title could be judged with none on file
while the follow-up discussion — which does fetch it — argued from better
data than the verdict it was questioning.
## 2026-08-21 — Beta hardening
**Version.** The app reports `1.0.0-beta`, and says so in the UI. The
number is declared in one place instead of being repeated across two dozen
file headers that had already drifted apart.
**Play counts mean what they claim.** Replays were counted from raw history
rows, which mistook two things for a rewatch: an episode logged once
partway through and again when it finished, and repeated *abandoned* starts
of an episode that was never completed once — the exact inverse of the
signal. Views are now filtered by completion and collapsed when they fall
inside the same viewing window, and abandoned starts are reported
separately.
**Resumed views no longer leave two rows.** Plex reports a partial view and
the later finished view through two different queries; only the first was
being reconciled. The finished view now promotes the unfinished row it
belongs to. `scripts/dedupe_watch_history.py` repairs what earlier syncs
already wrote — dry run by default, and it only ever removes unfinished
rows.
**Duplicate detection respects external-id namespaces.** TMDB numbers films
and series in separate sequences, so the same number identifies two
unrelated works. Grouping on the bare id paired them and reported the pair
as one title stored twice, quoting the other work's size as the redundancy.
Duplicate lookups and technical-profile lookups are now scoped by media
type, and an unknown type is refused rather than guessed. Genuine
duplicates are unaffected.
**Listening depth counts listens, not skips.** Deletion protection for
music treated every history row as evidence of devotion, including tracks
that were skipped. It now requires a play that finished or ran past two
minutes — the same rule the import path applies. Heavy rotation is
unaffected; the change is felt only at the margin, which is precisely where
deletion candidates live.
**Non-fiction is judged as non-fiction.** A documentary was being measured
against the criteria of prestige drama. The judge now receives an explicit
form line for factual work: research depth and clarity are the yardstick, a
known outcome is the point, and a conventional structure is craft.
**Background text has a deterministic language.** Deletion pitches are
generated with no conversation attached, and language detection fell back
to scanning unrelated chat history — so the same title could be pitched in
one language and re-evaluated in another. Surfaces with no live message now
answer in English; chat still follows the language it is spoken to.
**Fewer round-trips.** Deletion scoring, library ranking, cache
invalidation, the downscale list and the backlog page all issued one query
per item. Each now issues one query per batch — the backlog page measured
35× faster on a full page. Its top-three tie-break is spelled out rather
than left to whatever order the database returned. Metadata lookups against
the optional LAN neighbour run concurrently, and a partial result is no
longer cached as though it were complete.
**Housekeeping.** Search evidence scoring was split into named steps with
scores verified unchanged against the ground-truth matrix. Markup no longer
leaks into generated prose. The test battery pins UTF-8 for child
processes, so a suite is no longer reported as failing because of the
terminal it was launched from. Runtime state and coding-agent scratch files
stay out of the repository.
## 2026-08-20 — Public release
- **Went public** under [AGPL-3.0](LICENSE); ported components remain under
their original licenses (see [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md)).
- **Performance:** deletion scoring and library ranking no longer issue one
vector-store call per item, and enrichment timestamps load as column
tuples instead of full ORM objects.
- **Security:** response-header middleware (`X-Content-Type-Options`,
`X-Frame-Options`, `Referrer-Policy`), implemented as pure ASGI so
server-sent event streams stay untouched.
- **Accessibility:** labels on icon-only buttons and unlabeled inputs;
decorative graphics hidden from screen readers.
- **Tests:** one battery command — `python tests/run_all.py` — runs every
suite, and CI runs exactly that. Several suites that silently executed
nothing under the old convention now execute for real.
- **Docs & packaging:** the README became a project front page, with daily
operation moved to an [operator guide](docs/USAGE.md) and internals to
[ARCHITECTURE.md](ARCHITECTURE.md). Added `.env.example`,
`CONTRIBUTING.md`, `SECURITY.md` and a pinned `requirements.txt`.
## 2026-08 — Curation quality: search, judge, integrity
- **Curated semantic search.** The model only parses the query (anchor and
constraints); scoring is deterministic against real metadata tags —
lexical-first matching, concept and tone families, negation, demographic
and comedy guards, per-constraint evidence notes, and an honest coverage
banner when nothing carries the full profile.
- **Multi-vector theme facets.** Each title's theme phrases become
individual points in a separate collection, so a query combining opposed
qualities can match a title in both regions instead of searching the
meaningless middle.
- **4-pillar deletion judge.** Taste, household use, custodianship and
resonance, ruled per title from verified evidence — with learned curation
principles extending the constitution, durable protections for what it
keeps, and a thin-evidence gate so titles without enrichment data are
skipped rather than judged blind.
- **Two-bake model split.** Conversation and batch deletion pitches run on
separately baked model tags, with residency-guarded VRAM eviction.
- **Background-job visibility.** Every maintenance task reports live
progress, and a test freezes the audited set so new jobs are visible by
default.
- **Data-integrity sweep.** Domain migration, tri-state significance so
transient failures no longer stamp a permanent verdict, self-healing for
orphaned vector documents (rebuilt deterministically from cached data, no
model involved), an adult-genre merge guard, and corrupt-source-id
detection.
- **Robustness layer** (ported, MIT): durable entity pins with a "Fix match"
action for same-named-work collisions, playlist reconcile instead of
delete-and-recreate with stale-key self-heal, and a mass-staleness guard
so an unreachable service is never mistaken for a deleted library.
## 2026-06 — Grounding, learning, multi-user recovery
- **Verified-data evidence everywhere.** Pitches, discussions and
re-evaluations reason from cached facts — creators, plot, themes, awards,
cultural significance — instead of model memory.
- **Entity resolution.** Year and MusicBrainz-id disambiguation so
same-named works are judged as themselves; watch status comes from real
history.
- **A curator that learns.** Keep-feedback becomes standing considerations
that protect similar titles in future proposals; episodic memory
extraction with semantic-similarity conflict handling.
- **Platform work.** Embedding-stack v2 (prefix-aware model, new
collection, eval-gated migration), concurrency lifecycle hardening, and
recovery flows for deleted library sections and re-attributed users.
## 2026-05 and earlier — the hardening passes
An audit-driven refactor arc that took the codebase from prototype to a
working household deployment:
- Critical-path fixes, multi-user attribution, settings surface,
concurrency lifecycle, listening-history import.
- Music pipeline cascade (library match → id resolve → genre fill →
fallback), scheduler missed-job replay, service caches, synopsis browser,
add-new flow.
- Anime id mapping bridge, vector-based recommendations, game-mode VRAM
hand-off, scheduler reliability.
- Numeric correctness fixes, a media-identity store, a server-side game
watcher, protection-intent detection.
- Negative caching, proposal re-evaluation, user ratings folded into taste,
database-lock cascade fixes, and the unified per-library breakdown panel.