0.2.76 - Icon Packs

This commit is contained in:
ponzischeme89
2026-08-18 14:59:29 +12:00
parent 36d171e51b
commit 8c847c59b8
70 changed files with 5267 additions and 673 deletions
+135 -6
View File
@@ -315,6 +315,56 @@ household, so watched/favourite/resume state must never be cached there and stil
from Emby live. A full import mark-and-sweeps on `synced_at`; incremental uses
`MinDateLastSaved` with a minute of overlap.
**But asking Emby is no longer how the gateway finds out.** Sonarr and Radarr are the
things that put files on disk, so they are what the catalogue learns from: both post to
`/hooks/sonarr` and `/hooks/radarr`, `internal/library/events.go` turns a notification into
a piece of work, and `ingest.go`'s single worker reads that one title out of Emby a minute
later. An episode imported at 19:05 is searchable at 19:06 rather than as late as 20:00,
and the hourly sweep becomes reconciliation for what the *arrs do not manage — a file
dropped in by hand, a title edited in Emby, a webhook that arrived while the container was
down. Things to preserve:
- **`library_ingest_queue` is durable, and it is the only queue in the schema that is.** A
Tracearr-derived credits candidate is rebuilt from one query on restart; "Sonarr imported
this at 19:05" cannot be rederived from anything, so a container restarted during the
settle delay must still read the file. The key names the **file** rather than the
delivery, which is what makes `ON CONFLICT` the whole of the repeat-delivery defence —
both *arrs re-notify on retry — while a file deleted and re-imported is a different file
and its own work.
- **Both hooks sit outside the quiet-time gate**, which the Radarr one previously sat
inside. That gate answers 503 and neither *arr re-delivers, so a quiet hour silently
discarded every import that happened during it. The hook records at any hour and the
*worker* is where quiet time is honoured — which is only possible because the queue is
durable.
- **An upgrade is silent as news and still refreshes the row.** Those are two judgements
made in two places: `AnnounceLibraryIngest` refuses to announce anything that is not
`ReasonImport` — the title was already there — and the catalogue re-reads it because the
file genuinely changed. A rename is a
refresh and never an invalidation — the Emby item id survives a move, and so does the
credits marker measured against it. A delete only counts when the media went with it: a
series unfollowed in Sonarr with its files left on disk is still in the library.
- **Emby not having scanned yet is the expected first answer**, not a fault. One
`RefreshItem` nudge at the parent, then a widening backoff (`IngestRetryDelay`) out to an
attempt limit — because past the last step the cause is not timing, and a row retrying for
ever is one nobody looks at.
- **The lookup asks for `syncFields`**, the scheduled import's own set, for the reason
`Syncer.Find` does: a thinner query leaves an event-imported title without People,
MediaStreams or ProviderIds — no cast, no ratings lookup, no format badges — until Emby
next reports it changed, which for a film nobody edits again is never.
- **A refresh resolves through Emby; a delete resolves through the catalogue.** That
asymmetry is deliberate. The local series index answers for every show ever imported and
Emby is asked only when it misses, which is exactly the case the feature exists for — a
brand-new show whose first episode has just landed, whose series row is then written
beside its episode. A delete is the other way round because the file is gone and Emby is
the least likely thing to still be able to name it.
- **The sweep interval is an operator override** (`librarySyncMinutes`, the
`embyHealthInterval` pattern) and `Syncer.Schedule` takes a *function* rather than a
value, because a setting read once at start-up is not a setting: lengthening the sweep to
6h after wiring the webhooks up must not need a restart.
- **`store.DeleteLibraryItem` takes the credits marker with the row.** `credits_markers` is
keyed on the item id and nothing else prunes it, so a deleted title would otherwise leave
a Skip Credits position behind for a file that no longer exists.
**External ratings** (MDBList) are bought by the day, not by the request, so the design
question is never "how fast can we fetch" but "how few times must we ever ask". The answer
is that a title is fetched once and kept: `external_media_ratings` holds the raw provider
@@ -365,12 +415,41 @@ window closes — a list rather than a push because the gateway holds no connect
television, and a window is what lets a set that was off or in the screensaver at the time
still hear the news. Four publishers today:
- `api/radarr_alerts.go` — a film Radarr just imported. `POST /hooks/radarr` is the "On
Import" webhook and the one thing that pushes *into* the gateway, guarded by
`MEMBY_RADARR_WEBHOOK_TOKEN` (unset ⇒ 404, the stance `/admin` takes) and mounted
outside both the auth middleware and the maintenance gate, because an event dropped
during maintenance is lost rather than delayed. A quality upgrade is deliberately
silent: the film was already there.
- `AnnounceLibraryIngest` in `api/ingest_alerts.go` — a film or an episode whose scan has
**finished**. `POST /hooks/radarr` and `POST /hooks/sonarr` are what push *into* the
gateway, guarded by `MEMBY_RADARR_WEBHOOK_TOKEN` / `MEMBY_SONARR_WEBHOOK_TOKEN` (unset
⇒ 404, the stance `/admin` takes) and mounted outside both the auth middleware and the
maintenance gate, because an event dropped during maintenance is lost rather than
delayed — but the *hook* no longer announces anything. It records, and the announcement
is hung off `Ingester.Announce` in `main.go`, the `SetAfterSync` arrangement, so
`library` stays ignorant of what an alert is. Things to preserve:
- **The webhook is not the news.** Both *arrs fire the moment they have moved a file and
Emby has not scanned it in yet, which is why the banner published from the hook could
only ever promise a film would be available "shortly" and why an episode could not be
announced at all — there was nothing true to say about one until it was there. Behind
the scan the banner says the title is **ready to watch**, and a title Emby never
manages to scan is never announced, which is the right way round.
- **A quality upgrade is still silent, and so are a rename and a delete.** Only
`ReasonImport` is news; the file genuinely changed, so the row is still re-read. That
judgement lives in `api` rather than in the worker: the worker's business is that the
row moved, this is the separate question of whether anybody should be told.
- **A season pack is one banner.** `ingestRuns` tallies a season's arrivals within
`ingestRunWindow` and every later one replaces the same alert, because the id is
anchored on the run's **first** episode. The anchor is what makes both halves work:
within the window a burst collapses, and next week's episode — arriving after it has
closed — starts a run of its own rather than reusing an id every television in the
house has already dismissed as seen. Bounded and lossy in memory, the `playbackTitles`
arrangement; a gateway restarted mid-pack announces the rest as a second run.
- **One arrival is named, several are counted.** Naming the last of six would be
arbitrary — nothing makes it the one worth mentioning — where the count is what the
viewer wants. `episodeSummary` drops the episode title when Emby has recorded it as
the show's own name, since "S03E05 — The Bear" reads as a mistake.
- **Emby's names outrank the *arr's.** The result carries what was actually written to
the catalogue, so the banner and the card underneath it cannot name one thing two ways.
- **The two windows still switch their own half off**: films answer to
`MEMBY_RADARR_ALERT_WINDOW`, episodes to `MEMBY_SONARR_ALERT_WINDOW`. `sonarr-import`
is its own kind, distinct from `sonarr-aired` — one says an episode has been broadcast
and is *not* here, the other that it is.
- `AnnounceLibrarySync` in `api/server_alerts.go`, hung off `syncer.SetAfterSync` in
`main.go` — "24 titles added or updated". Only a run that *changed* something is
announced; the import is scheduled, most passes find nothing, and an hourly "no news"
@@ -2199,6 +2278,56 @@ which is the same as the feature not existing. The only switch is the operator's
from its own clock that it is Halloween, while the household's gateway has seasons switched
off, would be the feature failing rather than degrading. `data/Themes.kt` is only hex
parsing, and it refuses anything it cannot read so the app's own token stands in.
**Icons are the server's answer too.** `ui/theme/MembyIcons.kt` is `DesignTokens.kt` for
marks: an enum of ~70 **slots** named for what they mean (`Search`, `Drama`, `Sparkle`), one
process-wide `mutableStateOf(MembyIconPack)`, and `applyMembyIconPack` to repaint. Before it
the app held seventy literal `Icons.Default.*` across nineteen files, which put icons exactly
where the palette was before its own work: unreachable from the gateway, because the server
can only change what the television has a slot for. The packs are
`ui/theme/MembyIconPack*.kt` — Material (what the app shipped with), Lucide and Font Awesome
Solid, from `com.composables:icons-*-cmp` — and the gateway names one with `iconSet` on the
theme document. Things to preserve:
- **A slot is named for the job, never for the mark that fills it today.** Filing the
recommendation slot under `AutoAwesome` would describe Material's four stars, and a pack
whose answer is a wand would then sit under a name that lies about it.
- **Nothing may hold a resolved mark.** `MembyIcon.mark` reads process-wide state, so a mark
captured in an `enum` constant or a top-level `val` freezes whichever pack was loaded when
that class initialised — the same `val`-versus-`get()` trap that made `SettingsSheet` the
one screen a palette could never reach. `BrowseDestination`, `SettingsPage` and
`RequestCardAction` therefore carry the **slot** and resolve it where they draw.
- **The marks are lambdas, not vectors.** An `ImageVector` is built when it is first read, so
a map of them would build all seventy on the first frame that touched a pack — on the cold
start, which is the one thing in this app nothing may cost.
- **A pack may be partial, and an absent slot falls back to Material.** Lucide is stroke-only
and has no filled heart, so mapping `Favourite` and `FavouriteOutline` to one glyph would
make "this is a favourite" and "this is not" identical on screen — a pack must never cost
the app a distinction. Font Awesome Solid declines the outline halves for the same reason
from the other side. The mixture is small and is the honest answer.
- **The wire carries a slug and never geometry**, the line the palette already draws: a
gateway that could send paths could draw an unreadable rail, where the worst a pack slug
does is look unchanged. An unknown slug resolves to Material at *both* ends —
`membyIconPackFor` on the television and `knownIconPack` on the gateway, which refuses to
echo a pack nothing can draw.
- **It rides the theme revision**, so it costs no new field on the status poll and no second
sync loop: `ThemeSync` already refetches on a revision it does not hold. The slug is cached
beside the palette and applied *before* any request, and on its own evidence — a set whose
stored palette will not parse still opens wearing the marks it was told to wear.
- **Where it is chosen is the `iconSet` preference**, beside `themeId`, so an operator sets it
per viewer from the console's existing catalogue-driven editor and no admin page was needed.
A **season may replace the marks; a selectable theme may not** (`IconSet` on
`themeDefinition`, the `Decoration` shape) — a season is a look, where a scheme somebody
picked to live with all year taking their marks away leaves no way to tell which of the two
choices did it.
- **R8 is what makes three packs affordable.** Only the slots named in the maps survive out of
packs holding a thousand icons each: measured, two complete packs cost **+16 KB** on the
release APK. A slot nothing draws costs three vectors for nothing.
- **`IconPackScreenshotTest` is the only test that can judge this** (`build/screenshots/
icon-packs/`), the point `ThemeScreenshotTest` makes about palettes. A unit test can check
that a pack names a mark for a slot; it cannot check whether that mark *means* the slot —
it is what caught Lucide's Action genre drawing an award ribbon. Both sizes are captured
because a stroke set has least to spare at the 21dp the rail draws.
**Seasonal decorations** are `ui/seasonal/SeasonalDecorations.kt`: snow, bats or blossom
drifting over the launcher for the few days a season is on. A palette on its own is a thin
idea of Christmas — the colours change and nothing says why — and this is the half that