# 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 '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 ` 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 `