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
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)"