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
+26 -8
View File
@@ -66,7 +66,11 @@ MEMBY_ADMIN_TOKEN=4fad67d508558efee5cc5ae05694105421d4c79d35ee2333a817b3791235cd
MEMBY_PUBLIC_URL=https://mserver.sublogue.com
MEMBY_SECRETS_DIR=/share/Docker/Memby-secrets
# Library import. Hourly incremental keeps up with episodes added through the day.
# Catalogue sweep. With both *arr webhooks below configured this is reconciliation for
# media Sonarr and Radarr do not manage — a file dropped in by hand, a title edited in
# Emby, a notification that never arrived — so it can be lengthened to 6h. Without them
# it is the only way a new title is ever found and must stay frequent. The console can
# override it at Settings > Catalogue sweep without a redeployment.
MEMBY_SYNC_INTERVAL=1h
MEMBY_SYNC_ON_START=false
@@ -81,9 +85,20 @@ MEMBY_SYNC_API_KEY=56775917938841e7ac1b6a233d4d5075
MEMBY_SONARR_URL=http://10.0.0.2:8989
MEMBY_SONARR_API_KEY=6b608b051ee24582925773bd5dfbe37a
MEMBY_SONARR_TTL=5m
# How long after an episode airs the "aired, coming soon" banner keeps being offered.
# 0 turns the banners off and leaves the airing-today row alone.
# How long an episode banner keeps being offered — both the "aired, coming soon" one and
# the "new episode added" one a finished scan produces. 0 turns both off and leaves the
# airing-today row alone.
MEMBY_SONARR_ALERT_WINDOW=3h
# Shared secret for Sonarr's webhook, which is how the catalogue learns that an episode
# landed rather than waiting for the next sweep. In Sonarr: Settings > Connect > + >
# Webhook, with On Import, On Upgrade, On Rename, On Episode File Delete and On Series
# Delete ticked, URL https://<public gateway>/hooks/sonarr?token=<this value>. Empty
# makes the hook 404.
MEMBY_SONARR_WEBHOOK_TOKEN=09f091f0f2ee8af3cba1aac29557e344
# How long after a webhook the gateway first looks for the file in Emby. Sonarr and
# Radarr fire the moment they have moved the file into place and Emby has not scanned it
# yet, so asking immediately spends a request to learn nothing.
MEMBY_ARR_INGEST_SETTLE=1m
# Optional Radarr calendar integration. Upcoming movies cover the coming month, ordered by
# Radarr's digital release date. A film with no digital date yet is estimated at its cinema
@@ -91,12 +106,15 @@ MEMBY_SONARR_ALERT_WINDOW=3h
MEMBY_RADARR_URL=http://10.0.0.2:7878
MEMBY_RADARR_API_KEY=d393acb157a44dc2b0e2aede96278ad5
MEMBY_RADARR_TTL=5m
# Shared secret for Radarr's "On Import" webhook, which announces a newly added film on
# every TV that is awake. In Radarr: Settings > Connect > + > Webhook, On Import only,
# URL https://<public gateway>/hooks/radarr?token=<this value>. Empty makes the hook 404.
# Shared secret for Radarr's webhook, which is how the catalogue learns that a film landed
# rather than waiting for the next sweep. In Radarr: Settings > Connect > + > Webhook, with
# On Import, On Upgrade, On Rename, On Movie File Delete and On Movie Delete ticked, URL
# https://<public gateway>/hooks/radarr?token=<this value>. Empty makes the hook 404.
# The catalogue reads all five; the banner is announced once the film has actually scanned
# in, and only for an import — an upgrade replaced a film that was already there.
MEMBY_RADARR_WEBHOOK_TOKEN=bfa059594adeadf9105c27481a5fd758
# How long an imported film keeps being announced, so a TV switched on shortly after the
# import still hears about it. 0 turns the banners off.
# How long a newly scanned film keeps being announced, so a TV switched on shortly after
# the import still hears about it. 0 turns the banners off.
MEMBY_RADARR_ALERT_WINDOW=3h
# Optional Bazarr integration, one of the two providers a viewer can fetch a missing
+3
View File
@@ -1,3 +1,6 @@
## 0.2.75 - 2026-08-18
- Chore: Upgrade player dependencies.
## 0.2.74 — 2026-08-17
- Fixed: Genre shelves were showing films only. A programme whose details came from TMDb carries its genre as one label — "Sci-Fi & Fantasy", "Action & Adventure", "War & Politics" — which the shelves did not recognise, so every show was missing from Sci-Fi & Fantasy, Action & Adventure and War & History. Shows now appear on them beside the films.
- Improved: Genre shelves recognise more ways of spelling the same genre, including the hyphenated names IMDb uses, so fewer titles are missed. Sport finds more than it did, though a film your server has not tagged with a sport genre at all still cannot appear there.
+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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-d9286FJI.js"></script>
<script type="module" crossorigin src="/admin/assets/index-BmnCg8np.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-cNUhbl7V.css">
</head>
+38
View File
@@ -541,6 +541,7 @@ export interface GatewaySettings {
sonarrAlertMinutes: number;
radarrAlertMinutes: number;
embyHealthSeconds: number;
librarySyncMinutes: number;
updatedAt?: string;
updatedBy?: string;
}
@@ -554,6 +555,7 @@ export interface GatewaySettingValues {
sonarrAlertMinutes: number;
radarrAlertMinutes: number;
embyHealthSeconds: number;
librarySyncMinutes: number;
}
export interface GatewaySettingsResponse {
@@ -563,3 +565,39 @@ export interface GatewaySettingsResponse {
logLevels: string[] | null;
version: string;
}
/** IngestJob is one thing Sonarr or Radarr said changed. The key is derived from the file
* rather than the delivery, which is what makes a repeated webhook one row. */
export interface IngestJob {
key: string;
action: string;
kind: string;
reason: string;
source: string;
payload: {
series?: string;
seriesYear?: number;
season?: number;
episode?: number;
title?: string;
year?: number;
embyItemId?: string;
};
state: string;
outcome: string;
itemId: string;
attempts: number;
lastError: string;
dueAt: string;
createdAt: string;
updatedAt: string;
}
export interface IngestResponse {
sonarrConfigured: boolean;
radarrConfigured: boolean;
settleSeconds: number;
syncMinutes: number;
counts: { pending: number; done: number; failed: number };
recent: IngestJob[];
}
+108 -54
View File
@@ -1,7 +1,16 @@
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { initials, num, presence, recent, watchTime, when } from '../lib/format';
import { Banner, Empty, Loading, Note, PageHead, Tag, Tiles } from '../components/ui';
import { ago, initials, num, presence, recent, watchTime, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Loading,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type { KnownClient } from '../api/types';
/* A directory, and only a directory. Everything you can *do* to a person lives on their own
@@ -38,6 +47,23 @@ interface AccountsResponse {
accounts: Account[] | null;
}
/** seenAt is a timestamp as a number, with anything unreadable sorting last rather than
* first an invalid date yields NaN, and NaN comparisons would scatter those rows. */
function seenAt(value: string | undefined): number {
const at = value ? new Date(value).getTime() : 0;
return Number.isFinite(at) ? at : 0;
}
/** A dash, and it says why on hover. Watch time comes from Tracearr; a household running
* none and a person it has never matched are both "not measured", never "none". */
function NotMeasured() {
return (
<span className="muted" title="No Tracearr sessions matched to this person">
</span>
);
}
export function AccountsPage() {
const { data, error, loading } = useQuery<AccountsResponse>('/admin/api/accounts', {
pollMs: 60_000,
@@ -53,17 +79,18 @@ export function AccountsPage() {
const tracked = accounts.filter((account) => account.watchTime?.matched);
const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0);
/* Most recently seen first, so the table means something without being sorted. Whoever
is using Memby right now is the row an operator opening this page is looking for, and a
person who has never signed in from a device sorts to the bottom rather than the top. */
const rows = [...accounts].sort(
(a, b) => seenAt(b.lastSeen) - seenAt(a.lastSeen),
);
return (
<>
<PageHead title="Memby users" intro="Who uses Memby, and the devices they are signed in on." />
<Banner message={error} />
<Note tone="info">
This is the Memby user list, not the Emby user directory. A person appears here only after
signing in to the Memby app. Removing access signs their Memby devices out and does not delete
or change their Emby account.
</Note>
{loading ? (
<Loading />
) : (
@@ -93,53 +120,80 @@ export function AccountsPage() {
]}
/>
<section className="card flush">
{accounts.length === 0 ? (
<Empty>
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
</Empty>
) : (
accounts.map((account) => {
const list = account.devices ?? [];
const active = list.filter((device) => recent(device.lastSeen)).length;
const state = account.recommendations?.completed
? { label: 'personalised', tone: 'ok' as const }
: account.recommendations?.prompted
? { label: 'prompt queued', tone: 'warn' as const }
: { label: 'not invited', tone: undefined };
const seen = presence(account.lastSeen);
const watched = account.watchTime;
return (
<Link className="list-row" key={account.id} to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
<span className="list-main">
<span className="avatar">{account.initials || initials(account.username)}</span>
<span>
<span className="list-title">
{account.username || 'Unnamed user'}
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
</span>
<span className="list-meta">
{num(list.length)} device{list.length === 1 ? '' : 's'}
{active ? ` · ${active} active now` : ''} · last seen {when(account.lastSeen)}
<Card title="People" icon="people" tone="note">
<TableWrap>
<table>
<thead>
<tr>
<th>Person</th>
<th className="num">Devices</th>
<th className="num">This week</th>
<th className="num">This month</th>
<th>Recommendations</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={6}>
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
</EmptyRow>
) : (
rows.map((account) => {
const list = account.devices ?? [];
const active = list.filter((device) => recent(device.lastSeen)).length;
const state = account.recommendations?.completed
? { label: 'personalised', tone: 'ok' as const }
: account.recommendations?.prompted
? { label: 'prompt queued', tone: 'warn' as const }
: { label: 'not invited', tone: undefined };
const seen = presence(account.lastSeen);
const watched = account.watchTime;
return (
<tr key={account.id}>
<td>
<span className="row tight">
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
<span className="avatar">{account.initials || initials(account.username)}</span>
<Link
className="table-row-link"
to={`/admin/accounts/${encodeURIComponent(account.id)}`}
>
{account.username || 'Unnamed user'}
</Link>
</span>
</td>
<td className="num">
{num(list.length)}
{/* Only where there is something to say. A sub-line under every
row reading "0 active now" is a column of noise. */}
{active ? <span className="table-sub">{num(active)} active now</span> : null}
</td>
{/* The month sits beside the week because a quiet week only means
something next to the month around it. Both are omitted rather
than zeroed for somebody Tracearr has never seen. */}
{watched?.matched
? ` · watched ${watchTime(watched.weekMs)} this week, ${watchTime(watched.monthMs)} this month`
: ''}
</span>
</span>
</span>
<span className="list-actions">
{watched?.matched ? <Tag tone="data">{watchTime(watched.weekMs)}</Tag> : null}
<Tag tone={state.tone}>{state.label}</Tag>
<span className="crumb">Manage</span>
</span>
</Link>
);
})
)}
</section>
something next to the month around it. Both are a dash rather
than a zero for somebody Tracearr has never seen: "0 min" would
have an operator investigating a person when the real answer is
that nothing was ever asked. */}
<td className="num">
{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}
</td>
<td className="num muted">
{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}
</td>
<td>
<Tag tone={state.tone}>{state.label}</Tag>
</td>
<td className="nowrap muted" title={when(account.lastSeen)}>
{ago(account.lastSeen)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
+138 -3
View File
@@ -1,6 +1,136 @@
import { useGateway } from '../lib/gateway';
import { useQuery } from '../lib/hooks';
import { num, when } from '../lib/format';
import { Banner, Card, EmptyRow, Loading, PageHead, TableWrap, Tag } from '../components/ui';
import { Banner, Card, EmptyRow, Loading, PageHead, TableWrap, Tag, Tiles } from '../components/ui';
import type { IngestResponse, IngestJob } from '../api/types';
// Two tables about the same catalogue, and they answer different questions. The webhook
// activity is "what did Sonarr and Radarr tell us, and did we act on it" — the one to read
// when somebody says a new episode is not showing up. The sweep history underneath is
// "when did we last ask Emby the whole question", which is now reconciliation rather than
// how anything is discovered.
const stateTone: Record<string, 'ok' | 'warn' | 'bad'> = {
done: 'ok',
pending: 'warn',
failed: 'bad',
};
/** What a row was about, in words. The key is a machine identity and the payload is what a
* person recognises. */
function subject(job: IngestJob): string {
const payload = job.payload ?? {};
if (payload.series) {
const position =
payload.episode && payload.episode > 0
? ` S${String(payload.season ?? 0).padStart(2, '0')}E${String(payload.episode).padStart(2, '0')}`
: '';
return `${payload.series}${position}`;
}
if (payload.title) {
return payload.year ? `${payload.title} (${payload.year})` : payload.title;
}
return job.key;
}
function WebhookActivity() {
const { data, error, loading } = useQuery<IngestResponse>('/admin/api/ingest', { pollMs: 15000 });
if (loading) return <Loading rows={1} />;
const wired = Boolean(data?.sonarrConfigured || data?.radarrConfigured);
const recent = data?.recent ?? [];
return (
<>
<Banner message={error} />
<Card
title="Webhook activity"
intro="Sonarr and Radarr are what put files on disk, so they are what the catalogue learns from. A notification is recorded the moment it arrives and read into the catalogue once the file has settled — which is why an import appears here before it appears in Emby."
icon="plug"
tone="info"
>
<Tiles
tiles={[
{
label: 'Sonarr webhook',
value: data?.sonarrConfigured ? 'Configured' : 'Not configured',
small: true,
tone: data?.sonarrConfigured ? 'ok' : 'warn',
icon: 'tv',
},
{
label: 'Radarr webhook',
value: data?.radarrConfigured ? 'Configured' : 'Not configured',
small: true,
tone: data?.radarrConfigured ? 'ok' : 'warn',
icon: 'play',
},
{ label: 'Waiting', value: num(data?.counts.pending ?? 0), icon: 'clock', tone: 'note' },
{ label: 'Given up on', value: num(data?.counts.failed ?? 0), icon: 'alert', tone: 'bad' },
{
label: 'Settle delay',
value: `${data?.settleSeconds ?? 0}s`,
small: true,
icon: 'history',
tone: 'data',
},
]}
/>
{!wired ? (
<p className="muted">
Neither hook has a token, so both answer 404 and nothing is recorded here. Set
MEMBY_SONARR_WEBHOOK_TOKEN and MEMBY_RADARR_WEBHOOK_TOKEN, then point each *arr at{' '}
<code>/hooks/sonarr</code> and <code>/hooks/radarr</code>. Until then the catalogue
sweep below is the only way a new title is found.
</p>
) : null}
<TableWrap>
<table>
<thead>
<tr>
<th>When</th>
<th>Source</th>
<th>What</th>
<th>Why</th>
<th>State</th>
<th>Outcome</th>
<th className="num">Tries</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{recent.length === 0 ? (
<EmptyRow columns={8}>
{wired
? 'Nothing has been imported, upgraded, renamed or deleted since this was switched on.'
: 'No webhook is configured.'}
</EmptyRow>
) : (
recent.map((job) => (
<tr key={job.key}>
<td className="nowrap muted">{when(job.updatedAt)}</td>
<td className="muted">{job.source || '—'}</td>
<td>{subject(job)}</td>
<td className="muted">{job.reason}</td>
<td>
<Tag tone={stateTone[job.state] ?? 'warn'}>{job.state}</Tag>
</td>
<td className="muted">{job.outcome || '—'}</td>
<td className="num">{job.attempts}</td>
<td className="muted">{job.lastError || ''}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
);
}
export function ImportsPage() {
const { status, error, loading } = useGateway();
@@ -8,15 +138,20 @@ export function ImportsPage() {
return (
<>
<PageHead title="Imports" intro="Catalogue synchronisation history." />
<PageHead
title="Imports"
intro="What Sonarr and Radarr said changed, and the catalogue sweep that reconciles everything they do not manage."
/>
<Banner message={error} />
<WebhookActivity />
{loading ? (
<Loading rows={1} />
) : (
<Card
title="Synchronisation history"
intro="A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs."
intro="A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs. With both webhooks wired up this is reconciliation — media dropped in by hand, a title edited in Emby, a notification that never arrived — rather than how new titles are found."
icon="sync"
tone="data"
>
+20
View File
@@ -53,6 +53,7 @@ interface Draft {
sonarrAlertMinutes: string;
radarrAlertMinutes: string;
embyHealthSeconds: string;
librarySyncMinutes: string;
}
function draftFrom(settings: GatewaySettings): Draft {
@@ -63,6 +64,7 @@ function draftFrom(settings: GatewaySettings): Draft {
sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes),
radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes),
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes),
};
}
@@ -92,6 +94,7 @@ export function SettingsPage() {
sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true),
radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true),
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true),
librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true),
};
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', body),
@@ -111,6 +114,7 @@ export function SettingsPage() {
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
timezone: '', logLevel: '', sessionIdleDays: 0,
sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0,
librarySyncMinutes: 0,
}),
'Every setting is back to what this container was deployed with.',
);
@@ -147,6 +151,7 @@ export function SettingsPage() {
{ label: 'Log level', value: effective.logLevel },
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
{ label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') },
{ label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
]}
@@ -225,6 +230,21 @@ export function SettingsPage() {
</Field>
</div>
<div className="fields">
<Field
label="Catalogue sweep (minutes)"
hint={`Deployed: ${deployed.librarySyncMinutes || 'off'}. How often the gateway asks Emby what has changed. With the Sonarr and Radarr webhooks wired up a new file is in the catalogue within a minute of landing, and this is only reconciliation for media they do not manage — 360 is a sensible choice then. Without them it is the only way anything is found.`}
>
<input
type="text"
inputMode="numeric"
value={draft.librarySyncMinutes}
placeholder={String(deployed.librarySyncMinutes)}
onChange={(event) => set('librarySyncMinutes', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Episode alert window (minutes)"
+44 -10
View File
@@ -12,6 +12,19 @@ plugins {
// the Jellyfin FFmpeg extension pins which line that can be.
val media3Version = "1.9.4"
// Lifecycle and Activity move as one AndroidX stack: activity-compose depends on the
// lifecycle artifacts, and lifecycle-runtime-compose depends on activity's ComponentActivity
// contract, so a split upgrade resolves to a mixture Gradle picked rather than one the
// libraries were tested as. Keep these two in step.
val lifecycleVersion = "2.11.0"
val activityVersion = "1.13.0"
// The icon packs a server-driven theme may name. One version across every pack: the
// receiver objects (Lucide, FontAwesome.Solid) come from shared base artifacts, so a split
// version resolves two copies of the same object and the extension properties stop
// matching their receiver.
val composeIconsVersion = "2.2.1"
// Set in gradle.properties (or ~/.gradle/gradle.properties, or -Pmemby.serverUrl=...).
// Blank means "no hardwired server": the setup screen asks the user for an address.
val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?).orEmpty().trim()
@@ -50,7 +63,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.75"
val defaultVersionName = "0.2.76"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -84,7 +97,11 @@ fun environmentSecret(key: String): String? {
android {
namespace = "com.ponzischeme89.memby"
compileSdk = 35
// Raised to 37 by the Lifecycle 2.11 / Activity 1.13 upgrade, which refuse to be
// consumed by a project compiled against anything older. This is a *compile* target
// only — targetSdk stays 35, so no new runtime behaviour is opted into and the
// televisions this ships to behave exactly as before.
compileSdk = 37
defaultConfig {
// Matches the Kotlin package. Changed from com.mattcohen.embyclientsname at
@@ -209,20 +226,27 @@ kotlin {
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
// Moved with the Lifecycle 2.11 / Activity 1.13 upgrade, which depend on Compose 1.11
// and so resolve past whatever this BOM says. Left at 2024.12.01 the BOM stopped being
// a floor OR a ceiling: Gradle picked ui 1.11.0 beside foundation 1.10.3, which is a
// Compose pair nobody published or tested together. The BOM's whole job is that the
// artifacts move as one set, so it has to keep up with what depends on them.
val composeBom = platform("androidx.compose:compose-bom:2026.06.01")
implementation(composeBom)
// Core / lifecycle / activity
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.activity:activity-compose:$activityVersion")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion")
implementation("androidx.work:work-runtime-ktx:2.11.2")
// ProcessLifecycleOwner: lets the status poll stop while no Memby screen is on top,
// instead of hitting the gateway every ten seconds for as long as the process lives.
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
implementation("androidx.savedstate:savedstate-ktx:1.2.1")
implementation("androidx.lifecycle:lifecycle-process:$lifecycleVersion")
// Pulled to 1.4.0 by lifecycle 2.11 regardless; declared at the resolved version so the
// build file states what actually ships.
implementation("androidx.savedstate:savedstate-ktx:1.4.0")
// Measurement only: JankStats is enabled by PerformanceMonitor for debug builds.
implementation("androidx.metrics:metrics-performance:1.0.0")
// Installs the baseline profile below. Without it the profile is only honoured on
@@ -238,6 +262,16 @@ dependencies {
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material:material-icons-extended")
// Server-driven icon packs. A theme names a pack (ui/theme/MembyIconPacks.kt) and the
// television paints its marks from it, so a household can be moved off Material's
// marks without an APK release — the same trade the palette makes.
//
// Pure Kotlin ImageVectors, one lazy val per icon, so R8 keeps only the ~70 slots the
// packs below actually name. No .so, no per-ABI multiplier: this is a size decision,
// but a small one, unlike the libmpv episode recorded further down this file.
implementation("com.composables:icons-lucide-cmp:$composeIconsVersion")
implementation("com.composables:icons-font-awesome-solid-cmp:$composeIconsVersion")
// Compose for TV
implementation("androidx.tv:tv-material:1.1.0")
@@ -388,6 +388,12 @@ data class Settings(
* and then flick into the viewer's.
*/
val themePaletteJson: String? = null,
/**
* The icon pack the gateway resolved, cached beside the palette because it arrives with
* it and is worth exactly as much on the next cold start: a set that painted its own
* colours and then swapped its marks a second later would be advertising the request.
*/
val themeIconSet: String = "",
val themeRevision: String = "",
/**
* User ids known to have finished recommendation onboarding on this TV. Cold start
@@ -570,6 +576,7 @@ class SettingsStore(private val context: Context) {
val PROFILE_INITIALS = stringPreferencesKey("profile_initials")
val THEME_ID = stringPreferencesKey("theme_id")
val THEME_PALETTE = stringPreferencesKey("theme_palette")
val THEME_ICON_SET = stringPreferencesKey("theme_icon_set")
val THEME_REVISION = stringPreferencesKey("theme_revision")
val PROFILES = stringPreferencesKey("profiles")
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
@@ -764,14 +771,16 @@ class SettingsStore(private val context: Context) {
* store rewritten every ten seconds to say nothing new is exactly the cost the revision
* exists to avoid.
*/
suspend fun setThemePalette(paletteJson: String, revision: String) {
suspend fun setThemePalette(paletteJson: String, iconSet: String, revision: String) {
if (latestSettings?.themePaletteJson == paletteJson &&
latestSettings?.themeIconSet == iconSet &&
latestSettings?.themeRevision == revision
) {
return
}
context.dataStore.edit { preferences ->
preferences[Keys.THEME_PALETTE] = paletteJson
preferences[Keys.THEME_ICON_SET] = iconSet
preferences[Keys.THEME_REVISION] = revision
}
}
@@ -1397,6 +1406,7 @@ class SettingsStore(private val context: Context) {
// Dropping it puts this set on the default until ThemeSync answers, which is a
// second of the app's own colours rather than a minute of somebody else's.
preferences.remove(Keys.THEME_PALETTE)
preferences.remove(Keys.THEME_ICON_SET)
preferences.remove(Keys.THEME_REVISION)
preferences[Keys.HOME_SECTIONS] = profile.homeSections
preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
@@ -1520,6 +1530,7 @@ class SettingsStore(private val context: Context) {
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID,
themePaletteJson = preferences[Keys.THEME_PALETTE],
themeIconSet = preferences[Keys.THEME_ICON_SET].orEmpty(),
themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
@@ -5,8 +5,11 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.ponzischeme89.memby.data.model.GatewayTheme
import com.ponzischeme89.memby.data.model.GatewayThemeStatus
import com.ponzischeme89.memby.ui.theme.MaterialIconPack
import com.ponzischeme89.memby.ui.theme.MembyPalette
import com.ponzischeme89.memby.ui.theme.applyMembyIconPack
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
import com.ponzischeme89.memby.ui.theme.membyIconPackFor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -95,8 +98,11 @@ class ThemeSync(
// happens to reach the foreground.
scope.launch {
repository.settingsFlow
.distinctUntilChanged { old, new -> old.themePaletteJson == new.themePaletteJson }
.collect { session -> applyCached(session.themePaletteJson) }
.distinctUntilChanged { old, new ->
old.themePaletteJson == new.themePaletteJson &&
old.themeIconSet == new.themeIconSet
}
.collect { session -> applyCached(session.themePaletteJson, session.themeIconSet) }
}
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -131,6 +137,7 @@ class ThemeSync(
_available.value = emptyList()
appliedRevision = null
applyMembyPalette(MembyPalette())
applyMembyIconPack(MaterialIconPack.pack)
return
}
// A server that predates themes sends nothing, and there is nothing to fetch. The
@@ -159,6 +166,9 @@ class ThemeSync(
val palette = resolved.palette.toMembyPalette()
applyMembyPalette(palette)
// Unknown slugs resolve to the marks the app shipped with, so a pack the operator
// added after this build went out costs nothing — see membyIconPackFor.
applyMembyIconPack(membyIconPackFor(resolved.iconSet))
// The revision recorded is the one the *response* carried, not the one the poll
// advertised. They differ if a season turned over between the two, and storing the
// poll's would leave this set believing it holds a palette it never received.
@@ -166,6 +176,7 @@ class ThemeSync(
runCatching {
settings.setThemePalette(
json.encodeToString(com.ponzischeme89.memby.data.model.GatewayPalette.serializer(), resolved.palette),
resolved.iconSet,
appliedRevision.orEmpty(),
)
}.onFailure {
@@ -180,7 +191,11 @@ class ThemeSync(
* Paints from what was stored at the end of the last session, before anything is asked
* of the network. A blank or unreadable cache leaves the default palette standing.
*/
private fun applyCached(paletteJson: String?) {
private fun applyCached(paletteJson: String?, iconSet: String) {
// The pack is applied on its own evidence. It is cached as a slug rather than
// inside the palette document, so a set whose stored palette will not parse still
// opens wearing the marks it was told to wear.
applyMembyIconPack(membyIconPackFor(iconSet))
if (paletteJson.isNullOrBlank()) return
val palette = runCatching {
json.decodeFromString(
@@ -319,6 +319,18 @@ data class GatewayTheme(
* field rather than deriving the animation from the theme id.
*/
val decoration: String = "",
/**
* The pack this theme's marks are drawn from: "material", "lucide", "fontawesome", or
* empty for the marks the app shipped with.
*
* A slug, for the reason [decoration] is one the shapes are the television's, in
* `ui/theme/MembyIconPacks.kt`, and a pack this build has never heard of falls back to
* Material rather than drawing nothing. That is what lets an operator add a pack to the
* catalogue before the fleet has the release that knows it, and it is the same bargain
* the palette makes: the gateway sends a decision, never geometry, so the worst a bad
* theme can do is look unremarkable.
*/
val iconSet: String = "",
val revision: String = "",
val palette: GatewayPalette = GatewayPalette(),
)
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
@@ -39,9 +41,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -1254,7 +1253,7 @@ internal fun DetailStripFrame(
// under this strip and this chevron are what say it. Never focusable — it is a
// caption on the Down key, not another thing to land on.
Icon(
Icons.Default.KeyboardArrowDown,
MembyIcon.ChevronDown.mark,
contentDescription = null,
tint = DetailQuietText,
modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp),
@@ -1690,7 +1689,7 @@ private fun DetailExtraCard(item: BaseItem, onClick: () -> Unit, modifier: Modif
AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
}
Icon(
Icons.Default.PlayArrow,
MembyIcon.Play.mark,
contentDescription = null,
tint = Color.White,
modifier = Modifier
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
@@ -22,12 +24,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -280,7 +276,7 @@ internal fun EpisodeDetailContent(
DetailHeroAction(
// A heart, not a tick: "Mark watched" beside it is a tick as well. The
// heart is what a home card and the screensaver already use.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
active = item.isFavorite,
onClick = {
@@ -292,7 +288,7 @@ internal fun EpisodeDetailContent(
)
add(
DetailHeroAction(
icon = Icons.Default.DoneAll,
icon = MembyIcon.CheckAll.mark,
description = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
active = item.userData?.played == true,
onClick = { onTogglePlayed(item, item.userData?.played != true) },
@@ -303,7 +299,7 @@ internal fun EpisodeDetailContent(
item.seriesId?.takeIf(String::isNotBlank)?.let { seriesId ->
add(
DetailHeroAction(
icon = Icons.Default.Tv,
icon = MembyIcon.Tv.mark,
description = "Open ${item.seriesName ?: "the series"}",
onClick = {
onOpenItem(
@@ -453,7 +449,7 @@ private fun SeasonStop(
) {
if (done) {
Icon(
Icons.Default.Check,
MembyIcon.Check.mark,
contentDescription = "Watched",
tint = if (selected || focused) DetailAccent else DetailAccent.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp).padding(end = 1.dp),
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -19,8 +21,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -162,7 +162,7 @@ internal fun ExitMembyConfirmation(
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PowerSettingsNew,
MembyIcon.Power.mark,
contentDescription = null,
tint = MembyAccentBright,
modifier = Modifier.size(27.dp),
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
@@ -82,37 +84,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.PlaylistRemove
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Tv
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
@@ -177,21 +148,21 @@ internal val TvRailContentShift = 112.dp
* while For You is a *narrow* one a handful of ranked titles so it belongs down beside
* the calendar with the other answers rather than above the shelves it draws from.
*/
enum class BrowseDestination(val label: String, val icon: ImageVector) {
HOME("Home", Icons.Default.Home),
enum class BrowseDestination(val label: String, val icon: MembyIcon) {
HOME("Home", MembyIcon.Home),
// The catalogue, browsed by genre rather than by shelf. Hidden unless the gateway has
// the genre browser on — see [TvNavigationRail]'s genresEnabled.
GENRES("Genres", Icons.Default.GridView),
SEARCH("Search", Icons.Default.Search),
MOVIES("Movies", Icons.Default.Movie),
SHOWS("TV Shows", Icons.Default.Tv),
FOR_YOU("For You", Icons.Default.AutoAwesome),
GENRES("Genres", MembyIcon.Grid),
SEARCH("Search", MembyIcon.Search),
MOVIES("Movies", MembyIcon.Movie),
SHOWS("TV Shows", MembyIcon.Tv),
FOR_YOU("For You", MembyIcon.Sparkle),
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
// has one — see [TvNavigationRail]'s calendarEnabled.
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
FAVORITES("Favourites", Icons.Default.Favorite),
PROFILES("User", Icons.Default.Person),
SETTINGS("Settings", Icons.Default.Settings),
CALENDAR("TV Calendar", MembyIcon.Calendar),
FAVORITES("Favourites", MembyIcon.Favourite),
PROFILES("User", MembyIcon.Person),
SETTINGS("Settings", MembyIcon.Settings),
}
/**
@@ -229,40 +200,40 @@ private data class HomeRowVisual(
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
row.id == "continue" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
MembyIcon.PlayCircle.mark,
)
row.id == "continue-shows" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
MembyIcon.PlayCircle.mark,
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
Icons.Default.Favorite,
MembyIcon.Favourite.mark,
)
row.id == "latest-movies" -> HomeRowVisual(
Icons.Default.Movie,
MembyIcon.Movie.mark,
)
row.id == "sonarr-airing-today" -> HomeRowVisual(
Icons.Default.CalendarMonth,
MembyIcon.Calendar.mark,
)
row.id == "curated:apple-tv" -> HomeRowVisual(
Icons.Default.LiveTv,
MembyIcon.LiveTv.mark,
)
row.id == "curated:drama-shows" -> HomeRowVisual(
Icons.Default.TheaterComedy,
MembyIcon.Drama.mark,
)
row.id == "curated:comedy-shows" -> HomeRowVisual(
Icons.Default.SentimentVerySatisfied,
MembyIcon.Happy.mark,
)
row.id.startsWith("similar:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
MembyIcon.Sparkle.mark,
)
row.id == "recommended" -> HomeRowVisual(
Icons.Default.Recommend,
MembyIcon.Recommend.mark,
)
row.id.startsWith("for-you:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
MembyIcon.Sparkle.mark,
)
else -> HomeRowVisual(
Icons.Default.VideoLibrary,
MembyIcon.VideoLibrary.mark,
)
}
@@ -632,7 +603,7 @@ fun UserSwitcherOverlay(
// what replaces the bell that used to sit in the corner of Home.
UserSwitcherAction(
label = "Notifications",
icon = Icons.Default.Notifications,
icon = MembyIcon.Notification.mark,
badge = alertBadgeLabel(alertCount),
modifier = Modifier
.focusRequester(focusRequesters[profiles.size])
@@ -645,7 +616,7 @@ fun UserSwitcherOverlay(
if (showRequests) {
UserSwitcherAction(
label = "Requests",
icon = Icons.Default.PlaylistAdd,
icon = MembyIcon.PlaylistAdd.mark,
modifier = Modifier
.focusRequester(focusRequesters[profiles.size + 1])
.onFocusChanged {
@@ -657,7 +628,7 @@ fun UserSwitcherOverlay(
val settingsIndex = profiles.size + if (showRequests) 2 else 1
UserSwitcherAction(
label = "Settings",
icon = Icons.Default.Settings,
icon = MembyIcon.Settings.mark,
modifier = Modifier
.focusRequester(focusRequesters[settingsIndex])
.onFocusChanged {
@@ -668,7 +639,7 @@ fun UserSwitcherOverlay(
val manageIndex = profiles.size + actionCount - 1
UserSwitcherAction(
label = "Manage users",
icon = Icons.Default.Person,
icon = MembyIcon.Person.mark,
modifier = Modifier
.focusRequester(focusRequesters[manageIndex])
.onFocusChanged {
@@ -743,7 +714,7 @@ private fun UserSwitcherProfileItem(
)
if (current) {
Icon(
Icons.Default.CheckCircle,
MembyIcon.CheckCircle.mark,
contentDescription = null,
tint = EmbyGreen,
modifier = Modifier.size(14.dp),
@@ -876,7 +847,7 @@ fun ExpandableNavigationItem(
)
}
} else {
Icon(destination.icon, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
Icon(destination.icon.mark, contentDescription = null, tint = foreground.value, modifier = Modifier.size(21.dp))
}
if (selected) {
Box(
@@ -1158,7 +1129,7 @@ fun MediaQuickActionsOverlay(
)
QuickActionMenuItem(
label = "View details",
icon = Icons.Default.Info,
icon = MembyIcon.Info.mark,
modifier = Modifier
.focusRequester(focusRequesters[0])
.onFocusChanged { if (it.isFocused) focusedIndex = 0 },
@@ -1167,7 +1138,7 @@ fun MediaQuickActionsOverlay(
Spacer(Modifier.height(2.dp))
QuickActionMenuItem(
label = if (item.isFavorite) "Remove from favourites" else "Add to favourites",
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
modifier = Modifier
.focusRequester(focusRequesters[1])
.onFocusChanged { if (it.isFocused) focusedIndex = 1 },
@@ -1179,7 +1150,7 @@ fun MediaQuickActionsOverlay(
Spacer(Modifier.height(2.dp))
QuickActionMenuItem(
label = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
icon = Icons.Default.CheckCircle,
icon = MembyIcon.CheckCircle.mark,
modifier = Modifier
.focusRequester(focusRequesters[2])
.onFocusChanged { if (it.isFocused) focusedIndex = 2 },
@@ -1192,7 +1163,7 @@ fun MediaQuickActionsOverlay(
Spacer(Modifier.height(2.dp))
QuickActionMenuItem(
label = "Remove from Continue Watching",
icon = Icons.Default.PlaylistRemove,
icon = MembyIcon.PlaylistRemove.mark,
modifier = Modifier
.focusRequester(focusRequesters[3])
.onFocusChanged { if (it.isFocused) focusedIndex = 3 },
@@ -1217,7 +1188,7 @@ fun MediaQuickActionsOverlay(
)
QuickActionMenuItem(
label = if (rowPinned) "Unpin row" else "Pin row to top",
icon = Icons.Default.PushPin,
icon = MembyIcon.Pin.mark,
modifier = Modifier
.focusRequester(focusRequesters[rowActionStartIndex])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex },
@@ -1225,7 +1196,7 @@ fun MediaQuickActionsOverlay(
)
QuickActionMenuItem(
label = "Move row up",
icon = Icons.Default.ArrowUpward,
icon = MembyIcon.ArrowUp.mark,
modifier = Modifier
.focusRequester(focusRequesters[rowActionStartIndex + 1])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 1 },
@@ -1233,7 +1204,7 @@ fun MediaQuickActionsOverlay(
)
QuickActionMenuItem(
label = "Move row down",
icon = Icons.Default.ArrowDownward,
icon = MembyIcon.ArrowDown.mark,
modifier = Modifier
.focusRequester(focusRequesters[rowActionStartIndex + 2])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 2 },
@@ -1241,7 +1212,7 @@ fun MediaQuickActionsOverlay(
)
QuickActionMenuItem(
label = "Hide this row",
icon = Icons.Default.VisibilityOff,
icon = MembyIcon.HideWatched.mark,
modifier = Modifier
.focusRequester(focusRequesters[rowActionStartIndex + 3])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 3 },
@@ -1258,7 +1229,7 @@ fun MediaQuickActionsOverlay(
Spacer(Modifier.height(6.dp))
QuickActionMenuItem(
label = "Close",
icon = Icons.Default.ChevronLeft,
icon = MembyIcon.ChevronLeft.mark,
modifier = Modifier
.focusRequester(focusRequesters[actionCount - 1])
.onFocusChanged { if (it.isFocused) focusedIndex = actionCount - 1 },
@@ -1487,11 +1458,11 @@ private fun MetadataStatus(item: BaseItem) {
Text("${(progress * 100).toInt()}% watched", color = MutedText, fontSize = 13.sp)
}
if (item.userData?.played == true) {
Icon(Icons.Default.CheckCircle, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp))
Icon(MembyIcon.CheckCircle.mark, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp))
Text("Watched", color = MutedText, fontSize = 13.sp)
}
if (item.isFavorite) {
Icon(Icons.Default.Favorite, contentDescription = "Favourite", tint = EmbyGreen, modifier = Modifier.size(17.dp))
Icon(MembyIcon.Favourite.mark, contentDescription = "Favourite", tint = EmbyGreen, modifier = Modifier.size(17.dp))
Text("Favourite", color = MutedText, fontSize = 13.sp)
}
}
@@ -1542,8 +1513,8 @@ internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier)
*/
internal fun mediaTypeMark(item: BaseItem): Pair<ImageVector, String>? = when {
item.isSeries || item.isEpisode || item.type.equals("Season", ignoreCase = true) ->
Icons.Default.Tv to "TV show"
item.type.equals("Movie", ignoreCase = true) -> Icons.Default.Movie to "Film"
MembyIcon.Tv.mark to "TV show"
item.type.equals("Movie", ignoreCase = true) -> MembyIcon.Movie.mark to "Film"
else -> null
}
@@ -1817,7 +1788,7 @@ private fun FavoriteShowsEmptyState(
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.FavoriteBorder,
imageVector = MembyIcon.FavouriteOutline.mark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(25.dp),
@@ -1872,7 +1843,7 @@ private fun GalleryJumpButton(
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (forward) Icons.Default.ChevronRight else Icons.Default.ChevronLeft,
imageVector = if (forward) MembyIcon.ChevronRight.mark else MembyIcon.ChevronLeft.mark,
contentDescription = null,
tint = if (enabled) Color.White else QuietText.copy(alpha = 0.45f),
modifier = Modifier.size(23.dp),
@@ -2125,7 +2096,7 @@ private fun MediaCard(
}
if (imageUrl == null || failed) {
Icon(
Icons.Default.BrokenImage,
MembyIcon.BrokenImage.mark,
contentDescription = "Artwork unavailable",
tint = QuietText,
modifier = Modifier.size(30.dp),
@@ -2146,14 +2117,14 @@ private fun MediaCard(
) {
if (item.userData?.played == true) {
MediaStatusIcon(
icon = Icons.Default.CheckCircle,
icon = MembyIcon.CheckCircle.mark,
description = "Watched",
tint = EmbyGreen,
)
}
if (item.isFavorite) {
MediaStatusIcon(
icon = Icons.Default.Favorite,
icon = MembyIcon.Favourite.mark,
description = "Favourite",
tint = Color(0xFFFF6B81),
)
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import android.content.Intent
import android.os.Build
import android.os.Bundle
@@ -117,11 +119,6 @@ import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.NightsStay
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.WbSunny
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
@@ -4111,7 +4108,7 @@ private fun RecentSearchesRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HomeRowHeaderIcon(Icons.Default.Search)
HomeRowHeaderIcon(MembyIcon.Search.mark)
Spacer(Modifier.width(HomeRowHeaderIconGap))
Text(
"Recent searches",
@@ -4212,9 +4209,9 @@ private fun HomeClock(
) {
Icon(
imageVector = when (period) {
HomeGreetingPeriod.MORNING -> Icons.Default.LightMode
HomeGreetingPeriod.AFTERNOON -> Icons.Default.WbSunny
HomeGreetingPeriod.EVENING -> Icons.Default.NightsStay
HomeGreetingPeriod.MORNING -> MembyIcon.Sunrise.mark
HomeGreetingPeriod.AFTERNOON -> MembyIcon.Sun.mark
HomeGreetingPeriod.EVENING -> MembyIcon.Night.mark
},
contentDescription = null,
tint = MembyAccent,
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
@@ -27,8 +29,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -288,7 +288,7 @@ private fun PulsingEmblem(pulse: Float, gearRotation: Float) {
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Build,
imageVector = MembyIcon.Build.mark,
contentDescription = null,
tint = MaintenanceAccent,
modifier = Modifier
@@ -1,12 +1,8 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.FirstPage
import androidx.compose.material.icons.filled.Movie
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -257,7 +253,7 @@ internal fun MediaDetailContent(
DetailHeroAction(
// A heart, not a tick: "Mark watched" two buttons along is a tick as
// well. The heart is what a home card and the screensaver already use.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
active = item.isFavorite,
onClick = {
@@ -268,11 +264,11 @@ internal fun MediaDetailContent(
),
)
trailer?.let {
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) }))
add(DetailHeroAction(MembyIcon.Movie.mark, "Play trailer", onClick = { onPlayTrailer(item) }))
}
add(
DetailHeroAction(
icon = Icons.Default.DoneAll,
icon = MembyIcon.CheckAll.mark,
description = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
active = item.userData?.played == true,
onClick = { onTogglePlayed(item, item.userData?.played != true) },
@@ -283,7 +279,7 @@ internal fun MediaDetailContent(
franchise?.takeIf { it.firstMovie.id != item.id }?.let { start ->
add(
DetailHeroAction(
icon = Icons.Default.FirstPage,
icon = MembyIcon.FirstPage.mark,
description = "Open the first ${start.name} movie, ${start.firstMovie.name}",
label = "Start with ${start.firstMovie.name}",
onClick = { onOpenItem(start.firstMovie) },
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -13,9 +15,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -64,7 +63,7 @@ internal fun MembyPlayButton(
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "play-focus")
PrimaryActionSurface(
label = label,
icon = Icons.Default.PlayArrow,
icon = MembyIcon.Play.mark,
focused = focused,
compact = compact,
modifier = modifier
@@ -83,7 +82,7 @@ internal fun MembyPlayChip(
) {
PrimaryActionSurface(
label = label,
icon = Icons.Default.PlayArrow,
icon = MembyIcon.Play.mark,
focused = focused,
compact = compact,
modifier = modifier,
@@ -108,7 +107,7 @@ internal fun MembyArtworkPlayCue(modifier: Modifier = Modifier) {
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlayArrow,
MembyIcon.Play.mark,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
@@ -186,7 +185,7 @@ internal fun MembyChoiceChip(
) {
if (selected) {
Icon(
Icons.Default.Check,
MembyIcon.Check.mark,
contentDescription = null,
tint = if (focused) Color.Black else Color.White,
modifier = Modifier.size(15.dp),
@@ -2,6 +2,8 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -19,8 +21,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -77,7 +77,7 @@ internal fun MyShowsStrip(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HomeRowHeaderIcon(Icons.Default.Bookmark)
HomeRowHeaderIcon(MembyIcon.Bookmark.mark)
Spacer(Modifier.width(HomeRowHeaderIconGap))
Text(
"My Shows",
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -26,13 +28,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Movie
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -377,7 +372,7 @@ internal fun SeriesDetailContent(
// two adjacent circles that both read as "add this" say nothing about
// which list is which. The heart is already what a home card, the
// screensaver and the Favourites row header use for this.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
icon = if (item.isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
active = item.isFavorite,
onClick = {
@@ -392,14 +387,14 @@ internal fun SeriesDetailContent(
// Filled/outline says on-or-off for both toggles, so the two differ by
// silhouette alone — heart against bookmark. The +/✓ variants said it
// a second way and borrowed the glyphs the other buttons use.
icon = if (isMyShow) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
icon = if (isMyShow) MembyIcon.Bookmark.mark else MembyIcon.BookmarkOutline.mark,
description = if (isMyShow) "Remove from My Shows" else "Add to My Shows",
active = isMyShow,
onClick = { onToggleMyShow(item, !isMyShow) },
))
}
trailer?.let {
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlayTrailer(item) }))
add(DetailHeroAction(MembyIcon.Movie.mark, "Play trailer", onClick = { onPlayTrailer(item) }))
}
},
) { visibleTab ->
@@ -681,7 +676,7 @@ internal fun EpisodeCard(
}
if (episode.isPlayed) {
Icon(
Icons.Default.CheckCircle,
MembyIcon.CheckCircle.mark,
contentDescription = "Watched",
tint = DetailAccent,
modifier = Modifier
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import android.os.Build
import android.content.Context
import androidx.activity.compose.BackHandler
@@ -29,8 +31,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -283,7 +283,7 @@ fun UpdateScreen(
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.ArrowDownward,
imageVector = MembyIcon.ArrowDown.mark,
contentDescription = null,
tint = UpdateAccent,
modifier = Modifier.size(40.dp),
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui.alerts
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import android.provider.Settings as AndroidSettings
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -29,11 +31,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.NotificationsNone
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -332,7 +329,7 @@ private fun AlertsEmptyMark(listening: Boolean) {
drawAlertsEmptyMark(if (animate) progress.value else null, mark)
}
Icon(
if (listening) Icons.Default.NotificationsNone else Icons.Default.NotificationsOff,
if (listening) MembyIcon.NotificationNone.mark else MembyIcon.NotificationOff.mark,
contentDescription = null,
tint = mark,
modifier = Modifier
@@ -498,8 +495,8 @@ private fun AlertRow(
}
private fun alertIcon(kind: String): ImageVector = when {
kind.contains("return", ignoreCase = true) -> Icons.Default.Tv
kind.contains("series", ignoreCase = true) -> Icons.Default.Tv
kind.startsWith("show-", ignoreCase = true) -> Icons.Default.Tv
else -> Icons.Default.NotificationsActive
kind.contains("return", ignoreCase = true) -> MembyIcon.Tv.mark
kind.contains("series", ignoreCase = true) -> MembyIcon.Tv.mark
kind.startsWith("show-", ignoreCase = true) -> MembyIcon.Tv.mark
else -> MembyIcon.NotificationActive.mark
}
@@ -2,6 +2,8 @@
package com.ponzischeme89.memby.ui.calendar
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -25,11 +27,6 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -232,7 +229,7 @@ private fun AgendaHeader(
Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.CalendarMonth, null, tint = MembyAccent, modifier = Modifier.size(21.dp))
Icon(MembyIcon.Calendar.mark, null, tint = MembyAccent, modifier = Modifier.size(21.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
@@ -246,7 +243,7 @@ private fun AgendaHeader(
modifier = Modifier.padding(end = 14.dp),
)
AgendaIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
icon = MembyIcon.ArrowBack.mark,
description = "Previous month",
enabled = previousMonth.isNotEmpty(),
onClick = { onShowMonth(previousMonth) },
@@ -262,7 +259,7 @@ private fun AgendaHeader(
)
Spacer(Modifier.width(8.dp))
AgendaIconButton(
icon = Icons.AutoMirrored.Filled.ArrowForward,
icon = MembyIcon.ArrowForward.mark,
description = "Next month",
enabled = nextMonth.isNotEmpty(),
onClick = { onShowMonth(nextMonth) },
@@ -303,7 +300,7 @@ private fun WeekSwitcher(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
AgendaIconButton(
Icons.AutoMirrored.Filled.ArrowBack,
MembyIcon.ArrowBack.mark,
"Previous week",
weekIndex > 0,
onPrevious,
@@ -326,7 +323,7 @@ private fun WeekSwitcher(
.padding(horizontal = 18.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Event, null, tint = if (focused) MembySurface else MembyAccent, modifier = Modifier.size(16.dp))
Icon(MembyIcon.Event.mark, null, tint = if (focused) MembySurface else MembyAccent, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text(
week.label,
@@ -344,7 +341,7 @@ private fun WeekSwitcher(
}
Spacer(Modifier.width(10.dp))
AgendaIconButton(
Icons.AutoMirrored.Filled.ArrowForward,
MembyIcon.ArrowForward.mark,
"Next week",
weekIndex < weekCount - 1,
onNext,
@@ -498,7 +495,7 @@ private fun ProgrammePane(
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Event, null, tint = MembyQuietText, modifier = Modifier.size(30.dp))
Icon(MembyIcon.Event.mark, null, tint = MembyQuietText, modifier = Modifier.size(30.dp))
Spacer(Modifier.height(8.dp))
Text("Nothing airing", color = MembyMutedText, fontSize = 15.sp)
Text("Choose another day or week", color = MembyQuietText, fontSize = 12.sp)
@@ -2,6 +2,8 @@
package com.ponzischeme89.memby.ui.genre
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -34,23 +36,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Landscape
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.LocalFireDepartment
import androidx.compose.material.icons.filled.MilitaryTech
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.SportsSoccer
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -188,23 +173,23 @@ private fun GenreDiscoveryCard(
private data class GenreVisual(val icon: ImageVector, val colour: Color)
private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) {
GenreCategoryIcon.ALL -> GenreVisual(Icons.Default.Category, Color(0xFF4F46A5))
GenreCategoryIcon.ACTION -> GenreVisual(Icons.Default.Bolt, Color(0xFFB45309))
GenreCategoryIcon.COMEDY -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF15803D))
GenreCategoryIcon.CRIME -> GenreVisual(Icons.Default.Gavel, Color(0xFF475569))
GenreCategoryIcon.DRAMA -> GenreVisual(Icons.Default.TheaterComedy, Color(0xFF7E22CE))
GenreCategoryIcon.HORROR -> GenreVisual(Icons.Default.LocalFireDepartment, Color(0xFF991B1B))
GenreCategoryIcon.MYSTERY -> GenreVisual(Icons.AutoMirrored.Filled.HelpOutline, Color(0xFF4338CA))
GenreCategoryIcon.SCI_FI -> GenreVisual(Icons.Default.AutoAwesome, Color(0xFF0369A1))
GenreCategoryIcon.THRILLER -> GenreVisual(Icons.Default.VisibilityOff, Color(0xFF0F766E))
GenreCategoryIcon.WAR -> GenreVisual(Icons.Default.MilitaryTech, Color(0xFF57534E))
GenreCategoryIcon.FAMILY -> GenreVisual(Icons.Default.SentimentVerySatisfied, Color(0xFFDB2777))
GenreCategoryIcon.DOCUMENTARY -> GenreVisual(Icons.Default.VideoLibrary, Color(0xFF0E7490))
GenreCategoryIcon.ROMANCE -> GenreVisual(Icons.Default.Favorite, Color(0xFFBE185D))
GenreCategoryIcon.WESTERN -> GenreVisual(Icons.Default.Landscape, Color(0xFF92400E))
GenreCategoryIcon.MUSIC -> GenreVisual(Icons.Default.MusicNote, Color(0xFF6D28D9))
GenreCategoryIcon.SPORT -> GenreVisual(Icons.Default.SportsSoccer, Color(0xFF047857))
GenreCategoryIcon.REALITY -> GenreVisual(Icons.Default.LiveTv, Color(0xFFC2410C))
GenreCategoryIcon.ALL -> GenreVisual(MembyIcon.Category.mark, Color(0xFF4F46A5))
GenreCategoryIcon.ACTION -> GenreVisual(MembyIcon.Bolt.mark, Color(0xFFB45309))
GenreCategoryIcon.COMEDY -> GenreVisual(MembyIcon.Drama.mark, Color(0xFF15803D))
GenreCategoryIcon.CRIME -> GenreVisual(MembyIcon.Gavel.mark, Color(0xFF475569))
GenreCategoryIcon.DRAMA -> GenreVisual(MembyIcon.Drama.mark, Color(0xFF7E22CE))
GenreCategoryIcon.HORROR -> GenreVisual(MembyIcon.Fire.mark, Color(0xFF991B1B))
GenreCategoryIcon.MYSTERY -> GenreVisual(MembyIcon.Help.mark, Color(0xFF4338CA))
GenreCategoryIcon.SCI_FI -> GenreVisual(MembyIcon.Sparkle.mark, Color(0xFF0369A1))
GenreCategoryIcon.THRILLER -> GenreVisual(MembyIcon.HideWatched.mark, Color(0xFF0F766E))
GenreCategoryIcon.WAR -> GenreVisual(MembyIcon.Trophy.mark, Color(0xFF57534E))
GenreCategoryIcon.FAMILY -> GenreVisual(MembyIcon.Happy.mark, Color(0xFFDB2777))
GenreCategoryIcon.DOCUMENTARY -> GenreVisual(MembyIcon.VideoLibrary.mark, Color(0xFF0E7490))
GenreCategoryIcon.ROMANCE -> GenreVisual(MembyIcon.Favourite.mark, Color(0xFFBE185D))
GenreCategoryIcon.WESTERN -> GenreVisual(MembyIcon.Landscape.mark, Color(0xFF92400E))
GenreCategoryIcon.MUSIC -> GenreVisual(MembyIcon.Music.mark, Color(0xFF6D28D9))
GenreCategoryIcon.SPORT -> GenreVisual(MembyIcon.Football.mark, Color(0xFF047857))
GenreCategoryIcon.REALITY -> GenreVisual(MembyIcon.LiveTv.mark, Color(0xFFC2410C))
}
/**
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui.requests
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -16,13 +18,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -188,20 +183,20 @@ internal fun RequestCard(
)
}
if (busy) {
RequestTrailing(icon = Icons.Default.Schedule, label = "Asking", focused = focused)
RequestTrailing(icon = MembyIcon.Schedule.mark, label = "Asking", focused = focused)
} else if (action != null) {
RequestTrailing(icon = action.icon, label = action.label, focused = focused)
RequestTrailing(icon = action.icon.mark, label = action.label, focused = focused)
}
}
}
}
/** The trailing affordance on a card: what the centre button would do. */
internal data class RequestCardAction(val icon: ImageVector, val label: String)
internal data class RequestCardAction(val icon: MembyIcon, val label: String)
internal val RequestActionRequest = RequestCardAction(Icons.Default.Add, "Request")
internal val RequestActionPlay = RequestCardAction(Icons.Default.PlayArrow, "Watch")
internal val RequestActionRemove = RequestCardAction(Icons.Default.CheckCircle, "Remove")
internal val RequestActionRequest = RequestCardAction(MembyIcon.Add, "Request")
internal val RequestActionPlay = RequestCardAction(MembyIcon.Play, "Watch")
internal val RequestActionRemove = RequestCardAction(MembyIcon.CheckCircle, "Remove")
@Composable
private fun RequestTrailing(icon: ImageVector, label: String, focused: Boolean) {
@@ -287,7 +282,7 @@ internal fun requestToneColour(status: String): Color = when (requestStatusTone(
*/
@Composable
private fun MediaTypeGlyph(mediaType: String, modifier: Modifier = Modifier) {
val icon = if (mediaType == "series") Icons.Default.Tv else Icons.Default.Movie
val icon = if (mediaType == "series") MembyIcon.Tv.mark else MembyIcon.Movie.mark
Box(
modifier
.size(24.dp)
@@ -2,6 +2,8 @@
package com.ponzischeme89.memby.ui.requests
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -24,10 +26,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -171,7 +169,7 @@ fun RequestsScreen(
// Permission withdrawn while the page was open. Said plainly, because an
// empty list would read as "you have never asked for anything".
!state.allowed -> RequestsNotice(
icon = Icons.Default.Inbox,
icon = MembyIcon.Inbox.mark,
heading = "Requests are not available",
body = "This profile is no longer allowed to request titles. Ask whoever looks after Memby.",
)
@@ -227,7 +225,7 @@ private fun RequestsHeader(state: RequestsUiState) {
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PlaylistAdd, null,
MembyIcon.PlaylistAdd.mark, null,
tint = MembyAccent, modifier = Modifier.size(21.dp),
)
}
@@ -321,7 +319,7 @@ private fun RequestsTabStrip(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (tab == RequestsTab.MINE) Icons.Default.Inbox else Icons.Default.Search,
if (tab == RequestsTab.MINE) MembyIcon.Inbox.mark else MembyIcon.Search.mark,
null,
tint = if (focused) MembySurface else MembyAccent,
modifier = Modifier.size(15.dp),
@@ -361,7 +359,7 @@ private fun MyRequestsPane(
}
if (state.requestsError != null && state.requests.isEmpty()) {
RequestsNotice(
icon = Icons.Default.Inbox,
icon = MembyIcon.Inbox.mark,
heading = "Could not load your requests",
body = state.requestsError,
action = "Try again",
@@ -375,7 +373,7 @@ private fun MyRequestsPane(
}
if (state.requests.isEmpty()) {
RequestsNotice(
icon = Icons.Default.Inbox,
icon = MembyIcon.Inbox.mark,
heading = "You have not asked for anything yet",
body = "Find a film or series and it will show up here while it is added to your library.",
action = "Request something",
@@ -762,7 +760,7 @@ private fun PaneMessage(heading: String, body: String) {
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 40.dp),
) {
Icon(Icons.Default.Search, null, tint = MembyQuietText, modifier = Modifier.size(30.dp))
Icon(MembyIcon.Search.mark, null, tint = MembyQuietText, modifier = Modifier.size(30.dp))
Spacer(Modifier.height(8.dp))
Text(heading, color = MembyMutedText, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp))
@@ -1,5 +1,7 @@
package com.ponzischeme89.memby.ui.screensaver
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
@@ -22,19 +24,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Business
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -838,12 +827,12 @@ private fun InfoAndActions(
verticalAlignment = Alignment.CenterVertically,
) {
Button(onClick = onPlay, modifier = Modifier.focusRequester(playFocus)) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Icon(MembyIcon.Play.mark, contentDescription = null)
Text(text = " Play trailer", modifier = Modifier.padding(start = 4.dp))
}
Button(onClick = onToggleFavorite) {
Icon(
imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
imageVector = if (isFavorite) MembyIcon.Favourite.mark else MembyIcon.FavouriteOutline.mark,
contentDescription = null,
)
Text(
@@ -852,13 +841,13 @@ private fun InfoAndActions(
)
}
Spacer(Modifier.weight(1f))
Button(onClick = onPrev) { Icon(Icons.Default.ChevronLeft, contentDescription = "Previous") }
Button(onClick = onNext) { Icon(Icons.Default.ChevronRight, contentDescription = "Next") }
Button(onClick = onPrev) { Icon(MembyIcon.ChevronLeft.mark, contentDescription = "Previous") }
Button(onClick = onNext) { Icon(MembyIcon.ChevronRight.mark, contentDescription = "Next") }
Button(onClick = onOpenSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
Icon(MembyIcon.Settings.mark, contentDescription = "Settings")
}
Button(onClick = onExit) {
Icon(Icons.Default.Close, contentDescription = "Exit screensaver")
Icon(MembyIcon.Close.mark, contentDescription = "Exit screensaver")
}
}
}
@@ -933,7 +922,7 @@ private fun MediaMetadata(item: BaseItem) {
val metadataColor = Color(0xFFC7CED4)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(
imageVector = if (item.isSeries) Icons.Default.LiveTv else Icons.Default.Movie,
imageVector = if (item.isSeries) MembyIcon.LiveTv.mark else MembyIcon.Movie.mark,
contentDescription = if (item.isSeries) "Series" else "Movie",
tint = metadataColor,
modifier = Modifier.size(20.dp),
@@ -941,15 +930,15 @@ private fun MediaMetadata(item: BaseItem) {
item.productionYear?.let { MetadataText(it.toString(), metadataColor) }
item.communityRating?.let { MetadataText("${"%.1f".format(it)}", metadataColor) }
item.runtimeMinutes?.let {
Icon(Icons.Default.AccessTime, contentDescription = "Runtime", tint = metadataColor, modifier = Modifier.size(18.dp))
Icon(MembyIcon.Clock.mark, contentDescription = "Runtime", tint = metadataColor, modifier = Modifier.size(18.dp))
MetadataText("$it min", metadataColor)
}
item.studios.firstOrNull { it.name.isNotBlank() }?.name?.let {
Icon(Icons.Default.Business, contentDescription = "Studio", tint = metadataColor, modifier = Modifier.size(18.dp))
Icon(MembyIcon.Studio.mark, contentDescription = "Studio", tint = metadataColor, modifier = Modifier.size(18.dp))
MetadataText(it, metadataColor)
}
item.genres.firstOrNull()?.takeIf { it.isNotBlank() }?.let {
Icon(Icons.Default.LocalOffer, contentDescription = "Genre", tint = metadataColor, modifier = Modifier.size(18.dp))
Icon(MembyIcon.Tag.mark, contentDescription = "Genre", tint = metadataColor, modifier = Modifier.size(18.dp))
MetadataText(it, metadataColor)
}
}
@@ -2,6 +2,8 @@
package com.ponzischeme89.memby.ui.search
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import android.Manifest
import android.app.Activity
import android.content.Intent
@@ -41,21 +43,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.SpaceBar
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -461,7 +448,7 @@ internal fun SearchQueryField(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Search,
MembyIcon.Search.mark,
contentDescription = null,
tint = if (query.isEmpty()) Muted else Accent,
modifier = Modifier.size(18.dp),
@@ -505,7 +492,7 @@ internal fun SearchQueryField(
modifier = micModifier.clip(RoundedCornerShape(8.dp)),
) { focused ->
Icon(
Icons.Default.Mic,
MembyIcon.Mic.mark,
contentDescription = null,
tint = if (focused) KeyLabelFocused else KeyLabel,
modifier = Modifier
@@ -608,7 +595,7 @@ internal fun TvKeyboard(
Spacer(Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ActionKey(
icon = Icons.Default.SpaceBar,
icon = MembyIcon.Space.mark,
label = "Space",
contentDescription = "Insert a space",
onClick = { onCharacter(" ") },
@@ -626,7 +613,7 @@ internal fun TvKeyboard(
),
)
ActionKey(
icon = Icons.AutoMirrored.Filled.Backspace,
icon = MembyIcon.Backspace.mark,
label = "Delete",
contentDescription = "Delete the last character",
onClick = onBackspace,
@@ -643,7 +630,7 @@ internal fun TvKeyboard(
),
)
ActionKey(
icon = Icons.Default.Close,
icon = MembyIcon.Close.mark,
label = "Clear",
contentDescription = "Clear the whole query",
onClick = onClear,
@@ -671,7 +658,7 @@ internal fun TvKeyboard(
if (onSearch != null) {
Spacer(Modifier.height(if (compact) 4.dp else 6.dp))
ActionKey(
icon = Icons.Default.Search,
icon = MembyIcon.Search.mark,
label = "Search",
contentDescription = "Search for what you have typed",
onClick = onSearch,
@@ -961,7 +948,7 @@ private fun ResultsHeading(
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
MembyIcon.ArrowBack.mark,
contentDescription = null,
tint = if (focused) KeyLabelFocused else Heading,
modifier = Modifier.size(21.dp),
@@ -1145,7 +1132,7 @@ private fun RequestOptions(
.background(Accent.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Add, contentDescription = null, tint = Accent, modifier = Modifier.size(24.dp))
Icon(MembyIcon.Add.mark, contentDescription = null, tint = Accent, modifier = Modifier.size(24.dp))
}
Spacer(Modifier.width(13.dp))
Column(Modifier.weight(1f)) {
@@ -1207,7 +1194,7 @@ private fun RequestOptions(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (state.requestMessageIsError) Icons.Default.Close else Icons.Default.CheckCircle,
if (state.requestMessageIsError) MembyIcon.Close.mark else MembyIcon.CheckCircle.mark,
contentDescription = null,
tint = statusColor,
modifier = Modifier.size(18.dp),
@@ -1288,7 +1275,7 @@ private fun RequestCandidateCard(
)
} else {
Icon(
if (mediaLabel == "MOVIE") Icons.Default.Movie else Icons.Default.LiveTv,
if (mediaLabel == "MOVIE") MembyIcon.Movie.mark else MembyIcon.LiveTv.mark,
contentDescription = null,
tint = Muted.copy(alpha = 0.65f),
modifier = Modifier.size(30.dp),
@@ -1323,7 +1310,7 @@ private fun RequestCandidateCard(
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
if (candidate.inLibrary || candidate.alreadyAdded) {
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
Icon(MembyIcon.CheckCircle.mark, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(5.dp))
}
Text(
@@ -1394,12 +1381,12 @@ private val GenreColors = listOf(
)
private fun genreIcon(label: String): ImageVector = when {
label.contains("comedy", true) -> Icons.Default.TheaterComedy
label.contains("music", true) -> Icons.Default.LiveTv
label.contains("children", true) || label.contains("family", true) -> Icons.Default.SentimentVerySatisfied
label.contains("sport", true) -> Icons.Default.PlayCircleFilled
label.contains("document", true) -> Icons.Default.Movie
else -> Icons.Default.AutoAwesome
label.contains("comedy", true) -> MembyIcon.Drama.mark
label.contains("music", true) -> MembyIcon.LiveTv.mark
label.contains("children", true) || label.contains("family", true) -> MembyIcon.Happy.mark
label.contains("sport", true) -> MembyIcon.PlayCircle.mark
label.contains("document", true) -> MembyIcon.Movie.mark
else -> MembyIcon.Sparkle.mark
}
@Composable
@@ -8,6 +8,8 @@
package com.ponzischeme89.memby.ui.settings
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@@ -38,13 +40,6 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Storage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -187,20 +182,20 @@ private const val THEME_PICKER_ENABLED = false
internal enum class SettingsPage(
val label: String,
val description: String,
val icon: ImageVector,
val icon: MembyIcon,
val showInRail: Boolean = true,
) {
APPEARANCE("Appearance", "How Memby looks", Icons.Default.Palette),
PLAYBACK("Playback", "What happens while you watch", Icons.Default.PlayArrow),
HOME("Home screen", "What you see when Memby opens", Icons.Default.Home),
APPEARANCE("Appearance", "How Memby looks", MembyIcon.Palette),
PLAYBACK("Playback", "What happens while you watch", MembyIcon.Play),
HOME("Home screen", "What you see when Memby opens", MembyIcon.Home),
// No Updates page. The manual check needs a Gitea address, a repository and a token,
// and nothing on this television can enter them — so the button could only ever report
// a failure, on the one screen a viewer goes to when they suspect something is wrong.
// Updates arrive through the gateway's own verdict (ui/UpdateScreen.kt), which carries
// its download URL with it.
DEVICES("Devices", "TVs signed in to your account", Icons.Default.Devices),
STORAGE("Storage", "Artwork Memby keeps on this TV", Icons.Default.Storage),
ABOUT("About", "Version and release notes", Icons.Default.Info),
DEVICES("Devices", "TVs signed in to your account", MembyIcon.Devices),
STORAGE("Storage", "Artwork Memby keeps on this TV", MembyIcon.Storage),
ABOUT("About", "Version and release notes", MembyIcon.Info),
}
// Black, and one lit thing at a time.
@@ -1518,7 +1513,7 @@ private fun SettingsSecondaryRail(
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
page.icon,
page.icon.mark,
contentDescription = null,
tint = when {
focused -> Canvas
@@ -0,0 +1,144 @@
package com.ponzischeme89.memby.ui.theme
import com.composables.icons.fontawesome.FontAwesome
import com.composables.icons.fontawesome.solid.AngleDoubleLeft
import com.composables.icons.fontawesome.solid.ArrowDown
import com.composables.icons.fontawesome.solid.ArrowLeft
import com.composables.icons.fontawesome.solid.ArrowRight
import com.composables.icons.fontawesome.solid.ArrowUp
import com.composables.icons.fontawesome.solid.Backspace
import com.composables.icons.fontawesome.solid.Bell
import com.composables.icons.fontawesome.solid.BellSlash
import com.composables.icons.fontawesome.solid.Bolt
import com.composables.icons.fontawesome.solid.Bookmark
import com.composables.icons.fontawesome.solid.BroadcastTower
import com.composables.icons.fontawesome.solid.Building
import com.composables.icons.fontawesome.solid.CalendarAlt
import com.composables.icons.fontawesome.solid.CalendarDay
import com.composables.icons.fontawesome.solid.Check
import com.composables.icons.fontawesome.solid.CheckCircle
import com.composables.icons.fontawesome.solid.CheckDouble
import com.composables.icons.fontawesome.solid.ChevronDown
import com.composables.icons.fontawesome.solid.ChevronLeft
import com.composables.icons.fontawesome.solid.ChevronRight
import com.composables.icons.fontawesome.solid.Clock
import com.composables.icons.fontawesome.solid.Cog
import com.composables.icons.fontawesome.solid.Desktop
import com.composables.icons.fontawesome.solid.EyeSlash
import com.composables.icons.fontawesome.solid.Film
import com.composables.icons.fontawesome.solid.Fire
import com.composables.icons.fontawesome.solid.Futbol
import com.composables.icons.fontawesome.solid.Gavel
import com.composables.icons.fontawesome.solid.Hdd
import com.composables.icons.fontawesome.solid.Heart
import com.composables.icons.fontawesome.solid.Home
import com.composables.icons.fontawesome.solid.Image
import com.composables.icons.fontawesome.solid.Inbox
import com.composables.icons.fontawesome.solid.InfoCircle
import com.composables.icons.fontawesome.solid.Magic
import com.composables.icons.fontawesome.solid.Medal
import com.composables.icons.fontawesome.solid.Microphone
import com.composables.icons.fontawesome.solid.MinusCircle
import com.composables.icons.fontawesome.solid.Moon
import com.composables.icons.fontawesome.solid.Mountain
import com.composables.icons.fontawesome.solid.Music
import com.composables.icons.fontawesome.solid.Palette
import com.composables.icons.fontawesome.solid.PhotoVideo
import com.composables.icons.fontawesome.solid.Play
import com.composables.icons.fontawesome.solid.PlayCircle
import com.composables.icons.fontawesome.solid.Plus
import com.composables.icons.fontawesome.solid.PlusCircle
import com.composables.icons.fontawesome.solid.PowerOff
import com.composables.icons.fontawesome.solid.QuestionCircle
import com.composables.icons.fontawesome.solid.Search
import com.composables.icons.fontawesome.solid.Shapes
import com.composables.icons.fontawesome.solid.Smile
import com.composables.icons.fontawesome.solid.Sun
import com.composables.icons.fontawesome.solid.Tag
import com.composables.icons.fontawesome.solid.ThLarge
import com.composables.icons.fontawesome.solid.TheaterMasks
import com.composables.icons.fontawesome.solid.ThumbsUp
import com.composables.icons.fontawesome.solid.Thumbtack
import com.composables.icons.fontawesome.solid.Times
import com.composables.icons.fontawesome.solid.Tv
import com.composables.icons.fontawesome.solid.User
import com.composables.icons.fontawesome.solid.Wrench
/**
* Font Awesome Solid filled throughout, and the pack to reach for on a set watched from
* the sofa. Stroke sets are drawn for 1624px on a monitor; at three metres on a 38dp rail
* chip they read thin and washed, where a solid mark keeps its shape.
*
* The outline halves are the ones missing here, the mirror image of Lucide's omission and
* for the same reason.
*/
internal val fontAwesomeIconPack = MembyIconPack(
MEMBY_ICON_PACK_FONT_AWESOME,
mapOf(
MembyIcon.Home to { FontAwesome.Solid.Home },
MembyIcon.Search to { FontAwesome.Solid.Search },
MembyIcon.Grid to { FontAwesome.Solid.ThLarge },
MembyIcon.Movie to { FontAwesome.Solid.Film },
MembyIcon.Tv to { FontAwesome.Solid.Tv },
MembyIcon.LiveTv to { FontAwesome.Solid.BroadcastTower },
MembyIcon.VideoLibrary to { FontAwesome.Solid.PhotoVideo },
MembyIcon.Person to { FontAwesome.Solid.User },
MembyIcon.Settings to { FontAwesome.Solid.Cog },
MembyIcon.Calendar to { FontAwesome.Solid.CalendarAlt },
MembyIcon.Event to { FontAwesome.Solid.CalendarDay },
MembyIcon.Play to { FontAwesome.Solid.Play },
MembyIcon.PlayCircle to { FontAwesome.Solid.PlayCircle },
MembyIcon.Favourite to { FontAwesome.Solid.Heart },
MembyIcon.Bookmark to { FontAwesome.Solid.Bookmark },
MembyIcon.PlaylistAdd to { FontAwesome.Solid.PlusCircle },
MembyIcon.PlaylistRemove to { FontAwesome.Solid.MinusCircle },
MembyIcon.HideWatched to { FontAwesome.Solid.EyeSlash },
MembyIcon.Check to { FontAwesome.Solid.Check },
MembyIcon.CheckCircle to { FontAwesome.Solid.CheckCircle },
MembyIcon.CheckAll to { FontAwesome.Solid.CheckDouble },
MembyIcon.Add to { FontAwesome.Solid.Plus },
MembyIcon.Close to { FontAwesome.Solid.Times },
MembyIcon.ChevronLeft to { FontAwesome.Solid.ChevronLeft },
MembyIcon.ChevronRight to { FontAwesome.Solid.ChevronRight },
MembyIcon.ChevronDown to { FontAwesome.Solid.ChevronDown },
MembyIcon.ArrowBack to { FontAwesome.Solid.ArrowLeft },
MembyIcon.ArrowForward to { FontAwesome.Solid.ArrowRight },
MembyIcon.ArrowUp to { FontAwesome.Solid.ArrowUp },
MembyIcon.ArrowDown to { FontAwesome.Solid.ArrowDown },
MembyIcon.FirstPage to { FontAwesome.Solid.AngleDoubleLeft },
MembyIcon.Backspace to { FontAwesome.Solid.Backspace },
MembyIcon.Sparkle to { FontAwesome.Solid.Magic },
MembyIcon.Recommend to { FontAwesome.Solid.ThumbsUp },
MembyIcon.Drama to { FontAwesome.Solid.TheaterMasks },
MembyIcon.Happy to { FontAwesome.Solid.Smile },
MembyIcon.Music to { FontAwesome.Solid.Music },
MembyIcon.Football to { FontAwesome.Solid.Futbol },
MembyIcon.Trophy to { FontAwesome.Solid.Medal },
MembyIcon.Fire to { FontAwesome.Solid.Fire },
MembyIcon.Tag to { FontAwesome.Solid.Tag },
MembyIcon.Category to { FontAwesome.Solid.Shapes },
MembyIcon.Studio to { FontAwesome.Solid.Building },
MembyIcon.Landscape to { FontAwesome.Solid.Mountain },
MembyIcon.Palette to { FontAwesome.Solid.Palette },
MembyIcon.Notification to { FontAwesome.Solid.Bell },
MembyIcon.NotificationActive to { FontAwesome.Solid.Bell },
MembyIcon.NotificationOff to { FontAwesome.Solid.BellSlash },
MembyIcon.Inbox to { FontAwesome.Solid.Inbox },
MembyIcon.Pin to { FontAwesome.Solid.Thumbtack },
MembyIcon.Bolt to { FontAwesome.Solid.Bolt },
MembyIcon.Clock to { FontAwesome.Solid.Clock },
MembyIcon.Schedule to { FontAwesome.Solid.Clock },
MembyIcon.Sun to { FontAwesome.Solid.Sun },
MembyIcon.Sunrise to { FontAwesome.Solid.Sun },
MembyIcon.Night to { FontAwesome.Solid.Moon },
MembyIcon.Info to { FontAwesome.Solid.InfoCircle },
MembyIcon.Help to { FontAwesome.Solid.QuestionCircle },
MembyIcon.BrokenImage to { FontAwesome.Solid.Image },
MembyIcon.Devices to { FontAwesome.Solid.Desktop },
MembyIcon.Storage to { FontAwesome.Solid.Hdd },
MembyIcon.Build to { FontAwesome.Solid.Wrench },
MembyIcon.Power to { FontAwesome.Solid.PowerOff },
MembyIcon.Gavel to { FontAwesome.Solid.Gavel },
MembyIcon.Mic to { FontAwesome.Solid.Microphone },
),
)
@@ -0,0 +1,146 @@
package com.ponzischeme89.memby.ui.theme
import com.composables.icons.lucide.ArrowDown
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.ArrowRight
import com.composables.icons.lucide.ArrowUp
import com.composables.icons.lucide.Zap
import com.composables.icons.lucide.Bell
import com.composables.icons.lucide.BellOff
import com.composables.icons.lucide.BellRing
import com.composables.icons.lucide.Bookmark
import com.composables.icons.lucide.Building2
import com.composables.icons.lucide.CalendarClock
import com.composables.icons.lucide.CalendarDays
import com.composables.icons.lucide.Check
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.ChevronLeft
import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.ChevronsLeft
import com.composables.icons.lucide.CirclePlay
import com.composables.icons.lucide.CircleQuestionMark
import com.composables.icons.lucide.Clock
import com.composables.icons.lucide.Delete
import com.composables.icons.lucide.Drama
import com.composables.icons.lucide.EyeOff
import com.composables.icons.lucide.Film
import com.composables.icons.lucide.Flame
import com.composables.icons.lucide.Gavel
import com.composables.icons.lucide.HardDrive
import com.composables.icons.lucide.Heart
import com.composables.icons.lucide.House
import com.composables.icons.lucide.ImageOff
import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.Info
import com.composables.icons.lucide.LayoutGrid
import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.ListMinus
import com.composables.icons.lucide.ListPlus
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Mic
import com.composables.icons.lucide.MonitorSpeaker
import com.composables.icons.lucide.MoonStar
import com.composables.icons.lucide.MountainSnow
import com.composables.icons.lucide.Music2
import com.composables.icons.lucide.Palette
import com.composables.icons.lucide.Pin
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Power
import com.composables.icons.lucide.Radio
import com.composables.icons.lucide.Search
import com.composables.icons.lucide.Settings
import com.composables.icons.lucide.Shapes
import com.composables.icons.lucide.Smile
import com.composables.icons.lucide.Space
import com.composables.icons.lucide.Sparkles
import com.composables.icons.lucide.Sun
import com.composables.icons.lucide.Sunrise
import com.composables.icons.lucide.Tag
import com.composables.icons.lucide.ThumbsUp
import com.composables.icons.lucide.Trophy
import com.composables.icons.lucide.Tv
import com.composables.icons.lucide.User
import com.composables.icons.lucide.Volleyball
import com.composables.icons.lucide.Wrench
import com.composables.icons.lucide.X
/**
* Lucide one weight, drawn as strokes.
*
* The filled halves of the state-bearing pairs are deliberately **not** mapped. Lucide is a
* stroke set with no filled heart or bookmark, so mapping [MembyIcon.Favourite] to the same
* glyph as [MembyIcon.FavouriteOutline] would make "this is a favourite" and "this is not"
* identical on screen a pack cannot be allowed to cost the app a distinction. They fall
* back to Material's filled marks, which is a small mixture and the honest one.
*/
internal val lucideIconPack = MembyIconPack(
MEMBY_ICON_PACK_LUCIDE,
mapOf(
MembyIcon.Home to { Lucide.House },
MembyIcon.Search to { Lucide.Search },
MembyIcon.Grid to { Lucide.LayoutGrid },
MembyIcon.Movie to { Lucide.Film },
MembyIcon.Tv to { Lucide.Tv },
MembyIcon.LiveTv to { Lucide.Radio },
MembyIcon.VideoLibrary to { Lucide.LibraryBig },
MembyIcon.Person to { Lucide.User },
MembyIcon.Settings to { Lucide.Settings },
MembyIcon.Calendar to { Lucide.CalendarDays },
MembyIcon.Event to { Lucide.CalendarClock },
MembyIcon.Play to { Lucide.Play },
MembyIcon.PlayCircle to { Lucide.CirclePlay },
MembyIcon.FavouriteOutline to { Lucide.Heart },
MembyIcon.BookmarkOutline to { Lucide.Bookmark },
MembyIcon.PlaylistAdd to { Lucide.ListPlus },
MembyIcon.PlaylistRemove to { Lucide.ListMinus },
MembyIcon.HideWatched to { Lucide.EyeOff },
MembyIcon.Check to { Lucide.Check },
MembyIcon.Add to { Lucide.Plus },
MembyIcon.Close to { Lucide.X },
MembyIcon.ChevronLeft to { Lucide.ChevronLeft },
MembyIcon.ChevronRight to { Lucide.ChevronRight },
MembyIcon.ChevronDown to { Lucide.ChevronDown },
MembyIcon.ArrowBack to { Lucide.ArrowLeft },
MembyIcon.ArrowForward to { Lucide.ArrowRight },
MembyIcon.ArrowUp to { Lucide.ArrowUp },
MembyIcon.ArrowDown to { Lucide.ArrowDown },
MembyIcon.FirstPage to { Lucide.ChevronsLeft },
MembyIcon.Backspace to { Lucide.Delete },
MembyIcon.Space to { Lucide.Space },
MembyIcon.Sparkle to { Lucide.Sparkles },
MembyIcon.Recommend to { Lucide.ThumbsUp },
MembyIcon.Drama to { Lucide.Drama },
MembyIcon.Happy to { Lucide.Smile },
MembyIcon.Music to { Lucide.Music2 },
MembyIcon.Football to { Lucide.Volleyball },
MembyIcon.Trophy to { Lucide.Trophy },
MembyIcon.Fire to { Lucide.Flame },
MembyIcon.Tag to { Lucide.Tag },
MembyIcon.Category to { Lucide.Shapes },
MembyIcon.Studio to { Lucide.Building2 },
MembyIcon.Landscape to { Lucide.MountainSnow },
MembyIcon.Palette to { Lucide.Palette },
MembyIcon.Notification to { Lucide.Bell },
MembyIcon.NotificationActive to { Lucide.BellRing },
MembyIcon.NotificationNone to { Lucide.Bell },
MembyIcon.NotificationOff to { Lucide.BellOff },
MembyIcon.Inbox to { Lucide.Inbox },
MembyIcon.Pin to { Lucide.Pin },
MembyIcon.Bolt to { Lucide.Zap },
MembyIcon.Clock to { Lucide.Clock },
MembyIcon.Schedule to { Lucide.Clock },
MembyIcon.Sun to { Lucide.Sun },
MembyIcon.Sunrise to { Lucide.Sunrise },
MembyIcon.Night to { Lucide.MoonStar },
MembyIcon.Info to { Lucide.Info },
MembyIcon.Help to { Lucide.CircleQuestionMark },
MembyIcon.BrokenImage to { Lucide.ImageOff },
MembyIcon.Devices to { Lucide.MonitorSpeaker },
MembyIcon.Storage to { Lucide.HardDrive },
MembyIcon.Build to { Lucide.Wrench },
MembyIcon.Power to { Lucide.Power },
MembyIcon.Gavel to { Lucide.Gavel },
MembyIcon.Mic to { Lucide.Mic },
),
)
@@ -0,0 +1,202 @@
package com.ponzischeme89.memby.ui.theme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Business
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Category
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.FirstPage
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Landscape
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.LocalFireDepartment
import androidx.compose.material.icons.filled.LocalOffer
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MilitaryTech
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.NightsStay
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.NotificationsNone
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.PlaylistRemove
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SpaceBar
import androidx.compose.material.icons.filled.SportsSoccer
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Tv
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.ui.graphics.vector.ImageVector
/**
* The packs a theme may name, and the marks each of them draws.
*
* Adding a pack is one entry in [membyIconPacks] and one map here no call site changes,
* because every screen names a [MembyIcon] and nothing names a pack. That is the property
* the whole feature is for: an operator moves a household onto a different set of marks by
* editing the gateway's theme catalogue, and no television is touched.
*
* Only the ~70 slots named below survive R8 out of packs holding a thousand icons each, so
* this file is also the size bound: a slot costs three vectors, and a slot nothing draws
* costs three vectors for nothing.
*/
object MaterialIconPack {
/**
* Every slot, because this is what a partial pack falls back to. A slot added to
* [MembyIcon] and not to this map would draw nothing at all which is why
* `MembyIconPackTest` asserts the coverage rather than leaving it to a code review.
*/
private val marks: Map<MembyIcon, () -> ImageVector> = mapOf(
MembyIcon.Home to { Icons.Default.Home },
MembyIcon.Search to { Icons.Default.Search },
MembyIcon.Grid to { Icons.Default.GridView },
MembyIcon.Movie to { Icons.Default.Movie },
MembyIcon.Tv to { Icons.Default.Tv },
MembyIcon.LiveTv to { Icons.Default.LiveTv },
MembyIcon.VideoLibrary to { Icons.Default.VideoLibrary },
MembyIcon.Person to { Icons.Default.Person },
MembyIcon.Settings to { Icons.Default.Settings },
MembyIcon.Calendar to { Icons.Default.CalendarMonth },
MembyIcon.Event to { Icons.Default.Event },
MembyIcon.Play to { Icons.Default.PlayArrow },
MembyIcon.PlayCircle to { Icons.Default.PlayCircleFilled },
MembyIcon.Favourite to { Icons.Default.Favorite },
MembyIcon.FavouriteOutline to { Icons.Default.FavoriteBorder },
MembyIcon.Bookmark to { Icons.Default.Bookmark },
MembyIcon.BookmarkOutline to { Icons.Default.BookmarkBorder },
MembyIcon.PlaylistAdd to { Icons.Default.PlaylistAdd },
MembyIcon.PlaylistRemove to { Icons.Default.PlaylistRemove },
MembyIcon.HideWatched to { Icons.Default.VisibilityOff },
MembyIcon.Check to { Icons.Default.Check },
MembyIcon.CheckCircle to { Icons.Default.CheckCircle },
MembyIcon.CheckAll to { Icons.Default.DoneAll },
MembyIcon.Add to { Icons.Default.Add },
MembyIcon.Close to { Icons.Default.Close },
MembyIcon.ChevronLeft to { Icons.Default.ChevronLeft },
MembyIcon.ChevronRight to { Icons.Default.ChevronRight },
MembyIcon.ChevronDown to { Icons.Default.KeyboardArrowDown },
MembyIcon.ArrowBack to { Icons.AutoMirrored.Filled.ArrowBack },
MembyIcon.ArrowForward to { Icons.AutoMirrored.Filled.ArrowForward },
MembyIcon.ArrowUp to { Icons.Default.ArrowUpward },
MembyIcon.ArrowDown to { Icons.Default.ArrowDownward },
MembyIcon.FirstPage to { Icons.Default.FirstPage },
MembyIcon.Backspace to { Icons.AutoMirrored.Filled.Backspace },
MembyIcon.Space to { Icons.Default.SpaceBar },
MembyIcon.Sparkle to { Icons.Default.AutoAwesome },
MembyIcon.Recommend to { Icons.Default.Recommend },
MembyIcon.Drama to { Icons.Default.TheaterComedy },
MembyIcon.Happy to { Icons.Default.SentimentVerySatisfied },
MembyIcon.Music to { Icons.Default.MusicNote },
MembyIcon.Football to { Icons.Default.SportsSoccer },
MembyIcon.Trophy to { Icons.Default.MilitaryTech },
MembyIcon.Fire to { Icons.Default.LocalFireDepartment },
MembyIcon.Tag to { Icons.Default.LocalOffer },
MembyIcon.Category to { Icons.Default.Category },
MembyIcon.Studio to { Icons.Default.Business },
MembyIcon.Landscape to { Icons.Default.Landscape },
MembyIcon.Palette to { Icons.Default.Palette },
MembyIcon.Notification to { Icons.Default.Notifications },
MembyIcon.NotificationActive to { Icons.Default.NotificationsActive },
MembyIcon.NotificationNone to { Icons.Default.NotificationsNone },
MembyIcon.NotificationOff to { Icons.Default.NotificationsOff },
MembyIcon.Inbox to { Icons.Default.Inbox },
MembyIcon.Pin to { Icons.Default.PushPin },
MembyIcon.Bolt to { Icons.Default.Bolt },
MembyIcon.Clock to { Icons.Default.AccessTime },
MembyIcon.Schedule to { Icons.Default.Schedule },
MembyIcon.Sun to { Icons.Default.WbSunny },
MembyIcon.Sunrise to { Icons.Default.LightMode },
MembyIcon.Night to { Icons.Default.NightsStay },
MembyIcon.Info to { Icons.Default.Info },
MembyIcon.Help to { Icons.AutoMirrored.Filled.HelpOutline },
MembyIcon.BrokenImage to { Icons.Default.BrokenImage },
MembyIcon.Devices to { Icons.Default.Devices },
MembyIcon.Storage to { Icons.Default.Storage },
MembyIcon.Build to { Icons.Default.Build },
MembyIcon.Power to { Icons.Default.PowerSettingsNew },
MembyIcon.Gavel to { Icons.Default.Gavel },
MembyIcon.Mic to { Icons.Default.Mic },
)
/** The marks the app shipped with, and the floor every other pack stands on. */
val pack = MembyIconPack(MEMBY_ICON_PACK_MATERIAL, marks)
internal fun fallback(slot: MembyIcon): ImageVector =
marks.getValue(slot).invoke()
}
const val MEMBY_ICON_PACK_MATERIAL = "material"
const val MEMBY_ICON_PACK_LUCIDE = "lucide"
const val MEMBY_ICON_PACK_FONT_AWESOME = "fontawesome"
/**
* Every pack this build can draw, by the slug the gateway names it with.
*
* The list is the client's whole side of the contract. A gateway naming a pack this
* television has never heard of is an ordinary event an operator has added one and this
* set has not been updated so [membyIconPackFor] answers with the marks the app shipped
* with rather than with nothing. That is the same stance an unknown decoration slug and an
* unparseable hex already take: the worst a theme does is look unchanged.
*/
private val membyIconPacks: Map<String, MembyIconPack> = listOf(
MaterialIconPack.pack,
lucideIconPack,
fontAwesomeIconPack,
).associateBy { it.id }
/**
* The pack for a slug, or the app's own marks.
*
* Pure, and tested the slug arrives off a wire, and a blank one (a gateway that predates
* this, a theme that expresses no opinion) has to mean "leave the marks alone" rather than
* "draw nothing".
*/
fun membyIconPackFor(slug: String?): MembyIconPack =
membyIconPacks[slug?.trim()?.lowercase().orEmpty()] ?: MaterialIconPack.pack
/** The slugs this build understands. Read by the tests and by Settings → About. */
val membyIconPackIds: List<String> get() = membyIconPacks.keys.toList()
@@ -0,0 +1,165 @@
package com.ponzischeme89.memby.ui.theme
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.vector.ImageVector
/**
* The marks this app draws, named by what they mean rather than by who drew them.
*
* This is `DesignTokens.kt` for icons, and it exists for the same reason. A colour scheme
* could not reach the launcher while a hundred literal hexes were written at the call
* sites, and an icon pack could not reach it while seventy `Icons.Default.*` were: the
* gateway can only change what the television has a *slot* for. Naming a slot after the
* Material identifier it happens to hold today (`AutoAwesome`, `SentimentVerySatisfied`)
* would put that back a pack whose recommendation mark is a wand rather than four stars
* would be filed under a name that lies about it. So the slots are named for the job:
* [Sparkle], [Happy], [Drama].
*
* What a slot is *worth* is the other half of the rule. A slot is added when a screen needs
* a mark, never so that a pack can show off a glyph the gateway names a pack and nothing
* else, so an unused slot is dead weight in three maps at once.
*/
enum class MembyIcon {
// Navigation and destinations
Home,
Search,
Grid,
Movie,
Tv,
LiveTv,
VideoLibrary,
Person,
Settings,
Calendar,
Event,
// Playback and library actions
Play,
PlayCircle,
Favourite,
FavouriteOutline,
Bookmark,
BookmarkOutline,
PlaylistAdd,
PlaylistRemove,
HideWatched,
// Affirmation
Check,
CheckCircle,
CheckAll,
Add,
Close,
// Movement
ChevronLeft,
ChevronRight,
ChevronDown,
ArrowBack,
ArrowForward,
ArrowUp,
ArrowDown,
FirstPage,
Backspace,
Space,
// Row and genre character
Sparkle,
Recommend,
Drama,
Happy,
Music,
Football,
Trophy,
Fire,
Tag,
Category,
Studio,
Landscape,
Palette,
// News
Notification,
NotificationActive,
NotificationNone,
NotificationOff,
Inbox,
Pin,
Bolt,
// Time of day and time itself
Clock,
Schedule,
Sun,
Sunrise,
Night,
// Status and diagnosis
Info,
Help,
BrokenImage,
Devices,
Storage,
Build,
Power,
Gavel,
Mic,
}
/**
* One pack's answer for every slot it has a mark for.
*
* The marks are **lambdas, not vectors**, because an `ImageVector` is built the first time
* it is read and a map of them would build all seventy on the first frame that touched the
* pack on the cold start, which is the one thing in this app nothing is allowed to cost.
* Held this way a pack costs one map of function references, and a mark is built when a
* screen actually draws it.
*
* A pack may be **partial**, and that is deliberate rather than tolerated. Solid sets have
* no honest filled form of a chevron or a tick, and a pack forced to name one would either
* block the pack from ever being offered or put a poor mark on the rail. An absent slot
* falls back to [MaterialIconPack], so the worst a pack does is look unchanged in places
* the same stance [parseThemeColor] takes on a hex string it cannot read.
*/
@Stable
class MembyIconPack(
/** The slug the gateway names this pack by. */
val id: String,
private val marks: Map<MembyIcon, () -> ImageVector>,
) {
/** Which slots this pack draws itself. Read by the tests that pin pack coverage. */
val slots: Set<MembyIcon> get() = marks.keys
internal fun markFor(slot: MembyIcon): ImageVector =
marks[slot]?.invoke() ?: MaterialIconPack.fallback(slot)
}
private val currentIconPack = mutableStateOf(MaterialIconPack.pack)
/**
* Repaints every mark in the app.
*
* The state is process-wide and read through [mark] below, so this is the icon half of
* [applyMembyPalette] and arrives by the same road: a theme revision the television does
* not hold, fetched by `ThemeSync`.
*/
fun applyMembyIconPack(pack: MembyIconPack) {
if (currentIconPack.value.id != pack.id) currentIconPack.value = pack
}
/** The pack in force. Exposed for the settings screen's own description of it. */
val membyIconPackId: String get() = currentIconPack.value.id
/**
* The mark to draw for this slot, under whatever pack the gateway last resolved.
*
* Reading this in a composable is what subscribes that composable to a pack change, which
* is the whole delivery mechanism and the reason nothing may hold the result. A mark
* captured in an `enum` constant or a `val` freezes the pack that was in force when its
* class initialised, which is exactly the `val`-versus-`get()` trap that made
* `SettingsSheet` the one screen a palette could never reach. Types that carry a mark
* around (a rail destination, a row's visual) carry the [MembyIcon] and resolve it where
* they draw.
*/
val MembyIcon.mark: ImageVector get() = currentIconPack.value.markFor(this)
@@ -0,0 +1,152 @@
package com.ponzischeme89.memby.ui.theme
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Every mark of every pack, to `build/screenshots/icon-packs/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*IconPackScreenshotTest"
* ```
*
* This is the only test that can judge the feature, for the reason `ThemeScreenshotTest`
* gives about palettes: a unit test can check that a pack names a mark for a slot, and it
* cannot check whether that mark *means* the slot. Nothing would have caught Lucide's
* volleyball standing in for football, or a stroke set going invisible at rail size, except
* looking and a pack nobody has looked at is a pack pushed to a household on faith.
*
* Both sizes are captured on purpose and the small one is the point. 21dp is what the
* navigation rail actually draws, and a stroke set has least to spare there every pack
* looks fine in the large grid, so that is not where a thin one would be caught. The strip
* along the bottom is the same nine marks in the accent, because a rail is read as a column
* of coloured glyphs rather than as isolated shapes on black.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class IconPackScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
/**
* The pack is process-wide state the whole reason one answer from the gateway
* repaints every marked thing in the app so a test that left one applied would draw
* the wrong marks into every screenshot taken after it in the same JVM.
*/
@After
fun unpack() {
applyMembyIconPack(MaterialIconPack.pack)
}
@Test
fun `every pack, at reading size`() {
sweep("labelled") { Sheet(size = 28.dp, labelled = true) }
}
@Test
fun `every pack, at the size the rail draws them`() {
sweep("rail-size") { Sheet(size = 21.dp, labelled = false) }
}
/**
* Composes the sheet **once** and repaints it by swapping the pack, which is both the
* only thing `setContent` allows and the more honest picture: it is exactly what a
* television already showing a screen does the moment the gateway resolves a different
* pack, rather than what a set that happened to start up under one does.
*/
private fun sweep(suffix: String, content: @Composable () -> Unit) {
compose.setContent(content)
membyIconPackIds.forEach { id ->
applyMembyIconPack(membyIconPackFor(id))
compose.onRoot().captureRoboImage("build/screenshots/icon-packs/$id-$suffix.png")
}
}
@Composable
private fun Sheet(size: androidx.compose.ui.unit.Dp, labelled: Boolean) {
Box(Modifier.fillMaxSize().background(MembySurface)) {
LazyVerticalGrid(
columns = GridCells.Fixed(if (labelled) 9 else 14),
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(if (labelled) 10.dp else 14.dp),
) {
items(MembyIcon.entries.toList()) { slot ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
slot.mark,
contentDescription = null,
tint = MembyOnSurface,
modifier = Modifier.size(size),
)
if (labelled) {
Text(
slot.name,
color = MembyQuietText,
fontSize = 7.sp,
textAlign = TextAlign.Center,
modifier = Modifier.width(96.dp).padding(top = 3.dp),
)
}
}
}
}
// The rail is the case the small sheet exists for, so it is on the same image
// rather than in one nobody opens: an accent-tinted strip at the exact size and
// spacing TvNavigationRail draws.
if (!labelled) {
Row(
Modifier.align(Alignment.BottomStart).padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
listOf(
MembyIcon.Home, MembyIcon.Search, MembyIcon.Movie, MembyIcon.Tv,
MembyIcon.Sparkle, MembyIcon.Calendar, MembyIcon.Favourite,
MembyIcon.Person, MembyIcon.Settings,
).forEach {
Icon(it.mark, null, tint = MembyAccent, modifier = Modifier.size(21.dp))
}
}
}
}
}
}
@@ -0,0 +1,71 @@
package com.ponzischeme89.memby.ui.theme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The two things about icon packs that can break silently.
*
* Neither is visible at a code review and both look identical to a working feature from the
* outside: a slot Material does not draw is a blank space on one screen somewhere, and a
* slug that does not resolve is a television that quietly ignores what the gateway told it.
*/
class MembyIconPackTest {
@Test
fun `material draws every slot`() {
// The floor every partial pack stands on. A slot added to MembyIcon and forgotten
// here would draw nothing at all — and only on whichever screen happens to use it,
// which is exactly the kind of gap that ships.
val missing = MembyIcon.entries.filterNot { it in MaterialIconPack.pack.slots }
assertTrue("Material has no mark for $missing", missing.isEmpty())
}
@Test
fun `a partial pack is legal and falls back rather than drawing nothing`() {
// Lucide is a stroke set, so it deliberately declines the filled halves of the
// state-bearing pairs. What matters is that declining costs a mark, never a screen.
val lucide = membyIconPackFor(MEMBY_ICON_PACK_LUCIDE)
assertTrue(
"Lucide should decline the filled favourite",
MembyIcon.Favourite !in lucide.slots,
)
MembyIcon.entries.forEach { slot ->
assertNotNull("no mark resolved for $slot", lucide.markFor(slot))
}
}
@Test
fun `an unreadable slug leaves the marks the app shipped with`() {
// Every one of these is an ordinary event rather than a fault: a gateway that
// predates the feature sends nothing, a theme that expresses no opinion sends
// empty, and an operator may add a pack to the catalogue before this build knows
// it. None of them may produce a launcher drawn with no marks.
listOf(null, "", " ", "tabler", "Material ", "LUCIDE").forEach { slug ->
val pack = membyIconPackFor(slug)
when (slug?.trim()?.lowercase()) {
MEMBY_ICON_PACK_LUCIDE -> assertEquals(MEMBY_ICON_PACK_LUCIDE, pack.id)
MEMBY_ICON_PACK_MATERIAL -> assertEquals(MEMBY_ICON_PACK_MATERIAL, pack.id)
else -> assertSame(
"unknown slug $slug should fall back to the shipped marks",
MaterialIconPack.pack,
pack,
)
}
}
}
@Test
fun `the slugs are the ones the gateway sends`() {
// The client's whole half of the wire contract. `internal/api/themes.go` names the
// same three, and its own test pins them from that end — change one without the
// other and a household gets the marks it did not ask for, silently.
assertEquals(
listOf("material", "lucide", "fontawesome").sorted(),
membyIconPackIds.sorted(),
)
}
}
+331 -81
View File
@@ -28,6 +28,14 @@ by this script.
This deploys the current local working tree, including uncommitted server changes.
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
Use -AdminOnly (or --Admin) to replace the operations console and nothing else. The
console is its own container with its own health check and nothing depends on it, so
only admin-ui/ is uploaded, only the memby-admin image is rebuilt and only that one
container is restarted. The gateway, PostgreSQL and Redis keep running throughout,
.env and docker-compose.yml are left exactly as deployed, no APK is built, and no
television is told anything because nothing they use goes away. It amends an existing
deployment and refuses to create one.
.EXAMPLE
.\deploy-server.ps1
@@ -48,6 +56,12 @@ Use -SkipAppRelease for an admin/server-only deployment: no APK is built or publ
.EXAMPLE
.\deploy-server.ps1 -SkipAppRelease
.EXAMPLE
.\deploy-server.ps1 --Admin
.EXAMPLE
.\deploy-server.ps1 -AdminOnly
#>
#Requires -Version 7.2
@@ -87,6 +101,15 @@ param(
[Parameter()]
[switch] $SkipAppRelease,
# Replace the operations console and nothing else. The console is its own container
# with its own health check and nothing depends on it, so it can be rebuilt and
# restarted while the gateway, PostgreSQL and Redis keep running — a console change is
# then seconds rather than the several minutes a full stack swap costs, and no
# television notices anything at all.
[Parameter()]
[Alias('Admin')]
[switch] $AdminOnly,
[Parameter()]
[Alias('m')]
[switch] $MandatoryUpdate,
@@ -113,20 +136,41 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$trailingFlags = @($TrailingFlag0, $TrailingFlag1) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$unknownFlags = @($trailingFlags | Where-Object { $_ -notin @('--m', '--Quiet', '--quiet') })
$unknownFlags = @($trailingFlags | Where-Object {
$_ -notin @('--m', '--Quiet', '--quiet', '--Admin', '--admin')
})
if ($unknownFlags.Count -gt 0) {
throw "Unknown deployment option: $($unknownFlags -join ', ')"
}
$mandatoryRelease = $MandatoryUpdate -or $trailingFlags -contains '--m'
$quietDeployment = $Quiet -or $trailingFlags -contains '--Quiet' -or $trailingFlags -contains '--quiet'
$consoleOnly = $AdminOnly -or $trailingFlags -contains '--Admin' -or $trailingFlags -contains '--admin'
if ($mandatoryRelease -and $SkipAppRelease) {
throw '--m cannot be combined with -SkipAppRelease because no update would be published.'
if ($mandatoryRelease -and ($SkipAppRelease -or $consoleOnly)) {
throw '--m cannot be combined with -SkipAppRelease or --Admin because no update would be published.'
}
# Nothing about the gateway is rebuilt, so there is nothing for a television to be told
# about. Accepting --Quiet here would imply the announcement was a choice on this path.
if ($consoleOnly -and $quietDeployment) {
throw '--Quiet has no meaning with --Admin: no televisions are affected, so none are told.'
}
# The console-only path never packages an APK. Stated rather than silently ignored, so a
# combined invocation cannot look as though it published one.
if ($consoleOnly) {
$SkipAppRelease = $true
}
# Timings are kept per kind of deployment, because the two are not the same operation
# measured twice: a console run rebuilds one small image and a full one swaps the whole
# stack. Averaged together, each estimate would be wrong for both. A record written before
# this existed carries no kind and is read as 'full', which is what all of them were.
$script:DeploymentKind = if ($consoleOnly) { 'console' } else { 'full' }
$script:CurrentStep = 0
$script:LocalStepCount = if ($SkipAppRelease) { 4 } else { 5 }
$script:RemoteStepCount = 10
# A console deployment skips the .env install, the dependency pull, the gateway build, the
# stack swap and the APK publish: what is left is check, extract, swap, build, restart, wait.
$script:RemoteStepCount = if ($consoleOnly) { 6 } else { 10 }
$script:TotalSteps = $script:LocalStepCount + $script:RemoteStepCount
$script:PhaseOrder = @(
'prerequisites',
@@ -142,7 +186,9 @@ $script:FallbackSeconds = @{
'payload' = 4
'android-release' = 230
'packaging' = 12
'remote-deployment' = 330
# A console deployment uploads one small build context and rebuilds one image, where a
# full one ships the Go tree as well and rebuilds the whole stack.
'remote-deployment' = if ($consoleOnly) { 90 } else { 330 }
}
$script:PhaseDurations = [ordered]@{}
$script:CurrentPhaseKey = $null
@@ -205,6 +251,9 @@ function Import-DeploymentHistory {
function Get-PhaseHistory {
param([Parameter(Mandatory)][string] $Key)
$values = foreach ($run in $script:History) {
$kindProperty = $run.PSObject.Properties['kind']
$kind = if ($kindProperty -and $kindProperty.Value) { [string]$kindProperty.Value } else { 'full' }
if ($kind -ne $script:DeploymentKind) { continue }
$phasesProperty = $run.PSObject.Properties['phases']
if (-not $phasesProperty -or -not $phasesProperty.Value) { continue }
$property = $phasesProperty.Value.PSObject.Properties[$Key]
@@ -360,6 +409,7 @@ function Save-DeploymentHistory {
durationSeconds = [Math]::Round($DurationSeconds, 2)
phases = $script:PhaseDurations
appRelease = -not [bool]$SkipAppRelease
kind = $script:DeploymentKind
}
$runs = @($script:History) + @([pscustomobject]$record) | Select-Object -Last 30
$directory = Split-Path -Parent $script:HistoryPath
@@ -607,7 +657,13 @@ function New-DeploymentArchive {
[string] $ArchivePath,
[Parameter()]
[string] $ReleaseDirectory
[string] $ReleaseDirectory,
# Pack the console's build context alone. Everything else the remote side needs —
# the Compose file, the environment — is already deployed and is deliberately left
# alone, so it must not be shipped and cannot be changed by accident.
[Parameter()]
[switch] $ConsoleOnly
)
# Build artefacts left in either build context are paid for on the wire. Both
@@ -620,9 +676,12 @@ function New-DeploymentArchive {
'--exclude', 'server/bin', '--exclude', 'server/bin/*',
'--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*',
'--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*',
'-C', $RepositoryDirectory,
'server', 'admin-ui', 'docker-compose.yml', '.env.example'
)
'-C', $RepositoryDirectory
) + $(if ($ConsoleOnly) {
@('admin-ui')
} else {
@('server', 'admin-ui', 'docker-compose.yml', '.env.example')
})
if ($ReleaseDirectory) {
$arguments += @(
'-C', (Split-Path -Parent $ReleaseDirectory),
@@ -770,27 +829,39 @@ try {
Write-Success "Using $checkoutDirectory"
Write-Host ''
Write-Step 'Validating the Compose deployment payload' -Key 'payload'
$requiredPaths = @(
(Join-Path $checkoutDirectory 'server'),
(Join-Path $checkoutDirectory 'server/Dockerfile'),
(Join-Path $checkoutDirectory 'server/go.mod'),
Write-Step $(if ($consoleOnly) {
'Validating the console deployment payload'
} else {
'Validating the Compose deployment payload'
}) -Key 'payload'
$consolePaths = @(
(Join-Path $checkoutDirectory 'admin-ui'),
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
(Join-Path $checkoutDirectory 'admin-ui/src'),
(Join-Path $checkoutDirectory 'admin-ui/src')
)
# A console deployment reuses the deployed docker-compose.yml and .env rather than
# shipping its own. That is the whole safety of it: the running gateway's configuration
# is left exactly as it was, so nothing can be changed here that would need it to
# restart to take effect.
$requiredPaths = $consolePaths + $(if ($consoleOnly) { @() } else { @(
(Join-Path $checkoutDirectory 'server'),
(Join-Path $checkoutDirectory 'server/Dockerfile'),
(Join-Path $checkoutDirectory 'server/go.mod'),
(Join-Path $checkoutDirectory 'docker-compose.yml'),
(Join-Path $checkoutDirectory '.env.example')
)
) })
foreach ($requiredPath in $requiredPaths) {
if (-not (Test-Path -LiteralPath $requiredPath)) {
throw "Required deployment file is missing: $requiredPath"
}
}
Write-Detail 'server/ build context'
Write-Detail 'admin-ui/ build context'
Write-Detail 'docker-compose.yml'
Write-Detail '.env.example'
if (-not $consoleOnly) {
Write-Detail 'server/ build context'
Write-Detail 'docker-compose.yml'
Write-Detail '.env.example'
}
$releaseDirectory = ''
$releaseVersion = ''
$releaseSHA256 = ''
@@ -882,34 +953,20 @@ try {
Write-Host ''
}
Write-Step 'Packaging the release' -Key 'packaging'
Write-Step $(if ($consoleOnly) { 'Packaging the console' } else { 'Packaging the release' }) -Key 'packaging'
New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath `
-ReleaseDirectory $releaseDirectory
-ReleaseDirectory $releaseDirectory -ConsoleOnly:$consoleOnly
$archiveSize = (Get-Item -LiteralPath $archivePath).Length
Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB))
Write-Success 'Release archive is ready'
Write-Host ''
# This template is single-quoted so PowerShell does not expand the remote
# shell's variables. Replacement values are validated before insertion.
$remoteCommand = @'
set -eu
destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__
mandatory_update=__MANDATORY_UPDATE__
quiet_deployment=__QUIET_DEPLOYMENT__
colour_output=__COLOUR_OUTPUT__
remote_step_offset=__REMOTE_STEP_OFFSET__
total_steps=__TOTAL_STEPS__
parent=$(dirname "$destination")
staging="${destination}.new.$$"
backup="${destination}.previous.$$"
activated=0
previous_stopped=0
remote_step=0
# The reporting shared by both remote scripts. Kept in one place because the step
# counter, the wording and the health wait are what make a deployment readable, and two
# copies of them are two things to keep in step. Every template that uses this defines
# colour_output, remote_step, remote_step_offset, total_steps and health_timeout above
# the point it is inserted.
$remoteHelpers = @'
step() {
remote_step=$((remote_step + 1))
overall_step=$((remote_step_offset + remote_step))
@@ -932,6 +989,217 @@ failure() {
if [ "$colour_output" -eq 1 ]; then printf '\033[31m ✗ %s\033[0m\n' "$1" >&2; else printf ' FAILED %s\n' "$1" >&2; fi
}
wait_for_service() {
service="$1"
elapsed=0
detail "Waiting for $service"
while [ "$elapsed" -lt "$health_timeout" ]; do
container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true)
if [ -n "$container_id" ]; then
state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true)
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)
if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then
success "$service is $state ($health)"
return 0
fi
if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then
failure "$service entered state: $state"
docker compose logs --no-color --tail 60 "$service" || true
return 1
fi
fi
sleep 2
elapsed=$((elapsed + 2))
if [ $((elapsed % 10)) -eq 0 ]; then
remaining=$((health_timeout - elapsed))
detail "$service is still ${state:-starting} (${health:-health pending}) · ${elapsed}s elapsed · up to ${remaining}s remaining"
fi
done
failure "$service did not become healthy within ${health_timeout}s"
docker compose logs --no-color --tail 60 "$service" || true
return 1
}
'@
# Replacing the console alone.
#
# The console is its own container: nothing depends on it, it holds no state and it has
# a health check of its own, so it can be rebuilt and restarted while the gateway,
# PostgreSQL and Redis carry on serving. That is what makes this path safe enough to be
# worth having — and it is only safe while it stays this narrow. Three things it must
# never do: touch .env or docker-compose.yml (the running gateway's configuration would
# then differ from the file describing it, with no restart to reconcile them), name any
# service but memby-admin, or omit --no-deps (Compose would otherwise be free to
# recreate the gateway as a dependency and take the house down for a CSS change).
$consoleRemoteCommand = @'
set -eu
destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
colour_output=__COLOUR_OUTPUT__
remote_step_offset=__REMOTE_STEP_OFFSET__
total_steps=__TOTAL_STEPS__
# Staged and backed up *inside* the deployment directory, so the swap is a rename on one
# filesystem rather than a copy that can be interrupted half way.
staging="${destination}/admin-ui.new.$$"
backup="${destination}/admin-ui.previous.$$"
swapped=0
remote_step=0
__SHELL_HELPERS__
restore_console() {
status=$?
trap - EXIT
if [ "$status" -eq 0 ]; then
return
fi
failure 'Console deployment failed; cleaning up'
rm -rf -- "$staging"
if [ "$swapped" -eq 1 ] && [ -d "$backup" ]; then
detail 'Restoring the previous console'
rm -rf -- "$destination/admin-ui"
mv -- "$backup" "$destination/admin-ui"
# Rebuilt as well as restored: the image that is running is the one that was just
# built from the files being thrown away, so putting the directory back without
# rebuilding would leave the NAS serving exactly what failed.
(
cd "$destination"
docker compose build memby-admin >/dev/null 2>&1 &&
docker compose up -d --no-deps --no-build memby-admin >/dev/null 2>&1
) || true
failure 'Previous console files were restored'
fi
exit "$status"
}
trap restore_console EXIT
trap 'exit 130' INT TERM
step 'Checking Docker and the deployed stack'
if ! command -v docker >/dev/null 2>&1; then
failure 'Docker is not installed on the NAS'
exit 1
fi
docker info >/dev/null 2>&1 || {
failure 'Docker is installed but the daemon is unavailable'
exit 1
}
# A console deployment amends a deployment that already exists. It cannot create one:
# there is no .env and no Compose file in this archive, by design.
if [ ! -f "$destination/docker-compose.yml" ]; then
failure "No deployment found at $destination"
detail 'Run a full deployment first; --Admin only replaces the console of an existing one'
exit 1
fi
if ! (cd "$destination" && docker compose config --services 2>/dev/null | grep -qx 'memby-admin'); then
failure 'The deployed docker-compose.yml has no memby-admin service'
detail 'That release predates the separate console; run a full deployment'
exit 1
fi
success "$(docker --version)"
success 'Existing deployment found'
step 'Extracting the console'
rm -rf -- "$staging"
mkdir -- "$staging"
tar -xf - -C "$staging"
test -f "$staging/admin-ui/Dockerfile"
test -f "$staging/admin-ui/package.json"
test -d "$staging/admin-ui/src"
success 'Console sources extracted'
step 'Replacing the console files'
rm -rf -- "$backup"
if [ -d "$destination/admin-ui" ]; then
mv -- "$destination/admin-ui" "$backup"
fi
mv -- "$staging/admin-ui" "$destination/admin-ui"
rm -rf -- "$staging"
swapped=1
success 'Console files replaced'
step 'Building the console image'
# Built before anything is restarted, so a console that does not compile leaves the one
# that is running untouched. The type check runs inside this build.
(
cd "$destination"
docker compose build memby-admin
)
success 'Console image built'
step 'Restarting the console'
(
cd "$destination"
# --no-deps is what keeps this to one container; --no-build because it was just built.
if ! docker compose up -d --no-deps --no-build memby-admin; then
failure 'Compose could not start the console'
docker compose logs --no-color --tail 60 memby-admin || true
exit 1
fi
)
success 'Console container restarted'
step 'Waiting for the console'
cd "$destination"
wait_for_service memby-admin
# The gateway is what serves /admin, and it was never restarted — so this is a check that
# the console is reachable the way an operator actually reaches it, not merely that its own
# container is up.
if command -v curl >/dev/null 2>&1; then
console_status=$(curl --silent --show-error --max-time 5 \
--output /dev/null --write-out '%{http_code}' \
http://127.0.0.1:32768/admin/ 2>/dev/null || true)
case "$console_status" in
2??|3??) success 'Console is being served through the gateway' ;;
*) detail "Console health is good but the gateway returned HTTP ${console_status:-no response} for /admin/" ;;
esac
fi
printf '\n'
docker compose ps
printf '\n'
rm -rf -- "$backup"
swapped=0
trap - EXIT INT TERM
success 'Console deployment is healthy'
success 'Memby console: https://mserver.sublogue.com/admin/'
'@
# This template is single-quoted so PowerShell does not expand the remote
# shell's variables. Replacement values are validated before insertion.
$remoteCommand = @'
set -eu
destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__
mandatory_update=__MANDATORY_UPDATE__
quiet_deployment=__QUIET_DEPLOYMENT__
colour_output=__COLOUR_OUTPUT__
remote_step_offset=__REMOTE_STEP_OFFSET__
total_steps=__TOTAL_STEPS__
parent=$(dirname "$destination")
staging="${destination}.new.$$"
backup="${destination}.previous.$$"
activated=0
previous_stopped=0
remote_step=0
__SHELL_HELPERS__
start_restored_stack() {
docker compose up -d --build --remove-orphans >/dev/null 2>&1
}
@@ -977,43 +1245,6 @@ rollback() {
exit "$status"
}
wait_for_service() {
service="$1"
elapsed=0
detail "Waiting for $service"
while [ "$elapsed" -lt "$health_timeout" ]; do
container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true)
if [ -n "$container_id" ]; then
state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true)
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)
if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then
success "$service is $state ($health)"
return 0
fi
if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then
failure "$service entered state: $state"
docker compose logs --no-color --tail 60 "$service" || true
return 1
fi
fi
sleep 2
elapsed=$((elapsed + 2))
if [ $((elapsed % 10)) -eq 0 ]; then
remaining=$((health_timeout - elapsed))
detail "$service is still ${state:-starting} (${health:-health pending}) · ${elapsed}s elapsed · up to ${remaining}s remaining"
fi
done
failure "$service did not become healthy within ${health_timeout}s"
docker compose logs --no-color --tail 60 "$service" || true
return 1
}
trap rollback EXIT
trap 'exit 130' INT TERM
@@ -1320,6 +1551,9 @@ success 'Remote deployment is healthy'
success 'Memby gateway: https://mserver.sublogue.com'
'@
if ($consoleOnly) { $remoteCommand = $consoleRemoteCommand }
# The helpers go in before anything else, so a token inside them is substituted too.
$remoteCommand = $remoteCommand.Replace('__SHELL_HELPERS__', $remoteHelpers)
$remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination)
$remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString())
$remoteCommand = $remoteCommand.Replace(
@@ -1346,8 +1580,15 @@ success 'Memby gateway: https://mserver.sublogue.com'
# script is normalised to LF here rather than depending on how it was saved.
$remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n")
Start-DeploymentPhase -Key 'remote-deployment' -Message "Deploying to $RemoteHost" -RemoteRange
Start-DeploymentPhase -Key 'remote-deployment' -Message $(if ($consoleOnly) {
"Deploying the console to $RemoteHost"
} else {
"Deploying to $RemoteHost"
}) -RemoteRange
Write-Detail 'One SSH password prompt will appear'
if ($consoleOnly) {
Write-Detail 'The gateway, PostgreSQL and Redis keep running throughout'
}
Write-Detail 'Remote build output follows; its steps continue the overall counter'
Write-Host ''
Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand
@@ -1357,8 +1598,17 @@ success 'Memby gateway: https://mserver.sublogue.com'
Save-DeploymentHistory -Success $true -DurationSeconds $deploymentTimer.Elapsed.TotalSeconds
if ($script:UseAnimation) { Write-Progress -Id 1 -Activity 'Memby deployment' -Completed }
Write-Host ''
Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed)
Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White
Write-Success ($(if ($consoleOnly) {
"Console deployment complete in {0:mm\:ss}"
} else {
"Deployment complete in {0:mm\:ss}"
}) -f $deploymentTimer.Elapsed)
if ($consoleOnly) {
Write-Styled -Message ' Memby console: https://mserver.sublogue.com/admin/' -Colour White
Write-Styled -Message ' Gateway: untouched and still serving' -Colour Gray
} else {
Write-Styled -Message ' Memby gateway: https://mserver.sublogue.com' -Colour White
}
Write-Styled -Message " NAS endpoint: http://${RemoteHost}:32768" -Colour Gray
Write-Styled -Message " Install path: ${RemoteHost}:$Destination" -Colour Gray
if (-not $SkipAppRelease) {
+14
View File
@@ -68,11 +68,25 @@ services:
MEMBY_SONARR_URL: "${MEMBY_SONARR_URL:-}"
MEMBY_SONARR_API_KEY: "${MEMBY_SONARR_API_KEY:-}"
MEMBY_SONARR_TTL: "${MEMBY_SONARR_TTL:-5m}"
# The webhook Sonarr and Radarr push into, which is how the catalogue learns a file
# landed rather than waiting for the hourly sweep. Empty is what makes each hook 404,
# so a token missing *here* is indistinguishable from one never configured at all —
# every variable the gateway reads has to be named in this list to reach it.
MEMBY_SONARR_WEBHOOK_TOKEN: "${MEMBY_SONARR_WEBHOOK_TOKEN:-}"
MEMBY_SONARR_ALERT_WINDOW: "${MEMBY_SONARR_ALERT_WINDOW:-3h}"
# How long after a webhook the gateway first looks for the file in Emby.
MEMBY_ARR_INGEST_SETTLE: "${MEMBY_ARR_INGEST_SETTLE:-1m}"
# Optional read-only Radarr calendar integration. Only digital release dates
# appear in the five-day movie row.
MEMBY_RADARR_URL: "${MEMBY_RADARR_URL:-}"
MEMBY_RADARR_API_KEY: "${MEMBY_RADARR_API_KEY:-}"
MEMBY_RADARR_TTL: "${MEMBY_RADARR_TTL:-5m}"
MEMBY_RADARR_WEBHOOK_TOKEN: "${MEMBY_RADARR_WEBHOOK_TOKEN:-}"
MEMBY_RADARR_ALERT_WINDOW: "${MEMBY_RADARR_ALERT_WINDOW:-3h}"
# The reachability probe behind the outage bar and its two banners. 0 turns it off.
MEMBY_EMBY_HEALTH_INTERVAL: "${MEMBY_EMBY_HEALTH_INTERVAL:-60s}"
# Optional overrides for the recommendation scorer. Blank keeps the built-in weights.
MEMBY_RECOMMENDATION_WEIGHTS: "${MEMBY_RECOMMENDATION_WEIGHTS:-}"
# Optional Tracearr public API. Memby reads recent playback analytics to rank
# the dedicated For You area; the token never leaves this container.
MEMBY_TRACEARR_URL: "${MEMBY_TRACEARR_URL:-}"
+50
View File
@@ -429,6 +429,56 @@ favourites and resume positions are per-user and cannot be shared across a house
they still come from Emby live. The imported copy powers search and the recommendation
candidate pool.
### Event-driven ingest
Sonarr and Radarr are the things that put files on disk, so they are what the catalogue
learns from. Both post to the gateway, each event is recorded in `library_ingest_queue`,
and a single worker reads the named title out of Emby a minute later — rather than the
whole catalogue waiting on the next sweep. An episode imported at 19:05 is searchable at
19:06 instead of as late as 20:00.
| | |
|---|---|
| Sonarr | `POST /hooks/sonarr?token=$MEMBY_SONARR_WEBHOOK_TOKEN` |
| Radarr | `POST /hooks/radarr?token=$MEMBY_RADARR_WEBHOOK_TOKEN` |
In each *arr: **Settings → Connect → + → Webhook**, method POST, with **On Import, On
Upgrade, On Rename, On File Delete** and **On Series/Movie Delete** ticked. The token may
also be sent as `X-Memby-Token`, a bearer token or basic-auth password; an unset token
makes the hook 404, so a deployment that never configured one cannot be posted to. Press
**Test** to check reachability — it answers 200 and records nothing.
Both hooks sit outside the maintenance gate *and* outside the quiet-time gate, which is
the point of the queue being durable: the gate answers 503 and neither *arr re-delivers,
so a quiet hour would otherwise discard every import that happened during it. The hook
records at any hour; the worker is where quiet time is honoured.
**What each event does.** An import or an upgrade re-reads the item — an upgrade is silent
as *news*, because the film was already there, but 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 removes the row and its credits marker, and
only counts when the media went with it — a series unfollowed in Sonarr with its files
left on disk is still in the library.
**Nothing is done twice.** The queue key is derived from the *file* rather than the
delivery, so a repeated webhook collapses onto one row; a file deleted and re-imported is
a different file and its own work. Emby not having scanned a new file yet is the expected
first answer rather than a fault: one rescan nudge is sent and the row retries on a
widening backoff (1m, 5m, 20m, 1h, then four-hourly) before being given up on.
**The sweep is reconciliation now.** `MEMBY_SYNC_INTERVAL` still runs the incremental
import, and with both webhooks wired up it exists for what the *arrs do not manage — a
file dropped in by hand, a title edited in Emby, a notification that never arrived because
the container was down. Six hours is a sensible value then; the console can set it at
**Settings → Catalogue sweep** without a redeployment, and it takes effect on the next
cycle rather than at the next restart.
**Where to look.** Admin console → **Imports** shows whether each hook is configured, what
is waiting, and the last fifty events with why each was queued and what happened to it —
which is the page to read when somebody says a new episode is not showing up. In the log
it is `event=arr_ingest` with an `outcome` of `queued`, `imported`, `removed`, `absent`,
`not_found` or `deferred`.
## Maintenance mode
Takes Memby down independently of Emby: all `/v1` routes answer `503` with
+26 -1
View File
@@ -167,6 +167,20 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
DeviceID: "memby-gateway-sync",
Gateway: true,
}, log.With("component", "library"))
// Sonarr and Radarr are what put files on disk, so they are what the catalogue learns
// from. The worker is built whenever either webhook is configured; with neither token
// set both hooks 404 and nothing here ever has anything to do, so it is not started.
var ingester *library.Ingester
if cfg.SonarrWebhookToken != "" || cfg.RadarrWebhookToken != "" {
ingester = &library.Ingester{
Store: st,
Emby: embyClient,
Credentials: syncer.EmbyCredentials,
Log: log.With("component", "library-ingest"),
Settle: cfg.IngestSettleDelay,
}
}
var forYouService *foryou.Service
if tracearrClient != nil {
forYouService = foryou.New(
@@ -265,6 +279,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
Credits: creditsService,
CreditsLoad: creditsLoad,
Syncer: syncer,
Ingester: ingester,
Log: log,
Events: events,
@@ -283,6 +298,16 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
creditsService.SetPaused(server.ActivityPaused)
go creditsService.Run(ctx)
}
if ingester != nil {
// Quiet time is honoured here rather than at the hook: the webhook is recorded
// whatever the hour, and this is what waits.
ingester.Paused = server.ActivityPaused
// The news follows the scan rather than the webhook, so the banner can say a title
// is there rather than that it is coming. Installed here for the same reason
// SetAfterSync is: library stays ignorant of what an alert is.
ingester.Announce = server.AnnounceLibraryIngest
go ingester.Run(ctx)
}
// Registration is separate from construction so the task list reads as a declaration
// of what the gateway does in the background rather than as more wiring in here.
@@ -326,7 +351,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
}
go server.WatchUpdatePolicy(ctx, 60*time.Second)
go syncer.Schedule(ctx, cfg.SyncInterval, server.ActivityPaused)
go syncer.Schedule(ctx, server.LibrarySyncInterval, server.ActivityPaused)
if cfg.SyncOnStart {
go func() {
if server.ActivityPaused() {
+1
View File
@@ -55,6 +55,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("GET /admin/api/ingest", s.adminAuth(s.handleAdminIngest))
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
mux.Handle("POST /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"net/http"
"github.com/ponzischeme89/memby/server/internal/store"
)
// What the *arr webhooks have been doing, for the Imports page.
//
// It exists because a webhook is the one part of this gateway that fails *silently*: a
// token typed wrongly into Sonarr, a URL the container cannot be reached on, or a
// notification never enabled all look exactly like a household in which nothing has been
// imported lately. Without this an operator's only recourse is reading container logs.
// ingestEventLimit is how much of the log the page carries. Enough to cover an evening's
// imports and a season pack, which is what somebody is looking at when they open it.
const ingestEventLimit = 50
type adminIngestResponse struct {
// Configured says whether each hook would answer at all. Both unset is the honest
// explanation of an empty table, and the page says so rather than leaving an operator
// to conclude the feature is broken.
SonarrConfigured bool `json:"sonarrConfigured"`
RadarrConfigured bool `json:"radarrConfigured"`
SettleSeconds int `json:"settleSeconds"`
SyncMinutes int `json:"syncMinutes"`
Counts store.IngestCounts `json:"counts"`
Recent []store.IngestJob `json:"recent"`
}
func (s *Server) handleAdminIngest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
response := adminIngestResponse{
SonarrConfigured: s.cfg.SonarrWebhookToken != "",
RadarrConfigured: s.cfg.RadarrWebhookToken != "",
SettleSeconds: int(s.ingestSettle().Seconds()),
SyncMinutes: int(s.LibrarySyncInterval().Minutes()),
Recent: []store.IngestJob{},
}
counts, err := s.store.IngestStateCounts(ctx)
if err != nil {
s.loggerFor(ctx).Error("ingest counts failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the import queue")
return
}
response.Counts = counts
recent, err := s.store.RecentIngests(ctx, ingestEventLimit)
if err != nil {
s.loggerFor(ctx).Error("ingest history failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the import queue")
return
}
response.Recent = recent
writeJSON(w, http.StatusOK, response)
}
+4
View File
@@ -13,6 +13,10 @@ import (
const (
alertKindSonarrAired = "sonarr-aired"
alertKindRadarrImport = "radarr-import"
// A new episode that has finished scanning in. Distinct from sonarr-aired, which is
// about an episode that has been broadcast and is *not* here yet — the two are opposite
// halves of the same wait and a viewer reads them differently.
alertKindSonarrImport = "sonarr-import"
alertKindLibrarySync = "library-updated"
alertKindServerDown = "server-unreachable"
alertKindServerUp = "server-restored"
+21 -5
View File
@@ -59,8 +59,11 @@ type Server struct {
credits *credits.Service
creditsLoad *credits.PlaybackLoad
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
// ingester records what Sonarr and Radarr say changed. Nil where no webhook token is
// configured, which is also what makes both hooks 404.
ingester ingesterHandle
log *slog.Logger
events *serverlogging.Buffer
// adminEvents is the administrative feed: the console's notification bell and every
// outgoing integration read from it. Distinct from `events` above, which is the
// structured log ring — a log line is what the gateway did, an admin event is
@@ -96,6 +99,9 @@ type Server struct {
// logged by name.
playbackTitles playbackTitles
// ingestRuns collapses a season pack's worth of finished scans into one banner.
ingestRuns ingestRuns
recommendationBuilds recommendationBuilds
maintenance maintenanceState
quietTime quietTimeState
@@ -130,6 +136,7 @@ type Deps struct {
Credits *credits.Service
CreditsLoad *credits.PlaybackLoad
Syncer syncerHandle
Ingester ingesterHandle
Log *slog.Logger
Events *serverlogging.Buffer
@@ -157,6 +164,7 @@ func New(cfg config.Config, deps Deps) *Server {
credits: deps.Credits,
creditsLoad: deps.CreditsLoad,
syncer: deps.Syncer,
ingester: deps.Ingester,
log: deps.Log,
events: deps.Events,
@@ -314,9 +322,17 @@ func (s *Server) Routes() http.Handler {
// status even while every normal /v1 operation is deliberately unavailable.
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
mux.Handle("/v1/", s.maintenanceGate(v1))
// Radarr pushes here when an import finishes. Outside the gate on purpose: an event
// arriving during maintenance would otherwise be lost rather than delayed.
mux.Handle("POST /hooks/radarr", s.quietTimeGate(http.HandlerFunc(s.handleRadarrWebhook)))
// Sonarr and Radarr push here when something lands, is upgraded, is renamed or is
// deleted. Outside the maintenance gate on purpose: an event arriving during
// maintenance would otherwise be lost rather than delayed.
//
// Outside the *quiet-time* gate too, which the Radarr hook was previously inside. That
// gate answers 503, and neither *arr re-delivers — so a quiet hour used to silently
// discard every import that happened during it. The durable queue is what makes the
// distinction possible: the hook records the news whatever the hour, and the worker is
// where quiet time is honoured.
mux.HandleFunc("POST /hooks/radarr", s.handleRadarrWebhook)
mux.HandleFunc("POST /hooks/sonarr", s.handleSonarrWebhook)
// State the canonical console URL explicitly. The console and its assets live below
// /admin/, while a bare /admin is routinely typed and some reverse proxies do not
// preserve ServeMux's implicit trailing-slash redirect for a mounted subtree.
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"net/http"
"github.com/ponzischeme89/memby/server/internal/library"
)
// The two things that push into the gateway.
//
// Radarr's hook was already here, announcing a film as news. Both hooks now also *record*
// what changed, which is the half that replaces asking Emby every hour whether anything
// had happened: Sonarr and Radarr are the things that put files on disk, so they are the
// things that know.
//
// Recording is all a hook does. The lookup, the import and the retry all belong to the
// worker in internal/library, which is what lets these answer in a millisecond and, more
// importantly, what lets them answer *at all* during quiet hours — see below.
// ingesterHandle is the slice of the ingest worker the API needs, so api does not depend
// on the concrete type for testing. Same arrangement as syncerHandle.
type ingesterHandle interface {
Enqueue(ctx context.Context, source string, requests []library.IngestRequest) (int, error)
}
// handleSonarrWebhook accepts Sonarr's import, upgrade, rename and delete notifications.
//
// Unconfigured means absent — the stance /admin and the Radarr hook already take: a
// deployment that never set a token must not expose an endpoint anything can post to.
func (s *Server) handleSonarrWebhook(w http.ResponseWriter, r *http.Request) {
if s.cfg.SonarrWebhookToken == "" {
http.NotFound(w, r)
return
}
if subtle.ConstantTimeCompare(
[]byte(webhookToken(r)), []byte(s.cfg.SonarrWebhookToken),
) != 1 {
writeError(w, http.StatusUnauthorized, "invalid webhook token")
return
}
var payload library.SonarrWebhook
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid webhook payload")
return
}
// Sonarr's Test button posts a stub. Answering 200 without recording work about a
// series that does not exist is what makes that button mean "reachable".
if library.IsTestEvent(payload.EventType) {
s.loggerFor(r.Context()).Info("sonarr webhook test received")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "test": true})
return
}
queued := s.queueIngest(r, "sonarr", payload.EventType, library.SonarrRequests(payload))
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": queued})
}
// queueIngest records the work a notification implies and reports how much of it was news.
//
// The context is deliberately detached from the request. A webhook is answered in a
// millisecond and Sonarr closes the connection; hanging the insert off the request would
// abandon exactly the deliveries that arrive in bursts, which is what a season pack is.
func (s *Server) queueIngest(
r *http.Request, source, eventType string, requests []library.IngestRequest,
) int {
log := s.loggerFor(r.Context())
if s.ingester == nil {
return 0
}
if len(requests) == 0 {
// A grab, a health check, an unfollowed series whose files stayed on disk: all
// real events, none of them a reason to re-read anything.
log.Debug("webhook implies no catalogue work", "source", source, "event", eventType)
return 0
}
ctx := context.WithoutCancel(r.Context())
queued, err := s.ingester.Enqueue(ctx, source, requests)
if err != nil {
// The event is lost, which is the one failure worth an error line here: the *arrs
// do not re-deliver, so nothing will bring this news again. The reconciliation
// sweep is what eventually covers it.
log.Error("could not record webhook work",
"source", source, "event", eventType, "error", err)
return queued
}
if queued > 0 {
log.Info("arr ingest queued",
"event", "arr_ingest", "source", source, "webhook_event", eventType,
"outcome", "queued", "items", queued, "reason", requests[0].Reason)
}
return queued
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/library"
)
// recordingIngester stands in for the worker. The hook's whole job is to record, so what
// it recorded is the only thing worth asserting on here.
type recordingIngester struct {
sources []string
requests []library.IngestRequest
}
func (r *recordingIngester) Enqueue(
_ context.Context, source string, requests []library.IngestRequest,
) (int, error) {
r.sources = append(r.sources, source)
r.requests = append(r.requests, requests...)
return len(requests), nil
}
func sonarrHookRequest(body string) *http.Request {
return httptest.NewRequest(
http.MethodPost, "/hooks/sonarr?token=hook-secret", strings.NewReader(body))
}
func TestSonarrWebhookIsHiddenUntilATokenIsConfigured(t *testing.T) {
s := &Server{cfg: config.Config{}, log: discardLogger()}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, sonarrHookRequest("{}"))
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404 for an unconfigured hook", rec.Code)
}
}
func TestSonarrWebhookRejectsAWrongToken(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{SonarrWebhookToken: "hook-secret"},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, httptest.NewRequest(
http.MethodPost, "/hooks/sonarr?token=guess", strings.NewReader("{}")))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", rec.Code)
}
if len(ingester.requests) != 0 {
t.Fatal("an unauthorised delivery recorded work")
}
}
func TestSonarrWebhookRecordsAnImport(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{SonarrWebhookToken: "hook-secret"},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, sonarrHookRequest(`{
"eventType":"Download",
"series":{"id":12,"title":"Blue Bloods","year":2010},
"episodes":[{"id":551,"seasonNumber":6,"episodeNumber":7}],
"episodeFile":{"id":8123}
}`))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if len(ingester.requests) != 1 {
t.Fatalf("expected one recorded request, got %d", len(ingester.requests))
}
request := ingester.requests[0]
if request.Kind != library.KindEpisode || request.Episode != 7 || request.Season != 6 {
t.Fatalf("unexpected request: %+v", request)
}
if ingester.sources[0] != "sonarr" {
t.Fatalf("unexpected source: %q", ingester.sources[0])
}
}
// The Test button must answer without recording work about a series that does not exist.
func TestSonarrWebhookTestEventRecordsNothing(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{SonarrWebhookToken: "hook-secret"},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, sonarrHookRequest(
`{"eventType":"Test","series":{"id":1,"title":"Test Title"}}`))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if len(ingester.requests) != 0 {
t.Fatalf("the test event recorded work: %+v", ingester.requests)
}
}
// A Radarr upgrade is silent as news and still a reason to re-read the row. The two
// judgements are made in different places and this is what pins them apart.
func TestRadarrUpgradeIsRecordedThoughItIsNotAnnounced(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{
RadarrWebhookToken: "hook-secret",
RadarrAlertWindow: 0, // no announcement is possible
},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleRadarrWebhook(rec, httptest.NewRequest(
http.MethodPost, "/hooks/radarr?token=hook-secret", strings.NewReader(`{
"eventType":"Download","isUpgrade":true,
"movie":{"id":44,"title":"Arrival","year":2016},
"movieFile":{"id":441,"quality":"Bluray-1080p"}
}`)))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if len(ingester.requests) != 1 {
t.Fatalf("the upgrade was not recorded: %+v", ingester.requests)
}
if ingester.requests[0].Reason != library.ReasonUpgrade {
t.Fatalf("unexpected reason: %q", ingester.requests[0].Reason)
}
if ingester.requests[0].Action != library.ActionRefresh {
t.Fatalf("an upgrade must refresh, got %q", ingester.requests[0].Action)
}
}
// A grab is a real event and not a reason to re-read anything.
func TestSonarrGrabRecordsNothing(t *testing.T) {
ingester := &recordingIngester{}
s := &Server{
cfg: config.Config{SonarrWebhookToken: "hook-secret"},
log: discardLogger(),
ingester: ingester,
}
rec := httptest.NewRecorder()
s.handleSonarrWebhook(rec, sonarrHookRequest(
`{"eventType":"Grab","series":{"id":12,"title":"Blue Bloods"}}`))
if rec.Code != http.StatusOK || len(ingester.requests) != 0 {
t.Fatalf("got %d with %d requests", rec.Code, len(ingester.requests))
}
}
+21
View File
@@ -129,6 +129,15 @@ func (s *Server) embyHealthInterval() time.Duration {
s.cfg.EmbyHealthInterval)
}
// LibrarySyncInterval is how often the catalogue sweep runs. Exported because the syncer's
// schedule reads it every tick rather than closing over it at start-up — a setting read
// once at start-up is not a setting, and an operator lengthening the sweep after wiring up
// the webhooks must not have to restart the container to see it take effect.
func (s *Server) LibrarySyncInterval() time.Duration {
return overrideWindow(s.gatewaySettings.get().LibrarySyncMinutes, time.Minute,
s.cfg.SyncInterval)
}
// overrideWindow reads one of the three settings that can be switched off: a negative
// value is off, zero is "whatever was deployed", anything else is the override in the
// given unit.
@@ -153,6 +162,7 @@ type deployedGatewaySettings struct {
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
EmbyHealthSeconds int `json:"embyHealthSeconds"`
LibrarySyncMinutes int `json:"librarySyncMinutes"`
}
func (s *Server) deployedSettings() deployedGatewaySettings {
@@ -167,6 +177,7 @@ func (s *Server) deployedSettings() deployedGatewaySettings {
SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute),
RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute),
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute),
}
}
@@ -184,3 +195,13 @@ func levelName(level slog.Level) string {
return "error"
}
}
// ingestSettle is what the console prints beside the webhook activity, and it is a helper
// for the same reason the others here are: the delay is configuration, and the page must
// report the value actually in force rather than the constant it defaults to.
func (s *Server) ingestSettle() time.Duration {
if s.cfg.IngestSettleDelay > 0 {
return s.cfg.IngestSettleDelay
}
return time.Minute
}
+14
View File
@@ -77,6 +77,20 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
},
})
sched.Register(scheduler.Task{
ID: "ingest-cleanup",
Name: "Import queue cleanup",
Group: "Housekeeping",
Description: fmt.Sprintf(
"Removes settled Sonarr and Radarr import records older than %d days. Work still waiting is never removed.",
int(store.IngestRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneIngests(ctx, store.IngestRetention)
return countDetail(removed, "import record"), err
},
})
sched.Register(scheduler.Task{
ID: "task-history-cleanup",
Name: "Task history cleanup",
+210
View File
@@ -0,0 +1,210 @@
package api
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
)
// News about a finished Sonarr or Radarr scan.
//
// 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 this replaces had to say a film would
// be available "shortly", and why an episode could not be announced at all: there was
// nothing truthful to say about one until it was actually there. The gateway now knows when
// that moment arrives, because the ingest worker is what makes it arrive, so the
// announcement is made from the far end of the scan and says the title is ready.
//
// The cost is that the news is a minute or two later than the webhook, and that a title Emby
// never manages to scan is never announced. Both are the right way round: a notice about
// something a viewer can press Play on is worth more than an earlier one about something
// they cannot.
const (
// ingestRunWindow is how long two imports count as one piece of news. A season pack
// arrives as a dozen webhooks over a couple of minutes, and a household does not want a
// dozen banners about it — it wants to be told the show has new episodes.
ingestRunWindow = 15 * time.Minute
// trackedIngestRuns bounds the tally. A household imports a handful of things at once;
// this is generous enough that a season pack always collapses and small enough that it
// can never grow into a leak.
trackedIngestRuns = 64
)
// AnnounceLibraryIngest turns a completed scan into the banner every open television shows.
//
// Only a genuine import is announced. An upgrade is deliberately silent — the title was
// already there, and "new episode" would be a lie about a file that was replaced with a
// better copy — and so are a rename and a delete, which are housekeeping rather than news.
// That judgement lives here rather than in the worker: the worker's business is that the
// row changed, this is the separate question of whether anybody should be told.
func (s *Server) AnnounceLibraryIngest(ctx context.Context, result library.IngestResult) {
if result.Reason != library.ReasonImport {
return
}
switch result.Kind {
case library.KindMovie:
s.announceImportedMovie(ctx, result)
case library.KindEpisode:
s.announceImportedEpisode(ctx, result)
}
// A series-level result is a rename settling or a show written ahead of its first
// episode. Neither is a title somebody can watch, and the episode that follows is.
}
func (s *Server) announceImportedMovie(ctx context.Context, result library.IngestResult) {
window := s.radarrAlertWindow()
title := strings.TrimSpace(result.Name)
if window <= 0 || title == "" || result.ItemID == "" {
return
}
now := time.Now().UTC()
name := title
if result.Year > 0 {
name = fmt.Sprintf("%s (%d)", title, result.Year)
}
s.publishAlert(ctx, clientAlert{
// Keyed on the Emby item, so a repeated delivery of one import is one banner while
// a film deleted and re-imported is news again. Clients dedupe on this id forever.
ID: "ingest:movie:" + result.ItemID,
Kind: alertKindRadarrImport,
Label: "NEW MOVIE ADDED",
Title: name,
Message: fmt.Sprintf("%s is ready to watch.", title),
ItemID: result.ItemID,
ImageTag: result.ImageTag,
AiredAt: now.Format(time.RFC3339),
}, window)
s.loggerFor(ctx).Info("library ingest announced",
"event", "arr_ingest_alert", "source", result.Source, "kind", result.Kind,
"title", name, "item", result.ItemID)
}
func (s *Server) announceImportedEpisode(ctx context.Context, result library.IngestResult) {
// The Sonarr window, so MEMBY_SONARR_ALERT_WINDOW=0 switches episode news off exactly
// as it does the "aired, coming soon" one, without touching films.
window := s.sonarrAlertWindow()
series := strings.TrimSpace(result.SeriesName)
if window <= 0 || series == "" || result.ItemID == "" {
return
}
now := time.Now().UTC()
run := s.ingestRuns.record(
seasonRunKey(series, result.Season), result.ItemID,
episodeSummary(result), now, ingestRunWindow,
)
s.publishAlert(ctx, clientAlert{
// The run's *first* episode anchors the id, so every later arrival in the same
// season pack replaces one banner rather than stacking another — and next week's
// episode, arriving after the window has closed, starts a run of its own and is
// therefore its own news rather than one the fleet has already dismissed as seen.
ID: "ingest:episode:" + run.Anchor,
Kind: alertKindSonarrImport,
Label: "NEW EPISODE ADDED",
Title: series,
Message: episodeRunMessage(run),
ItemID: result.ItemID,
ImageTag: result.ImageTag,
AiredAt: now.Format(time.RFC3339),
}, window)
s.loggerFor(ctx).Info("library ingest announced",
"event", "arr_ingest_alert", "source", result.Source, "kind", result.Kind,
"series", series, "episodes", run.Count, "item", result.ItemID)
}
// episodeSummary is how one episode is named in a banner: "S03E05 — The Bear". The code
// alone is what a viewer scanning a shelf recognises, and the title is what tells them it
// is the one they were waiting for, so both are kept where both exist.
func episodeSummary(result library.IngestResult) string {
code := ""
if result.Season > 0 || result.Episode > 0 {
code = fmt.Sprintf("S%02dE%02d", result.Season, result.Episode)
}
title := strings.TrimSpace(result.Name)
switch {
case code == "":
return title
case title == "" || strings.EqualFold(title, result.SeriesName):
return code
default:
return fmt.Sprintf("%s — %s", code, title)
}
}
// episodeRunMessage words one arrival by name and several by count. Naming the last of six
// would be arbitrary — nothing makes it the one worth mentioning — where the count is the
// thing the viewer actually wants to know.
func episodeRunMessage(run ingestRun) string {
if run.Count > 1 {
return fmt.Sprintf("%d new episodes are ready to watch.", run.Count)
}
if run.Latest == "" {
return "A new episode is ready to watch."
}
return fmt.Sprintf("%s is ready to watch.", run.Latest)
}
func seasonRunKey(series string, season int) string {
return fmt.Sprintf("%s|%d", library.NormalizedTitle(series), season)
}
// ingestRun is what a season's imports have amounted to so far.
type ingestRun struct {
// Anchor is the first item id seen in this run, and is what keeps a burst of banners
// collapsed onto one.
Anchor string
Count int
Latest string
}
// ingestRuns collapses a burst of imports of one season into a single piece of news.
//
// Deliberately in memory and deliberately lossy, the playbackTitles arrangement: a gateway
// restarted half way through a season pack announces the rest as a second run, which is a
// far better trade than a table recording what a banner said.
type ingestRuns struct {
mu sync.Mutex
runs map[string]*runState
order []string
}
type runState struct {
anchor string
count int
latest string
until time.Time
}
// record folds one import into its season's run and reports where that run now stands. A
// run whose window has closed is replaced rather than extended, so a show importing an
// episode a week is a separate notice every week.
func (r *ingestRuns) record(
key, itemID, summary string, now time.Time, window time.Duration,
) ingestRun {
r.mu.Lock()
defer r.mu.Unlock()
if r.runs == nil {
r.runs = make(map[string]*runState, trackedIngestRuns)
}
state, live := r.runs[key]
if !live || !state.until.After(now) {
if !live {
r.order = append(r.order, key)
if len(r.order) > trackedIngestRuns {
delete(r.runs, r.order[0])
r.order = r.order[1:]
}
}
state = &runState{anchor: itemID}
r.runs[key] = state
}
state.count++
state.latest = summary
state.until = now.Add(window)
return ingestRun{Anchor: state.anchor, Count: state.count, Latest: state.latest}
}
+155
View File
@@ -0,0 +1,155 @@
package api
import (
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
)
func episodeResult(season, episode int, title string) library.IngestResult {
return library.IngestResult{
Source: "sonarr",
Kind: library.KindEpisode,
Reason: library.ReasonImport,
ItemID: "emby-" + title,
Name: title,
SeriesName: "The Bear",
Season: season,
Episode: episode,
}
}
func TestEpisodeSummaryNamesTheEpisodeBothWays(t *testing.T) {
if got := episodeSummary(episodeResult(3, 5, "Children")); got != "S03E05 — Children" {
t.Errorf("summary = %q, want the code and the title", got)
}
// Emby records plenty of episodes under the show's own name, and "S03E05 — The Bear"
// reads as a mistake where the code alone reads as an episode.
same := episodeResult(3, 5, "The Bear")
if got := episodeSummary(same); got != "S03E05" {
t.Errorf("summary = %q, want the code alone when the title repeats the series", got)
}
untitled := episodeResult(3, 5, "")
if got := episodeSummary(untitled); got != "S03E05" {
t.Errorf("summary = %q, want the code alone", got)
}
}
// A season pack is one piece of news. Every arrival replaces the same banner, which is
// what the shared anchor is for, and the wording moves from the episode to the count.
func TestIngestRunsCollapseASeasonPack(t *testing.T) {
var runs ingestRuns
now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC)
first := runs.record("bear|3", "emby-1", "S03E01", now, ingestRunWindow)
if first.Count != 1 || first.Anchor != "emby-1" {
t.Fatalf("first = %+v, want a run of one anchored on it", first)
}
if got := episodeRunMessage(first); got != "S03E01 is ready to watch." {
t.Errorf("message = %q, want the episode named", got)
}
second := runs.record("bear|3", "emby-2", "S03E02", now.Add(30*time.Second), ingestRunWindow)
if second.Anchor != "emby-1" {
t.Errorf("anchor = %q, want the run's first episode so the banner is replaced", second.Anchor)
}
if second.Count != 2 {
t.Errorf("count = %d, want 2", second.Count)
}
if got := episodeRunMessage(second); got != "2 new episodes are ready to watch." {
t.Errorf("message = %q, want the count once there is more than one", got)
}
}
// Next week's episode is its own news. Televisions dedupe on the alert id forever, so a
// run that reused last week's anchor would be silently swallowed on every set in the house.
func TestIngestRunsStartAfreshOnceTheWindowHasClosed(t *testing.T) {
var runs ingestRuns
now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC)
runs.record("bear|3", "emby-1", "S03E01", now, ingestRunWindow)
later := runs.record("bear|3", "emby-2", "S03E02", now.Add(ingestRunWindow+time.Minute), ingestRunWindow)
if later.Anchor != "emby-2" {
t.Errorf("anchor = %q, want a new run", later.Anchor)
}
if later.Count != 1 {
t.Errorf("count = %d, want a run of one", later.Count)
}
}
// Two shows importing at once are two pieces of news, not one run of four episodes.
func TestIngestRunsAreKeptPerSeason(t *testing.T) {
var runs ingestRuns
now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC)
runs.record(seasonRunKey("The Bear", 3), "bear-1", "S03E01", now, ingestRunWindow)
other := runs.record(seasonRunKey("Slow Horses", 4), "horses-1", "S04E01", now, ingestRunWindow)
if other.Count != 1 || other.Anchor != "horses-1" {
t.Fatalf("other show = %+v, want a run of its own", other)
}
// And the same show under a different spelling is still the same show, the rule the
// schedule row and the ingest worker already match titles by.
same := runs.record(seasonRunKey("the bear!", 3), "bear-2", "S03E02", now, ingestRunWindow)
if same.Anchor != "bear-1" || same.Count != 2 {
t.Fatalf("same season = %+v, want it folded into the first run", same)
}
}
// The tally is memory the gateway can afford to lose, so it must also be memory it cannot
// grow without bound.
func TestIngestRunsAreBounded(t *testing.T) {
var runs ingestRuns
now := time.Date(2026, 8, 18, 19, 4, 0, 0, time.UTC)
for i := 0; i < trackedIngestRuns*2; i++ {
runs.record(time.Duration(i).String(), "item", "S01E01", now, ingestRunWindow)
}
if len(runs.runs) > trackedIngestRuns {
t.Fatalf("tracking %d runs, want a cap of %d", len(runs.runs), trackedIngestRuns)
}
}
// Only an import is news. An upgrade replaced a file that was already watchable, and a
// rename or a delete is housekeeping — announcing any of them trains viewers to look away.
func TestOnlyAnImportIsAnnounced(t *testing.T) {
s := &Server{log: discardLogger()}
for _, reason := range []string{
library.ReasonUpgrade, library.ReasonRename, library.ReasonDelete,
} {
result := episodeResult(3, 5, "Children")
result.Reason = reason
// A nil cache would be reached by publishAlert if this announced anything; it
// returns early on one, so the assertion is that nothing is recorded either.
s.AnnounceLibraryIngest(t.Context(), result)
if len(s.ingestRuns.runs) != 0 {
t.Fatalf("%s was treated as news", reason)
}
}
}
// A series-level result is a rename settling or a show written ahead of its first episode.
// Neither is something anybody can press Play on.
func TestASeriesRefreshIsNotAnnounced(t *testing.T) {
s := &Server{log: discardLogger()}
s.AnnounceLibraryIngest(t.Context(), library.IngestResult{
Source: "sonarr", Kind: library.KindSeries, Reason: library.ReasonImport,
ItemID: "series-1", Name: "The Bear", SeriesName: "The Bear",
})
if len(s.ingestRuns.runs) != 0 {
t.Fatal("a series refresh was announced")
}
}
func TestMovieRunMessageSaysTheFilmIsThere(t *testing.T) {
// The wording is the whole point of moving the announcement behind the scan: the
// banner published from the webhook could only ever promise the film was coming.
run := ingestRun{Anchor: "a", Count: 1, Latest: "S01E01"}
if strings.Contains(episodeRunMessage(run), "shortly") {
t.Error("the message still promises rather than states")
}
}
+1
View File
@@ -61,6 +61,7 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
"/v1/my-shows": "my-shows",
"/admin/api/status": "admin",
"/hooks/radarr": "webhooks",
"/hooks/sonarr": "webhooks",
"/install": "installer",
"/updates/latest.apk": "updates",
"/something-nobody-has-written": "api",
+13
View File
@@ -136,6 +136,19 @@ var preferenceCatalogue = []preferenceDefinition{
Kind: preferenceChoice, Default: defaultThemeID,
Options: themeOptions(),
},
{
// The marks, kept apart from the palette because they are a different decision
// about legibility rather than about taste — a household watching from a sofa may
// well want the solid pack on every scheme they own.
//
// The options come from iconPackOptions() in themes.go for the reason themeId's
// come from the catalogue: a pack added there cannot become a value this rejects,
// and a pack removed cannot stay selectable here.
Key: "iconSet", Name: "Icon set", Area: "Presentation",
Description: "Which set of marks this viewer's televisions draw.",
Kind: preferenceChoice, Default: defaultIconPackID,
Options: iconPackOptions(),
},
{
Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation",
Description: "Tone of the short line shown after signing in.",
+39 -68
View File
@@ -3,10 +3,10 @@ package api
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
)
// Radarr's import notification arrives as a webhook, which is why this is the one part
@@ -31,6 +31,30 @@ type radarrWebhookPayload struct {
ID int `json:"id"`
Quality string `json:"quality"`
} `json:"movieFile"`
// The delete events carry the file id at the top level rather than under movieFile,
// and say whether the media went with the entry. Both are read by the catalogue half
// only; the banner has nothing to say about a deletion.
MovieFileID int `json:"movieFileId"`
DeletedFiles bool `json:"deletedFiles"`
}
// ingestPayload hands the same notification to the catalogue rules.
//
// Written out rather than shared as one struct because the two halves genuinely read
// different fields for different reasons — the banner wants the quality string, the
// catalogue wants the deletion flags — and a single type would grow whichever field
// either of them needed next.
func ingestPayload(payload radarrWebhookPayload) library.RadarrWebhook {
var out library.RadarrWebhook
out.EventType = payload.EventType
out.IsUpgrade = payload.IsUpgrade
out.Movie.ID = payload.Movie.ID
out.Movie.Title = payload.Movie.Title
out.Movie.Year = payload.Movie.Year
out.MovieFile.ID = payload.MovieFile.ID
out.MovieFileID = payload.MovieFileID
out.DeletedFiles = payload.DeletedFiles
return out
}
// handleRadarrWebhook accepts Radarr's "On Import" notification.
@@ -66,23 +90,20 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
return
}
alert, ok := radarrImportAlert(payload, time.Now().UTC())
if !ok {
// A grab, a rename, a health check or an upgrade of something already in the
// library: all real events, none of them "a new film is here".
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
return
// Recording is now the whole of what this hook does. The banner used to be published
// from right here, which meant it was published before Emby had scanned the film in —
// hence its wording, that the film would be available "shortly". It is announced from
// the far end of the scan instead (AnnounceLibraryIngest), where it can say the film is
// actually there and where an episode can be announced on the same terms.
//
// An upgrade is still recorded and still silent as news: the file genuinely changed, so
// the row must be re-read, but the film was already there.
queued := s.queueIngest(r, "radarr", payload.EventType, library.RadarrRequests(ingestPayload(payload)))
if queued > 0 {
s.loggerFor(r.Context()).Debug("radarr import recorded",
"movie", payload.Movie.Title, "quality", payload.MovieFile.Quality)
}
window := s.radarrAlertWindow()
if window <= 0 {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
return
}
s.publishAlert(r.Context(), alert, window)
s.loggerFor(r.Context()).Info("radarr import announced",
"movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": queued})
}
// webhookToken accepts the shared secret three ways because Radarr's webhook settings
@@ -100,53 +121,3 @@ func webhookToken(r *http.Request) string {
}
return strings.TrimSpace(r.URL.Query().Get("token"))
}
// radarrImportAlert turns an import notification into the banner a TV shows, or reports
// that this event is not worth announcing.
//
// An upgrade is deliberately silent: the film was already there, and "new movie added"
// would be a lie about a file that was replaced with a better copy.
func radarrImportAlert(payload radarrWebhookPayload, now time.Time) (clientAlert, bool) {
if !isRadarrImportEvent(payload.EventType) || payload.IsUpgrade {
return clientAlert{}, false
}
title := strings.TrimSpace(payload.Movie.Title)
if title == "" || payload.Movie.ID <= 0 {
return clientAlert{}, false
}
// Keyed on the file, so a title deleted and re-imported is news again while a
// repeated delivery of the same import is not. Clients dedupe on this id forever.
id := fmt.Sprintf("radarr:%d:file:%d", payload.Movie.ID, payload.MovieFile.ID)
if payload.MovieFile.ID <= 0 {
id = fmt.Sprintf("radarr:%d:imported:%d", payload.Movie.ID, now.Unix())
}
name := title
if payload.Movie.Year > 0 {
name = fmt.Sprintf("%s (%d)", title, payload.Movie.Year)
}
return clientAlert{
ID: id,
Kind: alertKindRadarrImport,
Label: "NEW MOVIE ADDED",
Title: name,
Message: fmt.Sprintf("%s will be available in Emby shortly.", title),
// The image proxy already serves Radarr covers under this id and tag, so the
// banner shows the poster before Emby has finished scanning the film in.
ItemID: fmt.Sprintf("radarr:%d", payload.Movie.ID),
ImageTag: "radarr",
AiredAt: now.UTC().Format(time.RFC3339),
}, true
}
// isRadarrImportEvent matches the event Radarr fires once a downloaded file has been
// imported into the library. The name has moved between versions, so both are accepted.
func isRadarrImportEvent(eventType string) bool {
switch strings.ToLower(strings.TrimSpace(eventType)) {
case "download", "moviefileimported":
return true
default:
return false
}
}
-90
View File
@@ -12,96 +12,6 @@ import (
"github.com/ponzischeme89/memby/server/internal/config"
)
func importPayload(movieID, fileID int, title string, year int) radarrWebhookPayload {
var payload radarrWebhookPayload
payload.EventType = "Download"
payload.Movie.ID = movieID
payload.Movie.Title = title
payload.Movie.Year = year
payload.MovieFile.ID = fileID
return payload
}
func TestRadarrImportAlertAnnouncesANewFilm(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
alert, ok := radarrImportAlert(importPayload(412, 9001, "Mr. Smith Goes to Washington", 1939), now)
if !ok {
t.Fatal("expected an import to be announced")
}
if alert.ID != "radarr:412:file:9001" {
t.Errorf("alert id = %q, want it keyed on the imported file", alert.ID)
}
if alert.Kind != alertKindRadarrImport {
t.Errorf("kind = %q, want %q", alert.Kind, alertKindRadarrImport)
}
if alert.Label == "" {
t.Error("want a label: the app cannot know the wording for a kind it predates")
}
if alert.Title != "Mr. Smith Goes to Washington (1939)" {
t.Errorf("title = %q, want the year alongside it", alert.Title)
}
if !strings.Contains(alert.Message, "available in Emby shortly") {
t.Errorf("message = %q, want it to say the film is on its way", alert.Message)
}
// The image proxy serves Radarr covers under this pair, so the banner has a poster
// before Emby has scanned the film in.
if alert.ItemID != "radarr:412" || alert.ImageTag != "radarr" {
t.Errorf("artwork = %q/%q, want the radarr media cover", alert.ItemID, alert.ImageTag)
}
if alert.AiredAt != now.Format(time.RFC3339) {
t.Errorf("airedAt = %q, want the import time so it sorts with the rest", alert.AiredAt)
}
}
func TestRadarrImportAlertIgnoresEventsThatAreNotANewFilm(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
upgrade := importPayload(412, 9002, "Mr. Smith Goes to Washington", 1939)
upgrade.IsUpgrade = true
grab := importPayload(413, 0, "Some Film", 2024)
grab.EventType = "Grab"
untitled := importPayload(414, 9003, " ", 2024)
unknownMovie := importPayload(0, 9004, "No Id", 2024)
for name, payload := range map[string]radarrWebhookPayload{
"quality upgrade of a film already there": upgrade,
"grabbed but not imported": grab,
"no title": untitled,
"no movie id": unknownMovie,
} {
if _, ok := radarrImportAlert(payload, now); ok {
t.Errorf("%s: expected no alert", name)
}
}
}
func TestRadarrImportAlertAcceptsTheNewerEventName(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
payload := importPayload(415, 9005, "Rear Window", 1954)
payload.EventType = "MovieFileImported"
if _, ok := radarrImportAlert(payload, now); !ok {
t.Error("expected the alternate import event name to be announced")
}
}
// A file id is what makes a repeated notification the same news; without one the id
// falls back to the clock so a re-import is not silently swallowed.
func TestRadarrImportAlertWithoutAFileIDIsStillAnnounced(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
alert, ok := radarrImportAlert(importPayload(416, 0, "Sabotage", 1936), now)
if !ok {
t.Fatal("expected an alert")
}
if !strings.HasPrefix(alert.ID, "radarr:416:imported:") {
t.Errorf("alert id = %q, want a time-keyed fallback", alert.ID)
}
}
func TestAppendAlertPrunesExpiredAndDeduplicates(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
existing := []storedAlert{
+81 -4
View File
@@ -68,6 +68,39 @@ const (
decorationBlossom = "blossom"
)
// The icon packs a theme may draw its marks from. Slugs, for the reason the decorations
// above are slugs: the shapes live on the television, in ui/theme/MembyIconPacks.kt, and
// the gateway has no business describing geometry to it. A client that does not recognise
// one draws the marks it shipped with — so this list may gain a pack before the fleet has
// the build that knows it, the MembyHeroLabel precedent again.
//
// The reason to want any of this is that Material's marks are the marks every Android app
// on the television already wears. Moving a household off them is a decision an operator
// should be able to make in the gateway, not one that waits on an APK reaching every set.
const (
iconPackMaterial = "material"
iconPackLucide = "lucide"
iconPackFontAwesome = "fontawesome"
)
// defaultIconPackID is the marks the app shipped with, so nothing changes appearance on the
// day this lands.
const defaultIconPackID = iconPackMaterial
// iconPackOptions is the vocabulary of the `iconSet` preference, and the only place a pack
// is declared. A pack this list does not name is one no viewer and no operator can select.
//
// The wording is about how the marks read from a sofa, because that is the whole of the
// choice: stroke sets are drawn for 16-24px on a monitor, and on a rail chip at three
// metres they go thin where a solid mark keeps its shape.
func iconPackOptions() []preferenceOption {
return []preferenceOption{
option(iconPackMaterial, "Material — Android's own marks"),
option(iconPackLucide, "Lucide — lighter, drawn as outlines"),
option(iconPackFontAwesome, "Font Awesome — solid, clearest at a distance"),
}
}
type themeDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -83,6 +116,14 @@ type themeDefinition struct {
// it. It is empty on every selectable theme by construction rather than by a check at
// the point of use.
Decoration string `json:"decoration,omitempty"`
// IconSet lets a theme bring its own marks, and like Decoration only a seasonal theme
// carries one. Empty means the viewer's own choice stands.
//
// The asymmetry with the palette is deliberate. A season *is* a look, so it may say
// what the marks are; a scheme somebody picked to live with all year must not silently
// take their marks away, because there would be no way to tell which of the two
// choices had done it.
IconSet string `json:"iconSet,omitempty"`
}
const (
@@ -164,6 +205,7 @@ var themeCatalogue = []themeDefinition{
ID: themeHalloween, Name: "Halloween", Seasonal: true,
Description: "Pumpkin orange on black, for the last week of October.",
Decoration: decorationBats,
IconSet: iconPackFontAwesome,
Palette: themePalette{
Surface: "#FF0A0704", SurfaceRaised: "#FF17100A", Accent: "#FFFF8A1F",
OnSurface: "#FFF2E7DA", MutedText: "#FFE2D2BE", QuietText: "#FFBBA48C",
@@ -341,6 +383,12 @@ type resolvedTheme struct {
// theme by the television — a set holding a cached Christmas palette must not keep
// snowing after the switch has been thrown.
Decoration string `json:"decoration,omitempty"`
// IconSet is the pack the television draws its marks from: "material", "lucide",
// "fontawesome". Resolved here rather than derived on the set from the theme id,
// exactly as Decoration is — a television holding a cached seasonal answer must stop
// using that season's marks when the switch is thrown, and it has no way to know that
// on its own.
IconSet string `json:"iconSet,omitempty"`
// Reason is the sentence the picker prints while it is locked. The gateway's wording,
// the MembyHeroLabel precedent, so a season invented later reads correctly on today's
// build rather than as a blank space where an explanation should be.
@@ -364,6 +412,7 @@ type resolvedTheme struct {
// a season. The only switch is seasonalEnabled, and that is the operator's feature flag.
func resolveTheme(
chosen string,
chosenIconPack string,
allowed []string,
seasonalEnabled bool,
decorationsEnabled bool,
@@ -394,15 +443,38 @@ func resolveTheme(
decoration = applied.Decoration
}
// The viewer's marks, unless the season brought its own. Note that this is *not* gated
// on decorationsEnabled: that switch is about the cost of a continuous animation on a
// weak box, and a set of icons costs nothing to draw.
iconPack := chosenIconPack
if !knownIconPack(iconPack) {
iconPack = defaultIconPackID
}
if seasonal && applied.IconSet != "" {
iconPack = applied.IconSet
}
resolved := resolvedTheme{
ID: applied.ID, Name: applied.Name, Palette: applied.Palette,
Seasonal: seasonal, Locked: seasonal, Chosen: pick.ID, Reason: reason,
Decoration: decoration,
Decoration: decoration, IconSet: iconPack,
}
resolved.Revision = themeRevision(resolved)
return resolved
}
// knownIconPack keeps an unreadable or retired slug out of the answer. The television
// would fall back on its own — membyIconPackFor answers with Material for anything it does
// not know — but a gateway that echoed a pack nobody can draw would make every set in the
// house look broken in the same way while reporting that it had done what it was asked.
func knownIconPack(id string) bool {
switch id {
case iconPackMaterial, iconPackLucide, iconPackFontAwesome:
return true
}
return false
}
// themeAllowed applies the operator's per-user list. An empty list is *permissive*: no row
// has ever been written for the great majority of households, and reading that as "this
// person may have no themes" would empty every picker in the house the day this ships.
@@ -422,7 +494,7 @@ func themeRevision(resolved resolvedTheme) string {
for _, part := range []string{
strconv.Itoa(themeSchemaVersion), resolved.ID, resolved.Chosen,
strconv.FormatBool(resolved.Seasonal), strconv.FormatBool(resolved.Locked), resolved.Reason,
resolved.Decoration,
resolved.Decoration, resolved.IconSet,
palette.Surface, palette.SurfaceRaised, palette.Accent, palette.OnSurface,
palette.MutedText, palette.QuietText, palette.Hairline, palette.RatingsSurface,
} {
@@ -442,12 +514,17 @@ func themeRevision(resolved resolvedTheme) string {
// palette it falls back to is the one the app shipped with.
func (s *Server) themeFor(ctx context.Context, sess store.Session) resolvedTheme {
chosen, _ := preferenceDefault("themeId").(string)
iconPack, _ := preferenceDefault("iconSet").(string)
allowed := []string(nil)
if s.store != nil && sess.EmbyUserID != "" {
if stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID); err == nil {
if value, ok := decodePreferences(stored.Preferences)["themeId"].(string); ok {
decoded := decodePreferences(stored.Preferences)
if value, ok := decoded["themeId"].(string); ok {
chosen = value
}
if value, ok := decoded["iconSet"].(string); ok {
iconPack = value
}
} else {
s.loggerFor(ctx).Warn("theme preference unavailable", "error", err)
}
@@ -458,7 +535,7 @@ func (s *Server) themeFor(ctx context.Context, sess store.Session) resolvedTheme
}
}
return resolveTheme(
chosen, allowed,
chosen, iconPack, allowed,
s.featureEnabled(ctx, featureSeasonalThemes),
s.featureEnabled(ctx, featureSeasonalDecorations),
s.now(),
+96 -11
View File
@@ -77,7 +77,7 @@ func TestEasterWindowIsTheLongWeekend(t *testing.T) {
// A season is the one thing on this feature nobody on a television can decline, so the
// tests that matter most are the ones asserting that no argument suppresses it.
func TestSeasonOutranksTheViewer(t *testing.T) {
resolved := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20))
resolved := resolveTheme(themePlum, "", nil, true, true, date(2026, time.December, 20))
if resolved.ID != themeChristmas {
t.Fatalf("applied theme = %q, want %q", resolved.ID, themeChristmas)
}
@@ -98,7 +98,7 @@ func TestSeasonOutranksTheViewer(t *testing.T) {
// grantable per person, and an operator restricting a viewer to one palette must not be a
// way of exempting them from Christmas.
func TestAllowlistDoesNotApplyToSeasons(t *testing.T) {
resolved := resolveTheme(themePlum, []string{themeEmber}, true, true, date(2026, time.October, 31))
resolved := resolveTheme(themePlum, "", []string{themeEmber}, true, true, date(2026, time.October, 31))
if resolved.ID != themeHalloween {
t.Fatalf("applied theme = %q, want %q", resolved.ID, themeHalloween)
}
@@ -110,7 +110,7 @@ func TestAllowlistDoesNotApplyToSeasons(t *testing.T) {
}
func TestSeasonsOffLeavesTheViewersChoice(t *testing.T) {
resolved := resolveTheme(themeEmber, nil, false, true, date(2026, time.December, 20))
resolved := resolveTheme(themeEmber, "", nil, false, true, date(2026, time.December, 20))
if resolved.ID != themeEmber {
t.Fatalf("applied theme = %q, want %q", resolved.ID, themeEmber)
}
@@ -138,7 +138,7 @@ func TestResolveThemeFallbacks(t *testing.T) {
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
got := resolveTheme(testCase.chosen, testCase.allowed, true, true, ordinary)
got := resolveTheme(testCase.chosen, "", testCase.allowed, true, true, ordinary)
if got.ID != testCase.want {
t.Fatalf("resolveTheme(%q, %v) = %q, want %q",
testCase.chosen, testCase.allowed, got.ID, testCase.want)
@@ -150,14 +150,14 @@ func TestResolveThemeFallbacks(t *testing.T) {
// The revision is the whole delivery mechanism: a television refetches the palette only when
// this moves. If it did not move when a season began, no set in the house would repaint.
func TestThemeRevisionTracksTheAnswer(t *testing.T) {
ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14))
christmas := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20))
ordinary := resolveTheme(themePlum, "", nil, true, true, date(2026, time.June, 14))
christmas := resolveTheme(themePlum, "", nil, true, true, date(2026, time.December, 20))
if ordinary.Revision == christmas.Revision {
t.Fatal("the revision must change when the season does, or nothing refetches")
}
// And it must be stable, or every poll would look like a change and every set would
// fetch the palette six times a minute.
again := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 15))
again := resolveTheme(themePlum, "", nil, true, true, date(2026, time.June, 15))
if ordinary.Revision != again.Revision {
t.Fatalf("the revision moved on an ordinary day: %s then %s", ordinary.Revision, again.Revision)
}
@@ -168,10 +168,10 @@ func TestThemeRevisionTracksTheAnswer(t *testing.T) {
// every day of the year.
func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) {
christmas := date(2026, time.December, 20)
if got := resolveTheme(themePlum, nil, true, true, christmas); got.Decoration != decorationSnow {
if got := resolveTheme(themePlum, "", nil, true, true, christmas); got.Decoration != decorationSnow {
t.Fatalf("decoration = %q, want %q", got.Decoration, decorationSnow)
}
off := resolveTheme(themePlum, nil, true, false, christmas)
off := resolveTheme(themePlum, "", nil, true, false, christmas)
if off.Decoration != "" {
t.Fatalf("decorations off still returned %q", off.Decoration)
}
@@ -179,10 +179,10 @@ func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) {
t.Fatal("turning decorations off must keep the seasonal palette")
}
// The revision has to move, or a set already snowing is never told to stop.
if off.Revision == resolveTheme(themePlum, nil, true, true, christmas).Revision {
if off.Revision == resolveTheme(themePlum, "", nil, true, true, christmas).Revision {
t.Fatal("the revision must change when the decoration does")
}
ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14))
ordinary := resolveTheme(themePlum, "", nil, true, true, date(2026, time.June, 14))
if ordinary.Decoration != "" {
t.Fatalf("a chosen theme carries a decoration: %q", ordinary.Decoration)
}
@@ -277,3 +277,88 @@ func TestNormalizeThemeAllowlist(t *testing.T) {
t.Fatalf("allowlist is not in catalogue order: %v", ordered)
}
}
// --- Icon packs ---------------------------------------------------------------------------
// The gateway's half of the wire contract with ui/theme/MembyIconPacks.kt. Its own
// MembyIconPackTest pins the same three slugs from the television's end; change one
// without the other and a household is sent marks nothing can draw.
func TestIconPackOptionsAreTheKnownPacks(t *testing.T) {
for _, option := range iconPackOptions() {
if !knownIconPack(option.Value) {
t.Fatalf("offered icon pack %q is not one the resolver will accept", option.Value)
}
}
if !knownIconPack(defaultIconPackID) {
t.Fatalf("the default icon pack %q is not selectable", defaultIconPackID)
}
}
// A slug the resolver does not recognise must never reach a television. The set would fall
// back on its own, but a gateway echoing a retired pack would make every screen in the
// house look wrong in the same way while reporting it had done what it was asked.
func TestUnknownIconPackFallsBackToTheShippedMarks(t *testing.T) {
ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC)
for _, chosen := range []string{"", "tabler", "material "} {
got := resolveTheme(themeMidnight, chosen, nil, true, true, ordinary)
if got.IconSet != defaultIconPackID {
t.Fatalf("resolveTheme(icon %q) = %q, want %q", chosen, got.IconSet, defaultIconPackID)
}
}
}
// The viewer's marks stand on every scheme they can choose, and only a season may replace
// them — the same asymmetry decorations have, and for the same reason: a season is a look,
// where a scheme somebody picked to live with all year must not silently take their marks
// away with no way to tell which choice did it.
func TestOnlyASeasonMayReplaceTheViewersMarks(t *testing.T) {
ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC)
if got := resolveTheme(themePlum, iconPackLucide, nil, true, true, ordinary); got.IconSet != iconPackLucide {
t.Fatalf("an ordinary day = %q, want the viewer's %q", got.IconSet, iconPackLucide)
}
halloween := time.Date(2026, time.October, 30, 20, 0, 0, 0, time.UTC)
got := resolveTheme(themePlum, iconPackLucide, nil, true, true, halloween)
if !got.Seasonal {
t.Fatalf("expected the Halloween window to be seasonal")
}
if got.IconSet != iconPackFontAwesome {
t.Fatalf("Halloween = %q, want its own %q", got.IconSet, iconPackFontAwesome)
}
if got.Chosen != themePlum {
t.Fatalf("the viewer's own choice should survive underneath a season, got %q", got.Chosen)
}
// Seasons off: the viewer keeps both halves.
if off := resolveTheme(themePlum, iconPackLucide, nil, false, true, halloween); off.IconSet != iconPackLucide {
t.Fatalf("with seasons off = %q, want the viewer's %q", off.IconSet, iconPackLucide)
}
}
// Only seasonal themes may declare marks of their own, the rule that holds the asymmetry
// above in place by construction rather than by a check at the point of use.
func TestOnlySeasonalThemesDeclareAnIconSet(t *testing.T) {
for _, theme := range themeCatalogue {
if theme.Seasonal {
if theme.IconSet != "" && !knownIconPack(theme.IconSet) {
t.Fatalf("%s names an icon pack %q nothing can draw", theme.ID, theme.IconSet)
}
continue
}
if theme.IconSet != "" {
t.Fatalf("selectable theme %s takes the viewer's marks away", theme.ID)
}
}
}
// The revision is the entire delivery mechanism: televisions compare it and refetch only
// when it moves. A pack change the hash did not notice would reach nobody until something
// else about the theme happened to change.
func TestThemeRevisionTracksTheIconSet(t *testing.T) {
ordinary := time.Date(2026, time.May, 3, 12, 0, 0, 0, time.UTC)
material := resolveTheme(themeMidnight, iconPackMaterial, nil, true, true, ordinary)
lucide := resolveTheme(themeMidnight, iconPackLucide, nil, true, true, ordinary)
if material.Revision == lucide.Revision {
t.Fatalf("the same revision %q for two different icon packs", material.Revision)
}
}
+1 -1
View File
@@ -1 +1 @@
0.1.53
0.1.55
+11
View File
@@ -131,6 +131,15 @@ type Config struct {
// touching the five-day schedule row.
SonarrAlertWindow time.Duration
// SonarrWebhookToken guards the Sonarr import/upgrade/rename/delete webhook. Empty
// means the hook 404s, the stance the Radarr one takes.
SonarrWebhookToken string
// IngestSettleDelay is how long after a webhook the gateway first looks for the file
// in Emby. Sonarr fires the moment it has moved the file into place and Emby has not
// scanned it yet, so asking immediately spends a request to learn nothing.
IngestSettleDelay time.Duration
// Radarr is optional. Its calendar supplies the five-day digital movie release row.
RadarrURL string
RadarrAPIKey string
@@ -247,6 +256,8 @@ func Load() (Config, error) {
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour),
SonarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_SONARR_WEBHOOK_TOKEN")),
IngestSettleDelay: duration("MEMBY_ARR_INGEST_SETTLE", time.Minute),
RadarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RADARR_URL")), "/"),
RadarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_RADARR_API_KEY")),
RadarrTTL: duration("MEMBY_RADARR_TTL", 5*time.Minute),
+355
View File
@@ -0,0 +1,355 @@
package library
import (
"fmt"
"strings"
)
// Turning a Sonarr or Radarr notification into a piece of work, and all of it pure.
//
// The gateway used to learn that a file had appeared by asking Emby every hour whether
// anything had been saved since the last time it asked. Sonarr and Radarr already know —
// they are the things that put the file there — so this is the translation from what they
// say into the one question the ingest worker answers: which item should be re-read, or
// removed, and how do two deliveries of the same news collapse into one.
//
// Nothing here does I/O, which is what lets every rule below be a table test.
// Ingest actions. A rename is deliberately a refresh like any other: the Emby item id
// survives a file being moved, and so does the credits fingerprint measured against it —
// the only stale thing is the row's payload.
const (
ActionRefresh = "refresh"
ActionRemove = "remove"
)
// Ingest kinds.
const (
KindEpisode = "episode"
KindMovie = "movie"
KindSeries = "series"
)
// Why a request exists. It is carried through to the log and the console, because "this
// episode was re-read because Sonarr upgraded the file" is the sentence an operator needs
// and "an item changed" is not.
const (
ReasonImport = "import"
ReasonUpgrade = "upgrade"
ReasonRename = "rename"
ReasonDelete = "delete"
)
// IngestRequest is one piece of work. It carries what the *arr knew rather than an Emby
// id, because at the moment a webhook arrives Emby has very often not scanned the file in
// yet and there is no id to carry.
type IngestRequest struct {
// Key is the dedupe identity, and it names the *file* rather than the event. Two
// deliveries of one import collapse onto one row; a file deleted and re-imported is a
// different file and therefore its own work. Same reasoning as the alert id in
// radarrImportAlert.
Key string `json:"-"`
Action string `json:"-"`
Kind string `json:"-"`
Reason string `json:"-"`
// Series identity, for an episode or a series-level event.
Series string `json:"series,omitempty"`
SeriesYear int `json:"seriesYear,omitempty"`
Season int `json:"season,omitempty"`
Episode int `json:"episode,omitempty"`
// Film identity.
Title string `json:"title,omitempty"`
Year int `json:"year,omitempty"`
// EmbyItemID is filled in only where the caller already knows it — a delete of
// something the catalogue holds. Empty is the ordinary case.
EmbyItemID string `json:"embyItemId,omitempty"`
}
// IngestResult is a finished piece of ingest work, handed to whoever wants to announce it.
//
// It is what makes "a scan has completed" a thing the gateway can say: a webhook only means
// the *arr has moved a file, and the several minutes between that and Emby having scanned
// it in are exactly the minutes in which a banner saying the title is there would be wrong.
// This is emitted from the other end, once the row is in the catalogue.
//
// It carries both what the *arr said and what Emby turned out to call the thing, because
// the news is about the title and the item id is what can put artwork behind it.
type IngestResult struct {
Source string // sonarr | radarr
Kind string // KindEpisode | KindMovie | KindSeries
Reason string // ReasonImport | ReasonUpgrade | ReasonRename | ReasonDelete
// ItemID and Name are Emby's, filled in from the row that was just written. ItemID is
// empty for a series-wide refresh, which is about a show rather than about one file.
ItemID string
Name string
ImageTag string
// SeriesName is Emby's name for the show an episode belongs to, which is what a banner
// leads with — the episode's own Name is its title.
SeriesName string
Season int
Episode int
Year int
}
// SonarrWebhook is the subset of Sonarr's body this reads. Sonarr sends considerably
// more; anything not named here is ignored on purpose, so a Sonarr upgrade that adds
// fields cannot break the hook.
type SonarrWebhook struct {
EventType string `json:"eventType"`
Series struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
} `json:"series"`
Episodes []struct {
ID int `json:"id"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
} `json:"episodes"`
EpisodeFile struct {
ID int `json:"id"`
SeasonNumber int `json:"seasonNumber"`
} `json:"episodeFile"`
// RenamedEpisodeFiles is what On Rename carries: the files that moved, each with the
// id the library already knows them by.
RenamedEpisodeFiles []struct {
ID int `json:"id"`
SeasonNumber int `json:"seasonNumber"`
} `json:"renamedEpisodeFiles"`
IsUpgrade bool `json:"isUpgrade"`
// DeletedFiles marks a series delete that took the media with it. A series removed
// from Sonarr's list while its files stay on disk is not a reason to forget it.
DeletedFiles bool `json:"deletedFiles"`
}
// RadarrWebhook is the same narrow reading of Radarr's body.
type RadarrWebhook struct {
EventType string `json:"eventType"`
IsUpgrade bool `json:"isUpgrade"`
Movie struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
} `json:"movie"`
MovieFile struct {
ID int `json:"id"`
} `json:"movieFile"`
MovieFileID int `json:"movieFileId"`
DeletedFiles bool `json:"deletedFiles"`
}
// IsTestEvent reports the payload a webhook's Test button sends. It is answered 200 and
// enqueues nothing, which is what makes that button mean "reachable" rather than
// "reachable, and here is a row about a series that does not exist".
func IsTestEvent(eventType string) bool {
return strings.EqualFold(strings.TrimSpace(eventType), "Test")
}
// SonarrRequests turns one Sonarr notification into the work it implies.
//
// A notification can name several episodes — a multi-episode file, or a rename that moved
// a season — so this answers a slice. Each carries its own key, because each is its own
// file and the two may well arrive again separately.
func SonarrRequests(payload SonarrWebhook) []IngestRequest {
event := strings.ToLower(strings.TrimSpace(payload.EventType))
title := strings.TrimSpace(payload.Series.Title)
switch event {
case "download", "episodefileimported":
if title == "" || len(payload.Episodes) == 0 {
return nil
}
reason := ReasonImport
if payload.IsUpgrade {
// The file genuinely changed, so the row must be re-read. That it is not
// *news* is a separate judgement, made by the alert half.
reason = ReasonUpgrade
}
out := make([]IngestRequest, 0, len(payload.Episodes))
for _, episode := range payload.Episodes {
out = append(out, IngestRequest{
Key: sonarrEpisodeKey(payload.EpisodeFile.ID, episode.ID),
Action: ActionRefresh,
Kind: KindEpisode,
Reason: reason,
Series: title,
SeriesYear: payload.Series.Year,
Season: episode.SeasonNumber,
Episode: episode.EpisodeNumber,
})
}
return out
case "rename":
if title == "" {
return nil
}
// A rename names files rather than episodes, and Sonarr does not say which episode
// each file held. The series is the unit of work: one re-read of the show's
// episodes settles every file that moved, and a season rename would otherwise be
// one request per episode for the same answer.
return []IngestRequest{{
Key: fmt.Sprintf("sonarr:series:%d:rename", payload.Series.ID),
Action: ActionRefresh,
Kind: KindSeries,
Reason: ReasonRename,
Series: title,
SeriesYear: payload.Series.Year,
}}
case "episodefiledelete", "episodefiledeleted":
if title == "" {
return nil
}
season, episode := deletedEpisodeNumbers(payload)
if episode <= 0 {
return nil
}
return []IngestRequest{{
Key: fmt.Sprintf("sonarr:episodefile:%d:delete", payload.EpisodeFile.ID),
Action: ActionRemove,
Kind: KindEpisode,
Reason: ReasonDelete,
Series: title,
SeriesYear: payload.Series.Year,
Season: season,
Episode: episode,
}}
case "seriesdelete", "seriesdeleted":
// Only a delete that took the files. A series unfollowed in Sonarr while its
// episodes stay on disk is still in the library and must stay in the catalogue.
if title == "" || !payload.DeletedFiles {
return nil
}
return []IngestRequest{{
Key: fmt.Sprintf("sonarr:series:%d:delete", payload.Series.ID),
Action: ActionRemove,
Kind: KindSeries,
Reason: ReasonDelete,
Series: title,
SeriesYear: payload.Series.Year,
}}
}
return nil
}
// sonarrEpisodeKey prefers the file id, which is the thing that actually changed. Sonarr
// omits it on some versions of the import event, and the episode id is then the only
// stable identity available — coarser, since it does not change when the file is
// replaced, but a repeated delivery still collapses, which is what the key is for.
func sonarrEpisodeKey(fileID, episodeID int) string {
if fileID > 0 {
return fmt.Sprintf("sonarr:episodefile:%d:%d", fileID, episodeID)
}
return fmt.Sprintf("sonarr:episode:%d", episodeID)
}
// deletedEpisodeNumbers reads the position of a deleted file. The episode list is
// preferred because it carries the episode number; the file's own season number stands in
// where the list is absent.
func deletedEpisodeNumbers(payload SonarrWebhook) (int, int) {
for _, episode := range payload.Episodes {
if episode.EpisodeNumber > 0 {
season := episode.SeasonNumber
if season == 0 && payload.EpisodeFile.SeasonNumber > 0 {
season = payload.EpisodeFile.SeasonNumber
}
return season, episode.EpisodeNumber
}
}
return payload.EpisodeFile.SeasonNumber, 0
}
// RadarrRequests turns one Radarr notification into the work it implies.
func RadarrRequests(payload RadarrWebhook) []IngestRequest {
event := strings.ToLower(strings.TrimSpace(payload.EventType))
title := strings.TrimSpace(payload.Movie.Title)
if title == "" || payload.Movie.ID <= 0 {
return nil
}
fileID := payload.MovieFile.ID
if fileID <= 0 {
fileID = payload.MovieFileID
}
switch event {
case "download", "moviefileimported":
reason := ReasonImport
if payload.IsUpgrade {
reason = ReasonUpgrade
}
return []IngestRequest{{
Key: radarrFileKey(payload.Movie.ID, fileID, reason),
Action: ActionRefresh,
Kind: KindMovie,
Reason: reason,
Title: title,
Year: payload.Movie.Year,
}}
case "rename":
return []IngestRequest{{
Key: fmt.Sprintf("radarr:movie:%d:rename", payload.Movie.ID),
Action: ActionRefresh,
Kind: KindMovie,
Reason: ReasonRename,
Title: title,
Year: payload.Movie.Year,
}}
case "moviefiledelete", "moviefiledeleted":
return []IngestRequest{{
Key: radarrFileKey(payload.Movie.ID, fileID, ReasonDelete),
Action: ActionRemove,
Kind: KindMovie,
Reason: ReasonDelete,
Title: title,
Year: payload.Movie.Year,
}}
case "moviedelete", "moviedeleted":
if !payload.DeletedFiles {
return nil
}
return []IngestRequest{{
Key: fmt.Sprintf("radarr:movie:%d:delete", payload.Movie.ID),
Action: ActionRemove,
Kind: KindMovie,
Reason: ReasonDelete,
Title: title,
Year: payload.Movie.Year,
}}
}
return nil
}
func radarrFileKey(movieID, fileID int, reason string) string {
if fileID > 0 {
return fmt.Sprintf("radarr:moviefile:%d:%s", fileID, reason)
}
return fmt.Sprintf("radarr:movie:%d:%s", movieID, reason)
}
// NormalizedTitle strips a title down to the letters and digits it shares with whatever
// the other system calls it, so "Marvel's Daredevil" and "Marvels Daredevil" are one show.
//
// It lives here because both halves of the gateway need it and there must be exactly one
// answer to "which show is this": the schedule row matches Sonarr titles against the Emby
// catalogue with it, and the ingest worker matches the same titles against Emby itself.
func NormalizedTitle(value string) string {
return strings.Map(func(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A')
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
return -1
}, value)
}
+229
View File
@@ -0,0 +1,229 @@
package library
import "testing"
func sonarrDownload(upgrade bool) SonarrWebhook {
var payload SonarrWebhook
payload.EventType = "Download"
payload.IsUpgrade = upgrade
payload.Series.ID = 12
payload.Series.Title = "Blue Bloods"
payload.Series.Year = 2010
payload.EpisodeFile.ID = 8123
payload.Episodes = append(payload.Episodes, struct {
ID int `json:"id"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
}{ID: 551, SeasonNumber: 6, EpisodeNumber: 7})
return payload
}
func TestSonarrImportBecomesOneEpisodeRefresh(t *testing.T) {
requests := SonarrRequests(sonarrDownload(false))
if len(requests) != 1 {
t.Fatalf("expected one request, got %d", len(requests))
}
request := requests[0]
if request.Action != ActionRefresh || request.Kind != KindEpisode {
t.Fatalf("unexpected shape: %+v", request)
}
if request.Reason != ReasonImport {
t.Fatalf("expected an import, got %q", request.Reason)
}
if request.Series != "Blue Bloods" || request.Season != 6 || request.Episode != 7 {
t.Fatalf("unexpected identity: %+v", request)
}
}
// An upgrade is silent as *news* and is still a reason to re-read the row: the file
// genuinely changed. Conflating those two judgements is how a replaced file would keep a
// catalogue entry describing the copy it replaced.
func TestSonarrUpgradeStillRefreshes(t *testing.T) {
requests := SonarrRequests(sonarrDownload(true))
if len(requests) != 1 || requests[0].Reason != ReasonUpgrade {
t.Fatalf("expected one upgrade refresh, got %+v", requests)
}
if requests[0].Action != ActionRefresh {
t.Fatalf("an upgrade must refresh, got %q", requests[0].Action)
}
}
// The key names the file, so two deliveries of one import are one piece of work. Both
// *arrs re-notify on retry and neither promises exactly-once.
func TestRepeatedDeliveryKeepsOneKey(t *testing.T) {
first := SonarrRequests(sonarrDownload(false))
second := SonarrRequests(sonarrDownload(false))
if first[0].Key != second[0].Key {
t.Fatalf("the same import produced two keys: %q and %q", first[0].Key, second[0].Key)
}
// A different file for the same episode is different work, or a replacement would be
// swallowed by the row its predecessor left behind.
replaced := sonarrDownload(true)
replaced.EpisodeFile.ID = 9001
if SonarrRequests(replaced)[0].Key == first[0].Key {
t.Fatal("a replacement file must not reuse the previous file's key")
}
}
// A multi-episode file names several episodes and each is its own row, because each may
// well be delivered again on its own.
func TestSonarrMultiEpisodeFileProducesOneRequestEach(t *testing.T) {
payload := sonarrDownload(false)
payload.Episodes = append(payload.Episodes, struct {
ID int `json:"id"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
}{ID: 552, SeasonNumber: 6, EpisodeNumber: 8})
requests := SonarrRequests(payload)
if len(requests) != 2 {
t.Fatalf("expected two requests, got %d", len(requests))
}
if requests[0].Key == requests[1].Key {
t.Fatal("two episodes of one file collapsed onto one key")
}
}
// A rename is series-wide because Sonarr does not say which episode each moved file held,
// and it is a refresh rather than an invalidation: the Emby id survives a move.
func TestSonarrRenameRefreshesTheSeries(t *testing.T) {
var payload SonarrWebhook
payload.EventType = "Rename"
payload.Series.ID = 12
payload.Series.Title = "Blue Bloods"
requests := SonarrRequests(payload)
if len(requests) != 1 {
t.Fatalf("expected one request, got %d", len(requests))
}
if requests[0].Kind != KindSeries || requests[0].Action != ActionRefresh {
t.Fatalf("unexpected shape: %+v", requests[0])
}
if requests[0].Reason != ReasonRename {
t.Fatalf("expected a rename, got %q", requests[0].Reason)
}
}
func TestSonarrEpisodeDeleteRemovesThatEpisode(t *testing.T) {
payload := sonarrDownload(false)
payload.EventType = "EpisodeFileDelete"
requests := SonarrRequests(payload)
if len(requests) != 1 || requests[0].Action != ActionRemove {
t.Fatalf("expected one removal, got %+v", requests)
}
if requests[0].Season != 6 || requests[0].Episode != 7 {
t.Fatalf("unexpected position: %+v", requests[0])
}
}
// A series removed from Sonarr's list while its files stay on disk is still in the
// library. Only a delete that took the media with it removes anything.
func TestSonarrSeriesDeleteOnlyCountsWhenFilesWent(t *testing.T) {
var payload SonarrWebhook
payload.EventType = "SeriesDelete"
payload.Series.ID = 12
payload.Series.Title = "Blue Bloods"
if requests := SonarrRequests(payload); len(requests) != 0 {
t.Fatalf("an unfollowed series must not be removed: %+v", requests)
}
payload.DeletedFiles = true
requests := SonarrRequests(payload)
if len(requests) != 1 || requests[0].Action != ActionRemove || requests[0].Kind != KindSeries {
t.Fatalf("expected a series removal, got %+v", requests)
}
}
func TestSonarrIgnoresEventsThatChangeNothing(t *testing.T) {
for _, event := range []string{"Grab", "Health", "ApplicationUpdate", "", "ManualInteractionRequired"} {
var payload SonarrWebhook
payload.EventType = event
payload.Series.Title = "Blue Bloods"
if requests := SonarrRequests(payload); len(requests) != 0 {
t.Fatalf("%q produced work: %+v", event, requests)
}
}
}
func radarrDownload(upgrade bool) RadarrWebhook {
var payload RadarrWebhook
payload.EventType = "Download"
payload.IsUpgrade = upgrade
payload.Movie.ID = 44
payload.Movie.Title = "Arrival"
payload.Movie.Year = 2016
payload.MovieFile.ID = 441
return payload
}
func TestRadarrImportAndUpgradeBothRefresh(t *testing.T) {
imported := RadarrRequests(radarrDownload(false))
if len(imported) != 1 || imported[0].Reason != ReasonImport || imported[0].Kind != KindMovie {
t.Fatalf("unexpected import: %+v", imported)
}
upgraded := RadarrRequests(radarrDownload(true))
if len(upgraded) != 1 || upgraded[0].Reason != ReasonUpgrade {
t.Fatalf("unexpected upgrade: %+v", upgraded)
}
// The two are separate work: an upgrade of a file already imported must not be
// swallowed by the settled row its import left behind.
if imported[0].Key == upgraded[0].Key {
t.Fatal("an upgrade reused the import's key")
}
}
func TestRadarrDeleteReadsTheTopLevelFileID(t *testing.T) {
var payload RadarrWebhook
payload.EventType = "MovieFileDelete"
payload.Movie.ID = 44
payload.Movie.Title = "Arrival"
payload.MovieFileID = 441
requests := RadarrRequests(payload)
if len(requests) != 1 || requests[0].Action != ActionRemove {
t.Fatalf("expected one removal, got %+v", requests)
}
if requests[0].Key != "radarr:moviefile:441:delete" {
t.Fatalf("unexpected key: %q", requests[0].Key)
}
}
func TestRadarrMovieDeleteOnlyCountsWhenFilesWent(t *testing.T) {
var payload RadarrWebhook
payload.EventType = "MovieDelete"
payload.Movie.ID = 44
payload.Movie.Title = "Arrival"
if requests := RadarrRequests(payload); len(requests) != 0 {
t.Fatalf("an unmonitored film must not be removed: %+v", requests)
}
payload.DeletedFiles = true
if requests := RadarrRequests(payload); len(requests) != 1 {
t.Fatalf("expected a removal, got %+v", requests)
}
}
// The Test button must be answered without recording work about something that does not
// exist, which is what makes it mean "reachable".
func TestTestEventIsRecognisedFromEitherArr(t *testing.T) {
if !IsTestEvent("Test") || !IsTestEvent(" test ") {
t.Fatal("a test event was not recognised")
}
if IsTestEvent("Download") {
t.Fatal("an import was read as a test")
}
var sonarrTest SonarrWebhook
sonarrTest.EventType = "Test"
sonarrTest.Series.Title = "Test Title"
if requests := SonarrRequests(sonarrTest); len(requests) != 0 {
t.Fatalf("the test event produced work: %+v", requests)
}
}
func TestNormalizedTitleIgnoresPunctuationAndCase(t *testing.T) {
if NormalizedTitle("Marvel's Daredevil") != NormalizedTitle("Marvels Daredevil") {
t.Fatal("punctuation changed the answer")
}
if NormalizedTitle("The Pitt") != "thepitt" {
t.Fatalf("unexpected normalisation: %q", NormalizedTitle("The Pitt"))
}
if NormalizedTitle(" ") != "" {
t.Fatal("a blank title must normalise to nothing")
}
}
+740
View File
@@ -0,0 +1,740 @@
package library
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The worker that drains what Sonarr and Radarr told us.
//
// The scheduled import asks Emby "what has changed since an hour ago" and pages through
// the answer. This asks Emby "where is this one episode", which is a request whose size
// does not grow with the library, and it asks only because something that actually puts
// files on disk said there was a reason to.
//
// Emby is the *lookup* here and never the discovery mechanism. Nothing in this file
// enumerates a library, and the one thing that still does — Syncer.Schedule — is demoted
// to reconciliation for media the *arrs do not manage.
const (
// defaultSettleDelay is how long after a webhook the first attempt is made. Sonarr
// fires On Import the moment it has moved the file; Emby has not scanned it yet, and
// asking immediately would spend a request to learn that.
defaultSettleDelay = 60 * time.Second
// idlePoll is how often the worker looks for due work. Coarse on purpose: everything
// here is already late by a settle delay, and a tight loop against Postgres on an idle
// NAS is exactly the background cost this replaces.
idlePoll = 20 * time.Second
// claimBatch bounds one pass. A season pack arrives as a dozen notifications at once
// and there is no hurry: draining a few per pass keeps Emby's request rate flat.
claimBatch = 4
// maxAttempts is where a piece of work is given up on. With the backoff below that is
// most of a day, after which the item is the reconciliation sweep's problem — which is
// the honest answer, since something other than timing is wrong by then.
maxAttempts = 7
// jobBudget bounds one piece of work end to end.
jobBudget = 60 * time.Second
)
// IngestStore is the slice of the store this needs. Narrow so the whole worker can be
// exercised against maps in a test, and so it is visible at a glance that the only things
// it writes are catalogue rows and the queue's own state.
type IngestStore interface {
EnqueueIngest(ctx context.Context, job store.IngestJob) (bool, error)
ClaimIngest(ctx context.Context, now time.Time, limit int) ([]store.IngestJob, error)
FinishIngest(ctx context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time) error
UpsertLibraryItems(ctx context.Context, items []store.LibraryItem, syncedAt time.Time) (int64, error)
DeleteLibraryItem(ctx context.Context, itemID string) (int64, error)
SeriesRefs(ctx context.Context) ([]store.SeriesRef, error)
CreditsSeriesEpisodes(ctx context.Context, seriesIDs []string) ([]store.CreditsEpisodeRow, error)
LibraryItemsByName(ctx context.Context, itemType, name string) ([]store.NamedItem, error)
}
// EmbySource is the slice of Emby this needs: two reads and one nudge.
type EmbySource interface {
Items(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error)
Episodes(ctx context.Context, cred emby.Credentials, seriesID string, params url.Values) (*emby.ItemsResult, error)
RefreshItem(ctx context.Context, cred emby.Credentials, itemID string) error
}
// Ingester drains the durable queue.
type Ingester struct {
Store IngestStore
Emby EmbySource
Credentials func(ctx context.Context) (emby.Credentials, error)
Log *slog.Logger
// Paused is the server-wide quiet-time gate. The queue is durable precisely so this can
// say no: a webhook that arrives during quiet hours is recorded and read afterwards,
// where the old arrangement answered it 503 and lost the event outright.
Paused func() bool
// Settle is the delay applied when work is enqueued. Held here so the hook and the
// worker cannot disagree about it.
Settle time.Duration
// Announce is told about a finished import, so the news reaches the televisions from
// the moment the title is actually there rather than from the moment the *arr said it
// would be. Installed from main.go, like syncer.SetAfterSync and for the same reason:
// library has no business knowing what an alert is. Nil is ordinary — a gateway with
// nothing to announce to, and every test in this package.
Announce func(ctx context.Context, result IngestResult)
}
func (i *Ingester) log() *slog.Logger {
if i == nil || i.Log == nil {
return slog.Default()
}
return i.Log
}
// SettleDelay is what the hook stamps onto a new row.
func (i *Ingester) SettleDelay() time.Duration {
if i == nil || i.Settle <= 0 {
return defaultSettleDelay
}
return i.Settle
}
// Run is the worker. One goroutine for the whole gateway.
func (i *Ingester) Run(ctx context.Context) {
if i == nil || i.Store == nil || i.Emby == nil || i.Credentials == nil {
return
}
i.log().Info("library ingest worker started", "settle", i.SettleDelay().String())
for {
if ctx.Err() != nil {
return
}
worked := false
if i.Paused == nil || !i.Paused() {
worked = i.drain(ctx)
}
if worked {
continue
}
if !sleep(ctx, idlePoll) {
return
}
}
}
// drain works everything currently due and reports whether it did anything, so a busy
// queue is emptied without waiting a poll interval between rows.
func (i *Ingester) drain(ctx context.Context) bool {
jobs, err := i.Store.ClaimIngest(ctx, time.Now().UTC(), claimBatch)
if err != nil {
if ctx.Err() == nil {
i.log().Warn("could not read the ingest queue", "error", err)
}
return false
}
if len(jobs) == 0 {
return false
}
for _, job := range jobs {
if ctx.Err() != nil {
return false
}
jobCtx, cancel := context.WithTimeout(ctx, jobBudget)
i.work(jobCtx, job)
cancel()
}
return true
}
// work is one row, start to finish. Every exit records an outcome, because the row *is*
// the operator's answer to "why was this item re-read, and did it work".
func (i *Ingester) work(ctx context.Context, job store.IngestJob) {
var request IngestRequest
if err := json.Unmarshal(job.Payload, &request); err != nil {
i.settle(ctx, job, store.IngestFailed, "invalid", "", err)
return
}
request.Key, request.Action = job.Key, job.Action
request.Kind, request.Reason = job.Kind, job.Reason
cred, err := i.Credentials(ctx)
if err != nil {
// Nobody has signed in yet, so there is no way to ask Emby anything. That is a
// deferral rather than a failure: the work is still valid, it simply cannot be
// done until a television signs in.
i.defer_(ctx, job, "no_credentials", err)
return
}
if job.Action == ActionRemove {
i.remove(ctx, job, request)
return
}
i.refresh(ctx, job, request, cred)
}
// refresh is the ordinary path: find the item in Emby and write it into the catalogue.
func (i *Ingester) refresh(
ctx context.Context, job store.IngestJob, request IngestRequest, cred emby.Credentials,
) {
items, itemID, err := i.resolve(ctx, request, cred)
if err != nil {
i.defer_(ctx, job, "lookup_failed", err)
return
}
if len(items) == 0 {
// Emby has not scanned the file in yet, which on a fresh import is the expected
// first answer rather than a fault. One nudge, then wait: the backoff is what turns
// "not yet" into "not ever" without a request per minute in between.
i.nudge(ctx, request, cred)
i.defer_(ctx, job, "not_found", nil)
return
}
written := make([]store.LibraryItem, 0, len(items))
for _, raw := range items {
if item, ok := toLibraryItem(raw); ok {
written = append(written, item)
}
}
if len(written) == 0 {
i.defer_(ctx, job, "not_found", nil)
return
}
// Stamped now, like any other import, so a title written here is never the victim of a
// full pass that happens to be running.
if _, err := i.Store.UpsertLibraryItems(ctx, written, time.Now().UTC()); err != nil {
i.defer_(ctx, job, "write_failed", err)
return
}
i.settle(ctx, job, store.IngestDone, "imported", itemID, nil)
i.log().Info("arr ingest",
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
"kind", job.Kind, "outcome", "imported", "items", len(written),
"item", itemID, "attempts", job.Attempts+1)
// After the row is recorded, never before: the announcement is a claim that the title
// is in the catalogue, and it must not be made by a pass that then failed to record it.
i.announce(ctx, job, request, written, itemID)
}
// announce reports a finished scan, if anybody is listening.
//
// Whether a given import is worth a banner is deliberately not decided here — that is a
// question about what viewers should be told, which belongs with the rest of the alert
// wording. This says what happened; the API package decides what to say about it.
func (i *Ingester) announce(
ctx context.Context, job store.IngestJob, request IngestRequest,
written []store.LibraryItem, itemID string,
) {
if i.Announce == nil {
return
}
result := IngestResult{
Source: job.Source,
Kind: job.Kind,
Reason: job.Reason,
ItemID: itemID,
SeriesName: request.Series,
Season: request.Season,
Episode: request.Episode,
Name: request.Title,
Year: request.Year,
}
// Emby's own record of the item outranks what the *arr called it: they disagree about
// punctuation and about years often enough that the banner and the card underneath it
// would otherwise name the same thing two ways.
if item, found := findWritten(written, itemID); found {
result.Name = item.Name
result.ImageTag = primaryImageTag(item.Payload)
if item.SeriesName != "" {
result.SeriesName = item.SeriesName
}
if item.ProductionYear != nil {
result.Year = *item.ProductionYear
}
}
i.Announce(ctx, result)
}
func findWritten(written []store.LibraryItem, itemID string) (store.LibraryItem, bool) {
if itemID == "" {
return store.LibraryItem{}, false
}
for _, item := range written {
if item.ID == itemID {
return item, true
}
}
return store.LibraryItem{}, false
}
// primaryImageTag digs the poster tag out of the payload that was just stored, so a banner
// can carry artwork without a second lookup. An absent tag is ordinary and costs nothing:
// the alert simply travels without one.
func primaryImageTag(payload json.RawMessage) string {
var parsed struct {
ImageTags map[string]string `json:"ImageTags"`
}
if json.Unmarshal(payload, &parsed) != nil {
return ""
}
return parsed.ImageTags["Primary"]
}
// remove takes a deleted title out of the catalogue.
//
// It resolves against the *local* catalogue rather than against Emby, which is the one
// place in this file that is deliberately the other way round: the thing being removed is
// a row in Memby's copy, and Emby — having had the file deleted underneath it — is the
// least likely place to still be able to name it.
func (i *Ingester) remove(ctx context.Context, job store.IngestJob, request IngestRequest) {
itemID, err := i.localItemID(ctx, request)
if err != nil {
i.defer_(ctx, job, "lookup_failed", err)
return
}
if itemID == "" {
// Nothing to remove. Ordinary rather than a failure: the catalogue may never have
// held it, or a previous delivery of this event already did the work.
i.settle(ctx, job, store.IngestDone, "absent", "", nil)
i.log().Info("arr ingest",
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
"kind", job.Kind, "outcome", "absent")
return
}
removed, err := i.Store.DeleteLibraryItem(ctx, itemID)
if err != nil {
i.defer_(ctx, job, "delete_failed", err)
return
}
i.settle(ctx, job, store.IngestDone, "removed", itemID, nil)
i.log().Info("arr ingest",
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
"kind", job.Kind, "outcome", "removed", "rows", removed, "item", itemID)
}
// resolve turns what the *arr said into Emby items, narrowly.
//
// The second return is the item the work was about, for the log and the console. It is
// empty for a series-wide refresh, which is about a show rather than about one file.
func (i *Ingester) resolve(
ctx context.Context, request IngestRequest, cred emby.Credentials,
) ([]json.RawMessage, string, error) {
switch request.Kind {
case KindMovie:
return i.resolveMovie(ctx, request, cred)
case KindEpisode, KindSeries:
return i.resolveFromSeries(ctx, request, cred)
}
return nil, "", fmt.Errorf("library: unknown ingest kind %q", request.Kind)
}
func (i *Ingester) resolveMovie(
ctx context.Context, request IngestRequest, cred emby.Credentials,
) ([]json.RawMessage, string, error) {
page, err := i.Emby.Items(ctx, cred, itemQuery(url.Values{
"SearchTerm": {request.Title},
"IncludeItemTypes": {"Movie"},
"Recursive": {"true"},
"Limit": {"20"},
}))
if err != nil {
return nil, "", err
}
if page == nil {
return nil, "", nil
}
match, id := pickByTitle(page.Items, request.Title, request.Year)
if match == nil {
return nil, "", nil
}
return []json.RawMessage{match}, id, nil
}
// resolveFromSeries handles both an episode and a whole-series refresh, because they share
// the expensive half: working out which Emby show this is.
func (i *Ingester) resolveFromSeries(
ctx context.Context, request IngestRequest, cred emby.Credentials,
) ([]json.RawMessage, string, error) {
seriesID, seriesPayload, err := i.seriesItem(ctx, request, cred)
if err != nil {
return nil, "", err
}
if seriesID == "" {
return nil, "", nil
}
params := itemQuery(url.Values{})
if request.Kind == KindEpisode && request.Season > 0 {
// One season rather than a show. A long-running series is a thousand records and
// this runs per imported file.
params.Set("Season", strconv.Itoa(request.Season))
}
page, err := i.Emby.Episodes(ctx, cred, seriesID, params)
if err != nil {
return nil, "", err
}
out := make([]json.RawMessage, 0, 8)
if seriesPayload != nil {
// A show Emby has only just created has no row here yet, and its episodes would be
// imported as children of a series the catalogue has never heard of.
out = append(out, seriesPayload)
}
if page == nil {
return out, "", nil
}
if request.Kind == KindSeries {
// A rename moved files; which files is not something Sonarr says, so the show is
// the unit of work and one re-read settles all of them.
return append(out, page.Items...), seriesID, nil
}
for _, raw := range page.Items {
var parsed struct {
ID string `json:"Id"`
IndexNumber *int `json:"IndexNumber"`
ParentIndexNumber *int `json:"ParentIndexNumber"`
}
if json.Unmarshal(raw, &parsed) != nil || parsed.IndexNumber == nil {
continue
}
if *parsed.IndexNumber != request.Episode {
continue
}
if parsed.ParentIndexNumber != nil && *parsed.ParentIndexNumber != request.Season {
continue
}
return append(out, raw), parsed.ID, nil
}
// The series is there and the episode is not: Emby has the show but has not scanned the
// new file. Reporting nothing found keeps that on the deferral path — but the series
// payload is still worth writing if it was new.
if len(out) > 0 {
if _, err := i.Store.UpsertLibraryItems(ctx, seriesItems(out), time.Now().UTC()); err != nil {
i.log().Debug("could not write the series row ahead of its episode", "error", err)
}
}
return nil, "", nil
}
// seriesItem answers which Emby series this is, preferring the catalogue.
//
// The local index is one query the gateway already makes elsewhere and it is right for
// every show that has ever been imported. Emby is asked only when it misses, which is
// exactly the case this feature exists for — a brand-new show whose first episode has just
// landed — and the payload comes back with it so the series row can be written too.
func (i *Ingester) seriesItem(
ctx context.Context, request IngestRequest, cred emby.Credentials,
) (string, json.RawMessage, error) {
if id := i.localSeriesID(ctx, request.Series, request.SeriesYear); id != "" {
return id, nil, nil
}
page, err := i.Emby.Items(ctx, cred, itemQuery(url.Values{
"SearchTerm": {request.Series},
"IncludeItemTypes": {"Series"},
"Recursive": {"true"},
"Limit": {"20"},
}))
if err != nil {
return "", nil, err
}
if page == nil {
return "", nil, nil
}
match, id := pickByTitle(page.Items, request.Series, request.SeriesYear)
return id, match, nil
}
func (i *Ingester) localSeriesID(ctx context.Context, title string, year int) string {
refs, err := i.Store.SeriesRefs(ctx)
if err != nil {
i.log().Debug("series index unavailable for ingest", "error", err)
return ""
}
return matchByTitle(refs, title, year)
}
// localItemID resolves a delete against the catalogue.
func (i *Ingester) localItemID(ctx context.Context, request IngestRequest) (string, error) {
switch request.Kind {
case KindMovie:
named, err := i.Store.LibraryItemsByName(ctx, "Movie", request.Title)
if err != nil {
return "", err
}
return matchNamed(named, request.Title, request.Year), nil
case KindSeries:
return i.localSeriesID(ctx, request.Series, request.SeriesYear), nil
case KindEpisode:
seriesID := i.localSeriesID(ctx, request.Series, request.SeriesYear)
if seriesID == "" {
return "", nil
}
episodes, err := i.Store.CreditsSeriesEpisodes(ctx, []string{seriesID})
if err != nil {
return "", err
}
for _, episode := range episodes {
if episode.Episode == request.Episode && episode.Season == request.Season {
return episode.ItemID, nil
}
}
}
return "", nil
}
// nudge asks Emby to look at the folder the file landed in.
//
// Best-effort and deliberately unreported: it is the same trick the subtitle download uses
// after Bazarr writes a sidecar, and a household whose Emby scans on its own does not need
// it. Refusing to nudge without a parent is the important half — a refresh of nothing is a
// request that cannot help.
func (i *Ingester) nudge(ctx context.Context, request IngestRequest, cred emby.Credentials) {
if request.Kind == KindMovie {
return
}
seriesID := i.localSeriesID(ctx, request.Series, request.SeriesYear)
if seriesID == "" {
return
}
if err := i.Emby.RefreshItem(ctx, cred, seriesID); err != nil {
i.log().Debug("could not ask emby to rescan a series", "series", seriesID, "error", err)
}
}
// defer_ schedules another attempt, or gives up.
func (i *Ingester) defer_(ctx context.Context, job store.IngestJob, outcome string, cause error) {
attempts := job.Attempts + 1
if attempts >= maxAttempts {
i.settle(ctx, job, store.IngestFailed, outcome, "", cause)
i.log().Warn("arr ingest gave up",
"event", "arr_ingest", "key", job.Key, "source", job.Source, "reason", job.Reason,
"kind", job.Kind, "outcome", outcome, "attempts", attempts, "error", errorText(cause))
return
}
retryAt := time.Now().UTC().Add(IngestRetryDelay(attempts))
if err := i.Store.FinishIngest(
ctx, job.Key, store.IngestPending, outcome, "", errorText(cause), retryAt,
); err != nil {
i.log().Warn("could not reschedule ingest work", "key", job.Key, "error", err)
}
i.log().Debug("arr ingest deferred",
"event", "arr_ingest", "key", job.Key, "reason", job.Reason, "outcome", outcome,
"attempts", attempts, "retry_in", IngestRetryDelay(attempts).String(),
"error", errorText(cause))
}
func (i *Ingester) settle(
ctx context.Context, job store.IngestJob, state, outcome, itemID string, cause error,
) {
// Detached from the job's own budget: a row that timed out must still record that it
// did, or the next pass claims it again immediately and the backoff never applies.
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := i.Store.FinishIngest(
writeCtx, job.Key, state, outcome, itemID, errorText(cause), time.Now().UTC(),
); err != nil {
i.log().Warn("could not record ingest outcome", "key", job.Key, "error", err)
}
}
// IngestRetryDelay is the backoff, and it is a step function rather than an exponent so
// the schedule can be read off the page: a minute, five, twenty, an hour, then four-hourly
// out to the attempt limit. The early steps are short because the common cause is Emby not
// having scanned yet, which resolves in minutes; the late ones are long because by then the
// cause is something a faster retry cannot fix.
func IngestRetryDelay(attempts int) time.Duration {
switch {
case attempts <= 1:
return time.Minute
case attempts == 2:
return 5 * time.Minute
case attempts == 3:
return 20 * time.Minute
case attempts == 4:
return time.Hour
default:
return 4 * time.Hour
}
}
// itemQuery is the field set every lookup here uses, and it is deliberately the scheduled
// import's own.
//
// Thinning it would leave an event-imported title without People, MediaStreams or
// ProviderIds — so no cast on its page, no ratings lookup and no format badges — until Emby
// next reported it changed, which for a film nobody edits again is never. Syncer.Find makes
// the same promise for the same reason.
func itemQuery(params url.Values) url.Values {
params.Set("Fields", syncFields)
params.Set("ImageTypeLimit", "1")
params.Set("EnableImages", "true")
params.Set("EnableImageTypes", syncImageTypes)
params.Set("EnableTotalRecordCount", "false")
params.Set("EnableUserData", "false")
return params
}
// pickByTitle chooses the item a title and year names.
//
// Year-qualified first and title-only as the fallback, the rule the schedule row's series
// index already applies: an *arr and Emby disagree about a show's year far more often than
// they disagree about its name, but where both know the year it is what separates a remake
// from its original.
func pickByTitle(items []json.RawMessage, title string, year int) (json.RawMessage, string) {
want := NormalizedTitle(title)
if want == "" {
return nil, ""
}
var fallback json.RawMessage
var fallbackID string
for _, raw := range items {
var parsed struct {
ID string `json:"Id"`
Name string `json:"Name"`
ProductionYear *int `json:"ProductionYear"`
}
if json.Unmarshal(raw, &parsed) != nil || parsed.ID == "" {
continue
}
if NormalizedTitle(parsed.Name) != want {
continue
}
if year > 0 && parsed.ProductionYear != nil && *parsed.ProductionYear == year {
return raw, parsed.ID
}
if fallback == nil {
fallback, fallbackID = raw, parsed.ID
}
}
return fallback, fallbackID
}
func matchByTitle(refs []store.SeriesRef, title string, year int) string {
want := NormalizedTitle(title)
if want == "" {
return ""
}
fallback := ""
for _, ref := range refs {
if NormalizedTitle(ref.Name) != want {
continue
}
if year > 0 && ref.Year == year {
return ref.ID
}
if fallback == "" {
fallback = ref.ID
}
}
return fallback
}
func matchNamed(items []store.NamedItem, title string, year int) string {
want := NormalizedTitle(title)
if want == "" {
return ""
}
fallback := ""
for _, item := range items {
if NormalizedTitle(item.Name) != want {
continue
}
if year > 0 && item.Year == year {
return item.ID
}
if fallback == "" {
fallback = item.ID
}
}
return fallback
}
// seriesItems is the series payload on its own, for the case where the episode has not
// appeared yet but the show has.
func seriesItems(payloads []json.RawMessage) []store.LibraryItem {
out := make([]store.LibraryItem, 0, len(payloads))
for _, raw := range payloads {
if item, ok := toLibraryItem(raw); ok && item.Type == "Series" {
out = append(out, item)
}
}
return out
}
func errorText(err error) string {
if err == nil {
return ""
}
return strings.TrimSpace(err.Error())
}
func sleep(ctx context.Context, duration time.Duration) bool {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
// Enqueue records work a webhook implied, and answers how much of it was news.
//
// It is the hook's whole job. Everything expensive happens later, on the worker, which is
// what lets the hook answer Sonarr in a millisecond and — more importantly — what lets it
// answer at all during quiet hours, when the work itself must wait.
func (i *Ingester) Enqueue(
ctx context.Context, source string, requests []IngestRequest,
) (int, error) {
if i == nil || i.Store == nil || len(requests) == 0 {
return 0, nil
}
due := time.Now().UTC().Add(i.SettleDelay())
fresh := 0
var firstErr error
for _, request := range requests {
payload, err := json.Marshal(request)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
inserted, err := i.Store.EnqueueIngest(ctx, store.IngestJob{
Key: request.Key,
Action: request.Action,
Kind: request.Kind,
Reason: request.Reason,
Source: source,
Payload: payload,
DueAt: due,
})
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
if inserted {
fresh++
continue
}
// A repeat delivery is ordinary — both *arrs re-notify on retry — so it is DEBUG,
// the same stance the per-keystroke search line takes.
i.log().Debug("arr ingest already queued",
"event", "arr_ingest", "key", request.Key, "source", source, "reason", request.Reason)
}
return fresh, firstErr
}
+507
View File
@@ -0,0 +1,507 @@
package library
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/url"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
// fakeStore is the queue and the catalogue as maps. Everything the worker does is visible
// in it, which is the point of the store being an interface here.
type fakeStore struct {
jobs map[string]*store.IngestJob
order []string
items map[string]store.LibraryItem
deleted []string
series []store.SeriesRef
episodes []store.CreditsEpisodeRow
named []store.NamedItem
failNext error
}
func newFakeStore() *fakeStore {
return &fakeStore{jobs: map[string]*store.IngestJob{}, items: map[string]store.LibraryItem{}}
}
func (f *fakeStore) EnqueueIngest(_ context.Context, job store.IngestJob) (bool, error) {
existing, found := f.jobs[job.Key]
if found {
// The real table's ON CONFLICT: one row, and a re-delivery never pulls the settle
// delay forward.
if job.DueAt.After(existing.DueAt) {
existing.DueAt = job.DueAt
}
existing.State = store.IngestPending
return false, nil
}
stored := job
stored.State = store.IngestPending
f.jobs[job.Key] = &stored
f.order = append(f.order, job.Key)
return true, nil
}
func (f *fakeStore) ClaimIngest(_ context.Context, now time.Time, limit int) ([]store.IngestJob, error) {
out := []store.IngestJob{}
for _, key := range f.order {
job := f.jobs[key]
if job.State != store.IngestPending || job.DueAt.After(now) {
continue
}
out = append(out, *job)
if len(out) == limit {
break
}
}
return out, nil
}
func (f *fakeStore) FinishIngest(
_ context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time,
) error {
job, found := f.jobs[key]
if !found {
return errors.New("no such job")
}
job.State, job.Outcome, job.ItemID = state, outcome, itemID
job.LastError, job.DueAt = errorText, retryAt
job.Attempts++
return nil
}
func (f *fakeStore) UpsertLibraryItems(
_ context.Context, items []store.LibraryItem, _ time.Time,
) (int64, error) {
if f.failNext != nil {
err := f.failNext
f.failNext = nil
return 0, err
}
for _, item := range items {
f.items[item.ID] = item
}
return int64(len(items)), nil
}
func (f *fakeStore) DeleteLibraryItem(_ context.Context, itemID string) (int64, error) {
f.deleted = append(f.deleted, itemID)
delete(f.items, itemID)
return 1, nil
}
func (f *fakeStore) SeriesRefs(context.Context) ([]store.SeriesRef, error) { return f.series, nil }
func (f *fakeStore) CreditsSeriesEpisodes(
context.Context, []string,
) ([]store.CreditsEpisodeRow, error) {
return f.episodes, nil
}
func (f *fakeStore) LibraryItemsByName(
_ context.Context, _, _ string,
) ([]store.NamedItem, error) {
return f.named, nil
}
// fakeEmby answers the two lookups and counts the nudges.
type fakeEmby struct {
items []json.RawMessage
episodes []json.RawMessage
refreshed []string
itemQueries []url.Values
seasons []string
err error
}
func (f *fakeEmby) Items(
_ context.Context, _ emby.Credentials, params url.Values,
) (*emby.ItemsResult, error) {
f.itemQueries = append(f.itemQueries, params)
if f.err != nil {
return nil, f.err
}
return &emby.ItemsResult{Items: f.items}, nil
}
func (f *fakeEmby) Episodes(
_ context.Context, _ emby.Credentials, _ string, params url.Values,
) (*emby.ItemsResult, error) {
f.seasons = append(f.seasons, params.Get("Season"))
if f.err != nil {
return nil, f.err
}
return &emby.ItemsResult{Items: f.episodes}, nil
}
func (f *fakeEmby) RefreshItem(_ context.Context, _ emby.Credentials, itemID string) error {
f.refreshed = append(f.refreshed, itemID)
return nil
}
func testIngester(st *fakeStore, source *fakeEmby) *Ingester {
return &Ingester{
Store: st,
Emby: source,
Credentials: func(context.Context) (emby.Credentials, error) {
return emby.Credentials{UserID: "u", Token: "t"}, nil
},
Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
Settle: time.Minute,
}
}
func episodePayload(id string, season, episode int) json.RawMessage {
raw, _ := json.Marshal(map[string]any{
"Id": id, "Name": "The Job", "Type": "Episode", "SeriesId": "emby-series",
"ParentIndexNumber": season, "IndexNumber": episode,
})
return raw
}
func enqueueOne(t *testing.T, ingester *Ingester, request IngestRequest) {
t.Helper()
if _, err := ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request}); err != nil {
t.Fatalf("enqueue: %v", err)
}
}
func episodeRequest() IngestRequest {
return IngestRequest{
Key: "sonarr:episodefile:8123:551", Action: ActionRefresh, Kind: KindEpisode,
Reason: ReasonImport, Series: "Blue Bloods", SeriesYear: 2010, Season: 6, Episode: 7,
}
}
// The ordinary path: the series is already in the catalogue, so Emby is asked for one
// season and the episode is written.
func TestImportWritesTheEpisodeFromOneSeasonLookup(t *testing.T) {
st := newFakeStore()
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
source := &fakeEmby{episodes: []json.RawMessage{
episodePayload("emby-ep-6", 6, 6),
episodePayload("emby-ep-7", 6, 7),
}}
ingester := testIngester(st, source)
enqueueOne(t, ingester, episodeRequest())
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
if _, written := st.items["emby-ep-7"]; !written {
t.Fatalf("the episode was not written: %v", st.items)
}
if _, extra := st.items["emby-ep-6"]; extra {
t.Fatal("an episode nobody asked about was written")
}
if len(source.seasons) != 1 || source.seasons[0] != "6" {
t.Fatalf("expected one season-scoped lookup, got %v", source.seasons)
}
// A series already in the catalogue costs no search at all.
if len(source.itemQueries) != 0 {
t.Fatalf("the catalogue was not used for the series: %v", source.itemQueries)
}
job := st.jobs["sonarr:episodefile:8123:551"]
if job.State != store.IngestDone || job.Outcome != "imported" {
t.Fatalf("unexpected outcome: %+v", job)
}
}
// The field set must be the scheduled import's own, or an event-imported title arrives
// without the cast, streams and provider ids everything downstream reads.
func TestLookupsAskForTheFullSyncFields(t *testing.T) {
st := newFakeStore()
source := &fakeEmby{}
ingester := testIngester(st, source)
enqueueOne(t, ingester, IngestRequest{
Key: "radarr:moviefile:441:import", Action: ActionRefresh, Kind: KindMovie,
Reason: ReasonImport, Title: "Arrival", Year: 2016,
})
ingester.work(context.Background(), *st.jobs["radarr:moviefile:441:import"])
if len(source.itemQueries) != 1 {
t.Fatalf("expected one lookup, got %d", len(source.itemQueries))
}
query := source.itemQueries[0]
if query.Get("Fields") != syncFields {
t.Fatalf("a thinner field set was requested: %q", query.Get("Fields"))
}
if query.Get("EnableUserData") != "false" {
t.Fatal("the shared catalogue must never carry one viewer's user data")
}
}
// Emby not having scanned the file yet is the expected first answer, not a fault: the row
// waits, one nudge is sent, and the backoff widens.
func TestNotFoundDefersWithABackoffRatherThanFailing(t *testing.T) {
st := newFakeStore()
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
source := &fakeEmby{}
ingester := testIngester(st, source)
enqueueOne(t, ingester, episodeRequest())
key := "sonarr:episodefile:8123:551"
before := time.Now().UTC()
ingester.work(context.Background(), *st.jobs[key])
job := st.jobs[key]
if job.State != store.IngestPending || job.Outcome != "not_found" {
t.Fatalf("expected a deferral, got %+v", job)
}
if !job.DueAt.After(before) {
t.Fatal("the next attempt was not scheduled into the future")
}
if len(source.refreshed) != 1 || source.refreshed[0] != "emby-series" {
t.Fatalf("expected one rescan nudge at the series, got %v", source.refreshed)
}
if len(st.items) != 0 {
t.Fatal("nothing should have been written")
}
}
// Attempts are given up on eventually, because past the last step of the backoff the cause
// is not timing and a row retrying for ever is one nobody looks at.
func TestRepeatedFailureIsEventuallyGivenUpOn(t *testing.T) {
st := newFakeStore()
source := &fakeEmby{err: errors.New("emby is not answering")}
ingester := testIngester(st, source)
enqueueOne(t, ingester, episodeRequest())
key := "sonarr:episodefile:8123:551"
for attempt := 0; attempt < maxAttempts; attempt++ {
ingester.work(context.Background(), *st.jobs[key])
}
if st.jobs[key].State != store.IngestFailed {
t.Fatalf("expected the row to be given up on, got %+v", st.jobs[key])
}
if st.jobs[key].LastError == "" {
t.Fatal("a failed row must record why")
}
}
func TestRetryDelayWidensAndSettles(t *testing.T) {
previous := time.Duration(0)
for attempt := 1; attempt <= 6; attempt++ {
delay := IngestRetryDelay(attempt)
if delay < previous {
t.Fatalf("the backoff narrowed at attempt %d: %s after %s", attempt, delay, previous)
}
previous = delay
}
if IngestRetryDelay(1) != time.Minute {
t.Fatalf("the first retry should be quick, got %s", IngestRetryDelay(1))
}
}
// A delete resolves against the catalogue, not against Emby: the file is gone, and Emby is
// the least likely thing to still be able to name it.
func TestDeleteRemovesTheEpisodeFromTheCatalogue(t *testing.T) {
st := newFakeStore()
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
st.episodes = []store.CreditsEpisodeRow{
{ItemID: "emby-ep-6", SeriesID: "emby-series", Season: 6, Episode: 6},
{ItemID: "emby-ep-7", SeriesID: "emby-series", Season: 6, Episode: 7},
}
source := &fakeEmby{}
ingester := testIngester(st, source)
request := episodeRequest()
request.Action, request.Reason, request.Key = ActionRemove, ReasonDelete, "sonarr:episodefile:8123:delete"
enqueueOne(t, ingester, request)
ingester.work(context.Background(), *st.jobs[request.Key])
if len(st.deleted) != 1 || st.deleted[0] != "emby-ep-7" {
t.Fatalf("unexpected deletions: %v", st.deleted)
}
if len(source.itemQueries) != 0 || len(source.seasons) != 0 {
t.Fatal("a delete must not need to ask Emby anything")
}
}
// A delete of something the catalogue never held is settled rather than retried: there is
// nothing to remove and no later attempt could change that.
func TestDeleteOfSomethingAbsentSettlesQuietly(t *testing.T) {
st := newFakeStore()
ingester := testIngester(st, &fakeEmby{})
request := IngestRequest{
Key: "radarr:moviefile:9:delete", Action: ActionRemove, Kind: KindMovie,
Reason: ReasonDelete, Title: "Never Imported", Year: 1999,
}
enqueueOne(t, ingester, request)
ingester.work(context.Background(), *st.jobs[request.Key])
job := st.jobs[request.Key]
if job.State != store.IngestDone || job.Outcome != "absent" {
t.Fatalf("expected a quiet settle, got %+v", job)
}
if len(st.deleted) != 0 {
t.Fatalf("something was deleted: %v", st.deleted)
}
}
// A brand-new show is the case the local index cannot answer, and it is exactly the case
// this feature exists for. Emby is asked, and the series row is written beside its episode
// so the episode is not a child of a show the catalogue has never heard of.
func TestANewSeriesIsResolvedThroughEmbyAndWrittenToo(t *testing.T) {
st := newFakeStore()
seriesRaw, _ := json.Marshal(map[string]any{
"Id": "emby-series", "Name": "Blue Bloods", "Type": "Series", "ProductionYear": 2010,
})
source := &fakeEmby{
items: []json.RawMessage{seriesRaw},
episodes: []json.RawMessage{episodePayload("emby-ep-7", 6, 7)},
}
ingester := testIngester(st, source)
enqueueOne(t, ingester, episodeRequest())
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
if _, written := st.items["emby-series"]; !written {
t.Fatalf("the new series row was not written: %v", st.items)
}
if _, written := st.items["emby-ep-7"]; !written {
t.Fatal("the episode was not written")
}
}
// The year separates a remake from its original where both systems know it, and the title
// alone is the fallback because they disagree about years more often than about names.
func TestMovieMatchingPrefersTheYearAndFallsBackToTheTitle(t *testing.T) {
original, _ := json.Marshal(map[string]any{
"Id": "old", "Name": "The Thing", "Type": "Movie", "ProductionYear": 1982,
})
remake, _ := json.Marshal(map[string]any{
"Id": "new", "Name": "The Thing", "Type": "Movie", "ProductionYear": 2011,
})
items := []json.RawMessage{original, remake}
if _, id := pickByTitle(items, "The Thing", 2011); id != "new" {
t.Fatalf("the year did not decide: %q", id)
}
if _, id := pickByTitle(items, "The Thing", 0); id != "old" {
t.Fatalf("expected the first title match as the fallback, got %q", id)
}
if _, id := pickByTitle(items, "Something Else", 0); id != "" {
t.Fatalf("an unrelated title matched: %q", id)
}
}
// A repeated webhook is one row, and it never pulls the settle delay forward — the whole
// point of the delay is that the file has finished being written.
func TestRepeatedEnqueueIsOneRowAndKeepsTheSettleDelay(t *testing.T) {
st := newFakeStore()
ingester := testIngester(st, &fakeEmby{})
request := episodeRequest()
fresh, err := ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request})
if err != nil || fresh != 1 {
t.Fatalf("first delivery: fresh=%d err=%v", fresh, err)
}
first := st.jobs[request.Key].DueAt
fresh, err = ingester.Enqueue(context.Background(), "sonarr", []IngestRequest{request})
if err != nil || fresh != 0 {
t.Fatalf("a repeat was treated as news: fresh=%d err=%v", fresh, err)
}
if len(st.jobs) != 1 {
t.Fatalf("a repeat produced %d rows", len(st.jobs))
}
if st.jobs[request.Key].DueAt.Before(first) {
t.Fatal("a repeat pulled the settle delay forward")
}
}
// Quiet time stands the worker down without touching the queue, which is the arrangement
// that lets the hook accept an event at any hour.
func TestQuietTimeStopsTheWorkerAndNotTheQueue(t *testing.T) {
st := newFakeStore()
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
source := &fakeEmby{episodes: []json.RawMessage{episodePayload("emby-ep-7", 6, 7)}}
ingester := testIngester(st, source)
ingester.Paused = func() bool { return true }
enqueueOne(t, ingester, episodeRequest())
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { ingester.Run(ctx); close(done) }()
time.Sleep(50 * time.Millisecond)
cancel()
<-done
if len(st.items) != 0 {
t.Fatal("work was done during quiet time")
}
if st.jobs["sonarr:episodefile:8123:551"].State != store.IngestPending {
t.Fatal("the queued work was lost rather than deferred")
}
}
// A finished scan is the moment there is something truthful to announce, which is why the
// hook fires from here rather than from the webhook. It must carry Emby's own names: the
// *arr and Emby disagree about punctuation often enough that a banner built from the
// webhook would name the same thing differently from the card underneath it.
func TestAFinishedImportIsAnnouncedWithEmbysOwnNames(t *testing.T) {
st := newFakeStore()
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
source := &fakeEmby{episodes: []json.RawMessage{episodeWithArtwork("emby-ep-7", 6, 7)}}
ingester := testIngester(st, source)
var announced []IngestResult
ingester.Announce = func(_ context.Context, result IngestResult) {
announced = append(announced, result)
}
enqueueOne(t, ingester, episodeRequest())
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
if len(announced) != 1 {
t.Fatalf("expected one announcement, got %d", len(announced))
}
result := announced[0]
if result.ItemID != "emby-ep-7" || result.Name != "The Job" {
t.Errorf("announced %q/%q, want Emby's id and episode title", result.ItemID, result.Name)
}
if result.SeriesName != "Blue Bloods" {
t.Errorf("series = %q, want Emby's series name", result.SeriesName)
}
if result.Season != 6 || result.Episode != 7 {
t.Errorf("position = S%02dE%02d, want S06E07", result.Season, result.Episode)
}
if result.ImageTag != "poster-tag" {
t.Errorf("image tag = %q, want the poster from the stored payload", result.ImageTag)
}
if result.Reason != ReasonImport || result.Kind != KindEpisode {
t.Errorf("result = %+v, want the import reason and kind carried through", result)
}
}
// Nothing is announced for work that did not land. The banner claims the title is in the
// catalogue, so a lookup that found nothing must stay silent and simply be retried.
func TestNothingIsAnnouncedWhenEmbyHasNotScannedYet(t *testing.T) {
st := newFakeStore()
st.series = []store.SeriesRef{{ID: "emby-series", Name: "Blue Bloods", Year: 2010}}
ingester := testIngester(st, &fakeEmby{})
announcements := 0
ingester.Announce = func(context.Context, IngestResult) { announcements++ }
enqueueOne(t, ingester, episodeRequest())
ingester.work(context.Background(), *st.jobs["sonarr:episodefile:8123:551"])
if announcements != 0 {
t.Fatalf("announced %d times for an episode Emby has not scanned", announcements)
}
}
func episodeWithArtwork(id string, season, episode int) json.RawMessage {
raw, _ := json.Marshal(map[string]any{
"Id": id, "Name": "The Job", "Type": "Episode", "SeriesId": "emby-series",
"SeriesName": "Blue Bloods",
"ParentIndexNumber": season, "IndexNumber": episode,
"ImageTags": map[string]string{"Primary": "poster-tag"},
})
return raw
}
+61 -23
View File
@@ -250,6 +250,14 @@ func (s *Syncer) run(
return result, nil
}
// EmbyCredentials is how the ingest worker borrows the same account the scheduled import
// uses. One rule for "who does the gateway talk to Emby as" rather than two, so a
// household with a service account configured never has an event-driven read appear in
// somebody's Emby history as their television.
func (s *Syncer) EmbyCredentials(ctx context.Context) (emby.Credentials, error) {
return s.credentials(ctx)
}
// credentials prefers the configured service account and otherwise borrows the most
// recent TV session.
func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) {
@@ -333,44 +341,74 @@ func (s *Syncer) Find(ctx context.Context, term string, limit int) ([]json.RawMe
return found, nil
}
// disabledSyncPoll is how often a switched-off schedule wakes to ask whether it still is.
// A setting an operator has just changed must not need a restart, which is the same reason
// the Emby reachability probe keeps ticking slowly while it is off.
const disabledSyncPoll = 5 * time.Minute
// Schedule runs an incremental import on an interval until ctx is cancelled.
//
// New episodes tend to land through the day and films weekly; an hourly incremental pass
// covers both without ever asking Emby for the whole catalogue again.
func (s *Syncer) Schedule(ctx context.Context, interval time.Duration, paused ...func() bool) {
if interval <= 0 {
// It is reconciliation now rather than discovery. Where the *arr webhooks are configured, a
// file is in the catalogue within a minute of Sonarr or Radarr putting it there and this
// pass exists for what they do not manage — media dropped in by hand, a title edited in
// Emby, a webhook that never arrived because the gateway was down. Where they are not, it
// is still the only thing that notices anything, which is why the interval is the
// operator's rather than a constant.
//
// interval is a function rather than a value because it is read every cycle: an operator
// who has just lengthened the sweep must see that take effect without restarting the
// container. Zero means switched off, and this keeps waking to ask.
func (s *Syncer) Schedule(
ctx context.Context, interval func() time.Duration, paused ...func() bool,
) {
if interval == nil {
s.log.Info("library auto-sync disabled")
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.log.Info("library auto-sync scheduled", "interval", interval.String())
s.log.Info("library auto-sync scheduled", "interval", durationLabel(interval()))
for {
wait := interval()
disabled := wait <= 0
if disabled {
wait = disabledSyncPoll
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return
case <-ticker.C:
if len(paused) > 0 && paused[0] != nil && paused[0]() {
s.log.Debug("skipping scheduled sync; quiet time is active")
case <-timer.C:
}
timer.Stop()
if disabled {
continue
}
if len(paused) > 0 && paused[0] != nil && paused[0]() {
s.log.Debug("skipping scheduled sync; quiet time is active")
continue
}
if s.Running() {
s.log.Info("skipping scheduled sync; one is already running")
continue
}
if _, err := s.Sync(ctx, "incremental", "schedule"); err != nil {
if errors.Is(err, ErrNoCredentials) {
// Nobody has signed in yet. Not worth an error-level log every hour.
s.log.Info("skipping scheduled sync; no credentials yet")
continue
}
if s.Running() {
s.log.Info("skipping scheduled sync; one is already running")
continue
}
if _, err := s.Sync(ctx, "incremental", "schedule"); err != nil {
if errors.Is(err, ErrNoCredentials) {
// Nobody has signed in yet. Not worth an error-level log every hour.
s.log.Info("skipping scheduled sync; no credentials yet")
continue
}
s.log.Error("scheduled sync failed", "error", err)
}
s.log.Error("scheduled sync failed", "error", err)
}
}
}
func durationLabel(value time.Duration) string {
if value <= 0 {
return "off"
}
return value.String()
}
// syncItem mirrors the Emby fields promoted to columns.
type syncItem struct {
ID string `json:"Id"`
+11
View File
@@ -46,6 +46,14 @@ type GatewaySettings struct {
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
// EmbyHealthSeconds is how often the reachability probe asks Emby whether it is there.
EmbyHealthSeconds int `json:"embyHealthSeconds"`
// LibrarySyncMinutes is how often the catalogue sweep asks Emby what changed.
//
// It is an override worth having because the answer now depends on the household's
// wiring rather than on the gateway: with both *arr webhooks configured, a new file is
// in the catalogue within a minute of landing and the sweep is reconciliation for
// media Sonarr and Radarr do not manage — six hours rather than one. With no webhooks
// it is still the only way anything is discovered and must stay frequent.
LibrarySyncMinutes int `json:"librarySyncMinutes"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
@@ -78,6 +86,9 @@ func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true)
settings.RadarrAlertMinutes = clampOverride(settings.RadarrAlertMinutes, 1, 7*24*60, true)
settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true)
// A day is the ceiling rather than a week: however well the webhooks are working, the
// sweep is the only thing that ever notices a file somebody moved by hand.
settings.LibrarySyncMinutes = clampOverride(settings.LibrarySyncMinutes, 5, 24*60, true)
return settings
}
+64
View File
@@ -120,6 +120,70 @@ func (s *Store) DeleteLibraryItemsBefore(ctx context.Context, cutoff time.Time)
return tag.RowsAffected(), nil
}
// NamedItem is the least a caller can be told about a catalogue row and still identify
// it: what it is called and, where the library knows, when it came out.
type NamedItem struct {
ID string
Name string
Year int
}
// LibraryItemsByName finds catalogue rows by title, case-insensitively.
//
// The comparison that decides the answer is not this one: the caller normalises both
// sides (punctuation and spacing are where an *arr and Emby actually differ) and picks by
// year. This is the narrowing query — a handful of rows out of twenty thousand — so that
// the matching rule can stay a pure function with one definition.
func (s *Store) LibraryItemsByName(ctx context.Context, itemType, name string) ([]NamedItem, error) {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
SELECT id, name, COALESCE(production_year, 0)
FROM library_items
WHERE type = $1 AND lower(name) = lower($2)
LIMIT 50`, itemType, trimmed)
if err != nil {
return nil, fmt.Errorf("store: library items by name: %w", err)
}
defer rows.Close()
out := []NamedItem{}
for rows.Next() {
var item NamedItem
if err := rows.Scan(&item.ID, &item.Name, &item.Year); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
// DeleteLibraryItem removes one item and anything derived from it.
//
// The credits marker goes with it, and that is the point of doing this in one place: the
// marker table is keyed on the item id and nothing else prunes it, so a title deleted from
// the library would otherwise leave a Skip Credits position behind for a file that no
// longer exists — and if that id were ever reused, in front of the wrong programme.
//
// Deleting a series takes its episodes with it, because Emby's own hierarchy is the only
// thing that made those rows meaningful.
func (s *Store) DeleteLibraryItem(ctx context.Context, itemID string) (int64, error) {
if strings.TrimSpace(itemID) == "" {
return 0, nil
}
tag, err := s.pool.Exec(ctx,
`DELETE FROM library_items WHERE id = $1 OR series_id = $1`, itemID)
if err != nil {
return 0, fmt.Errorf("store: delete library item: %w", err)
}
if _, err := s.pool.Exec(ctx,
`DELETE FROM credits_markers WHERE item_id = $1 OR series_id = $1`, itemID); err != nil {
return 0, fmt.Errorf("store: delete credits markers: %w", err)
}
return tag.RowsAffected(), nil
}
// SearchLibrary answers from the imported library rather than Emby.
//
// Full-text match first, with a trailing ILIKE so partial words ("sever") still hit
+216
View File
@@ -0,0 +1,216 @@
package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// The durable side of event-driven ingest.
//
// One table, five queries, and the only interesting one is the insert: it is written
// ON CONFLICT on a key derived from the file, which is the whole of what makes repeated
// webhook delivery safe. Sonarr and Radarr both re-notify on retry and neither guarantees
// exactly-once, so "the same news twice" has to be an ordinary event rather than a
// duplicate row and a duplicate Emby lookup.
// Ingest states.
const (
IngestPending = "pending"
IngestDone = "done"
IngestFailed = "failed"
)
// IngestRetention is how long settled rows are kept. Long enough that an operator asking
// "did the webhook fire when that episode landed last week" gets an answer, short enough
// that a household importing all day does not accumulate a table nobody reads.
const IngestRetention = 14 * 24 * time.Hour
// IngestJob is one row of work.
type IngestJob struct {
Key string `json:"key"`
Action string `json:"action"`
Kind string `json:"kind"`
Reason string `json:"reason"`
Source string `json:"source"`
Payload json.RawMessage `json:"payload"`
State string `json:"state"`
Outcome string `json:"outcome"`
ItemID string `json:"itemId"`
Attempts int `json:"attempts"`
LastError string `json:"lastError"`
DueAt time.Time `json:"dueAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// IngestCounts is what the console reads beside the list.
type IngestCounts struct {
Pending int `json:"pending"`
Done int `json:"done"`
Failed int `json:"failed"`
}
// EnqueueIngest records a piece of work, or refreshes one already waiting.
//
// The second return reports whether this delivery was news. A repeat is not an error and
// not a second row — it moves the existing row's due time no earlier and is logged at
// DEBUG, because a Sonarr that retried is an ordinary occurrence and not something an
// operator needs told about.
//
// A key that has already been *settled* is deliberately re-opened: the same file can
// legitimately be imported, deleted and imported again, and a row left at 'done' would
// swallow the second import for ever.
func (s *Store) EnqueueIngest(ctx context.Context, job IngestJob) (bool, error) {
if job.Key == "" || job.Action == "" {
return false, fmt.Errorf("store: ingest job needs a key and an action")
}
if len(job.Payload) == 0 {
job.Payload = json.RawMessage(`{}`)
}
if job.DueAt.IsZero() {
job.DueAt = time.Now().UTC()
}
var inserted bool
err := s.pool.QueryRow(ctx, `
INSERT INTO library_ingest_queue
(key, action, kind, reason, source, payload, state, due_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7, now(), now())
ON CONFLICT (key) DO UPDATE SET
action = EXCLUDED.action,
kind = EXCLUDED.kind,
reason = EXCLUDED.reason,
source = EXCLUDED.source,
payload = EXCLUDED.payload,
state = 'pending',
outcome = '',
last_error = '',
-- A re-delivery must never pull the settle delay forward: the point of it is
-- that the file has finished being written, and an eager retry would ask Emby
-- about a file it has not scanned yet.
due_at = GREATEST(library_ingest_queue.due_at, EXCLUDED.due_at),
-- Attempts reset only when the row had settled. A retry storm against a row
-- still being worked must not reset its backoff.
attempts = CASE WHEN library_ingest_queue.state = 'pending'
THEN library_ingest_queue.attempts ELSE 0 END,
updated_at = now()
RETURNING (xmax = 0)`,
job.Key, job.Action, job.Kind, job.Reason, job.Source, job.Payload, job.DueAt.UTC(),
).Scan(&inserted)
if err != nil {
return false, fmt.Errorf("store: enqueue ingest: %w", err)
}
return inserted, nil
}
// ClaimIngest takes the work that is due, oldest first.
//
// It marks nothing: the worker is single and in-process, so a claim flag would be state to
// get wrong (a row left claimed by a container that was killed) in exchange for protecting
// against a second worker that does not exist. FinishIngest is what moves a row on.
func (s *Store) ClaimIngest(ctx context.Context, now time.Time, limit int) ([]IngestJob, error) {
if limit <= 0 {
limit = 10
}
rows, err := s.pool.Query(ctx, `
SELECT key, action, kind, reason, source, payload, state, outcome, item_id,
attempts, last_error, due_at, created_at, updated_at
FROM library_ingest_queue
WHERE state = 'pending' AND due_at <= $1
ORDER BY due_at
LIMIT $2`, now.UTC(), limit)
if err != nil {
return nil, fmt.Errorf("store: claim ingest: %w", err)
}
defer rows.Close()
return scanIngestJobs(rows)
}
// FinishIngest settles a row, or schedules the next attempt.
//
// state is 'done', 'failed' or 'pending' — the last being a deferral, which is the
// ordinary answer for a file Emby has not scanned in yet.
func (s *Store) FinishIngest(
ctx context.Context, key, state, outcome, itemID, errorText string, retryAt time.Time,
) error {
due := retryAt
if due.IsZero() {
due = time.Now().UTC()
}
_, err := s.pool.Exec(ctx, `
UPDATE library_ingest_queue
SET state = $2, outcome = $3, item_id = $4, last_error = $5,
attempts = attempts + 1, due_at = $6, updated_at = now()
WHERE key = $1`, key, state, outcome, itemID, errorText, due.UTC())
if err != nil {
return fmt.Errorf("store: finish ingest: %w", err)
}
return nil
}
// RecentIngests is the console's read: newest activity first, whatever its state.
func (s *Store) RecentIngests(ctx context.Context, limit int) ([]IngestJob, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `
SELECT key, action, kind, reason, source, payload, state, outcome, item_id,
attempts, last_error, due_at, created_at, updated_at
FROM library_ingest_queue
ORDER BY updated_at DESC
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("store: recent ingests: %w", err)
}
defer rows.Close()
return scanIngestJobs(rows)
}
// IngestStateCounts is the summary above that list.
func (s *Store) IngestStateCounts(ctx context.Context) (IngestCounts, error) {
var counts IngestCounts
err := s.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE state = 'pending'),
COUNT(*) FILTER (WHERE state = 'done'),
COUNT(*) FILTER (WHERE state = 'failed')
FROM library_ingest_queue`).Scan(&counts.Pending, &counts.Done, &counts.Failed)
if err != nil {
return IngestCounts{}, fmt.Errorf("store: ingest counts: %w", err)
}
return counts, nil
}
// PruneIngests removes settled rows past their retention. Pending work is never pruned:
// a row still waiting is work nobody has done, however old it is.
func (s *Store) PruneIngests(ctx context.Context, retention time.Duration) (int64, error) {
if retention <= 0 {
return 0, nil
}
tag, err := s.pool.Exec(ctx, `
DELETE FROM library_ingest_queue
WHERE state <> 'pending' AND updated_at < $1`, time.Now().UTC().Add(-retention))
if err != nil {
return 0, fmt.Errorf("store: prune ingests: %w", err)
}
return tag.RowsAffected(), nil
}
func scanIngestJobs(rows pgx.Rows) ([]IngestJob, error) {
out := []IngestJob{}
for rows.Next() {
var job IngestJob
if err := rows.Scan(
&job.Key, &job.Action, &job.Kind, &job.Reason, &job.Source, &job.Payload,
&job.State, &job.Outcome, &job.ItemID, &job.Attempts, &job.LastError,
&job.DueAt, &job.CreatedAt, &job.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, job)
}
return out, rows.Err()
}
+39
View File
@@ -792,3 +792,42 @@ CREATE INDEX IF NOT EXISTS credits_scan_history_item_time_idx
ON credits_scan_history (item_id, finished_at DESC);
CREATE INDEX IF NOT EXISTS credits_scan_history_time_idx
ON credits_scan_history (finished_at DESC);
-- Work Sonarr and Radarr told the gateway about.
--
-- This is the one queue in the schema that is durable, and the reason is that a webhook is
-- gone once it has been dropped: a Tracearr-derived credits candidate is rebuilt from one
-- query on restart, while "Sonarr imported this at 19:05" cannot be rederived from
-- anything. A container restarted during the settle delay must still re-read the file.
--
-- The key is derived from the *file* rather than from the delivery, so ON CONFLICT is what
-- makes repeated webhook delivery safe: two notifications about one import collapse onto
-- one row, while a file deleted and re-imported is a different file and its own work.
--
-- Completed rows are kept rather than deleted. They are the operator's record of why an
-- item was re-read, which is the question the Imports page exists to answer; housekeeping
-- prunes them.
CREATE TABLE IF NOT EXISTS library_ingest_queue (
key TEXT PRIMARY KEY,
action TEXT NOT NULL,
kind TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
state TEXT NOT NULL DEFAULT 'pending',
outcome TEXT NOT NULL DEFAULT '',
item_id TEXT NOT NULL DEFAULT '',
attempts INT NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
due_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The worker's only query: what is due. Partial, because settled rows outnumber pending
-- ones by orders of magnitude within a day of the feature being switched on.
CREATE INDEX IF NOT EXISTS library_ingest_pending_idx
ON library_ingest_queue (due_at)
WHERE state = 'pending';
CREATE INDEX IF NOT EXISTS library_ingest_recent_idx
ON library_ingest_queue (updated_at DESC);