0.2.76 - Icon Packs
This commit is contained in:
@@ -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
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user