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