This commit is contained in:
ponzischeme89
2026-08-16 12:13:51 +12:00
parent bd3732fba5
commit b9374baaf1
47 changed files with 2793 additions and 364 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
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-B-5op1DD.js"></script>
<script type="module" crossorigin src="/admin/assets/index-C4RyKtIU.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-DSlxKU2t.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-Dyz7s7hT.css">
</head>
<body>
<div id="root"></div>
+2
View File
@@ -34,6 +34,7 @@ import { EngagementPage } from './pages/Engagement';
import { SearchesPage } from './pages/Searches';
import { ViewsPage } from './pages/Views';
import { MediaReportsPage } from './pages/MediaReports';
import { CreditsPage } from './pages/Credits';
/* The console's routing table.
*
@@ -79,6 +80,7 @@ export function App() {
<Route path="features" element={<FeaturesPage />} />
<Route path="playback" element={<PlaybackPage />} />
<Route path="subtitles" element={<SubtitlesPage />} />
<Route path="credits" element={<CreditsPage />} />
<Route path="updates" element={<UpdatesPage />} />
<Route path="tasks" element={<TasksPage />} />
+48
View File
@@ -216,6 +216,7 @@ export interface FeaturePolicy {
export interface AdminStatus {
serverVersion: string;
currentUser?: string;
maintenance: Maintenance;
updatePolicy: UpdatePolicy;
library: LibraryStats;
@@ -237,6 +238,53 @@ export interface AdminStatus {
radarrReady: boolean;
}
export interface CreditsSettings {
candidateLimit: number;
prefetchEpisodes: number;
maxPrefetch: number;
retryHours: number;
}
export interface CreditsCandidate {
itemId: string;
seriesId: string;
season: number;
episode: number;
priority: number;
reason: string;
userCount: number;
lastViewed: string;
}
export interface CreditsScanHistory {
id: number;
itemId: string;
itemName: string;
seriesName: string;
seriesId: string;
season: number;
episode: number;
reason: string;
priority: number;
outcome: 'detected' | 'unchanged' | 'no_match' | 'failed';
markerMs: number;
confidence: number;
method: string;
frames: number;
error: string;
startedAt: string;
finishedAt: string;
durationMs: number;
}
export interface CreditsResponse {
enabled: boolean;
settings: CreditsSettings;
queueDepth: number;
pending: CreditsCandidate[];
history: CreditsScanHistory[];
}
export interface ViewsReport {
today: { visits: number; viewers: number };
lastWeek: { visits: number; viewers: number };
+55 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { matchPath, NavLink, Outlet, useLocation } from 'react-router-dom';
import { Icon } from './Icon';
import { OmniSearch } from './OmniSearch';
@@ -133,12 +133,17 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
}
export function Layout() {
const { version, online, checkedAt, status, setMaintenance } = useGateway();
const { version, currentUser, online, checkedAt, loading, status, setMaintenance } = useGateway();
const [railOpen, setRailOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false);
const [changingAvailability, setChangingAvailability] = useState(false);
const [confirmingOffline, setConfirmingOffline] = useState(false);
const location = useLocation();
const offline = Boolean(status?.maintenance?.enabled);
const account = useRef<HTMLDivElement>(null);
const initial = Array.from(currentUser.trim())[0]?.toLocaleUpperCase('en-NZ') || 'A';
const statusTone = status && online && !offline ? 'ok' : status || !loading ? 'bad' : 'checking';
const statusLabel = offline ? 'offline' : status && online ? 'online' : loading ? 'checking' : 'not responding';
const toggleAvailability = async () => {
if (changingAvailability || !status) return;
@@ -154,6 +159,22 @@ export function Layout() {
// it sits over the page that was just opened.
useEffect(() => setRailOpen(false), [location.pathname]);
useEffect(() => {
if (!accountOpen) return;
const close = (event: MouseEvent) => {
if (!account.current?.contains(event.target as Node)) setAccountOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setAccountOpen(false);
};
document.addEventListener('mousedown', close);
window.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('mousedown', close);
window.removeEventListener('keydown', closeOnEscape);
};
}, [accountOpen]);
// An open tablet drawer is modal navigation: keep the page underneath still and let
// Escape close it. The media query decides whether the rail is overlaid; applying this
// on desktop is harmless because the desktop rail cannot be opened by a hidden button.
@@ -206,22 +227,46 @@ export function Layout() {
<button
type="button"
className="topbar-status"
data-tone={online && !offline ? 'ok' : 'bad'}
data-tone={statusTone}
aria-pressed={offline}
disabled={!status || changingAvailability}
title={offline ? 'Bring Memby back online' : 'Take Memby offline'}
disabled={!status || !online || changingAvailability}
title={!status ? 'Checking Memby status' : offline ? 'Bring Memby back online' : online ? 'Take Memby offline' : 'Memby is not responding'}
aria-label={offline ? 'Memby is offline. Bring it online' : status && online ? 'Memby is online. Take it offline' : loading ? 'Checking Memby status' : 'Memby is not responding'}
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
>
<span className="dot" />
<b>{offline ? 'offline' : online ? 'online' : 'not responding'}</b>
<b>{statusLabel}</b>
<span>{offline ? 'click to bring back online' : checkedAt ? `updated ${time(checkedAt)}` : ''}</span>
</button>
<NotificationBell />
<form method="post" action="/admin/logout">
<button type="submit" data-variant="quiet" data-size="sm" title="Sign out">
<Icon name="logout" />
<div className="account-menu" data-open={accountOpen || undefined} ref={account}>
<button
type="button"
className="account-trigger"
aria-haspopup="menu"
aria-expanded={accountOpen}
aria-label={`Signed in as ${currentUser}`}
onClick={() => setAccountOpen((open) => !open)}
>
<span className="account-avatar" aria-hidden="true">{initial}</span>
<span className="account-name">{currentUser}</span>
<Icon name="caret" className="ico account-caret" />
</button>
</form>
{accountOpen ? (
<div className="account-panel" role="menu">
<div className="account-identity">
<span className="account-avatar account-avatar-large" aria-hidden="true">{initial}</span>
<span><small>Signed in as</small><b>{currentUser}</b></span>
</div>
<form method="post" action="/admin/logout">
<button type="submit" role="menuitem">
<Icon name="logout" />
Log out
</button>
</form>
</div>
) : null}
</div>
</div>
</header>
+3 -1
View File
@@ -27,6 +27,7 @@ const POLL_MS = 30_000;
interface GatewayState {
status: AdminStatus | undefined;
version: string;
currentUser: string;
online: boolean;
/** checkedAt is the evidence behind the verdict: a page that looks alive while its
* numbers are twenty minutes old is the failure the bar exists to prevent. */
@@ -41,7 +42,7 @@ const GatewayContext = createContext<GatewayState | null>(null);
export function GatewayProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AdminStatus>();
const [online, setOnline] = useState(true);
const [online, setOnline] = useState(false);
const [checkedAt, setCheckedAt] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
@@ -104,6 +105,7 @@ export function GatewayProvider({ children }: { children: ReactNode }) {
() => ({
status,
version: status?.serverVersion ?? '',
currentUser: status?.currentUser?.trim() || 'Administrator',
online,
checkedAt,
error,
+8
View File
@@ -229,6 +229,14 @@ export const nav: NavGroup[] = [
intro: 'Which providers a viewer may fetch a missing subtitle from.',
icon: 'captions',
},
{
id: 'credits',
path: '/admin/credits',
label: 'Credits detection',
title: 'Credits detection',
intro: 'Control predictive scanning and review every completed credits scan.',
icon: 'clock',
},
],
},
{
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { CreditsResponse, CreditsScanHistory, CreditsSettings } from '../api/types';
import { Banner, Button, Card, EmptyRow, Field, Grid, Loading, Note, PageHead, TableWrap, Tag, Tiles } from '../components/ui';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, duration, num, percent, when, type Tone } from '../lib/format';
const outcomeTone = (outcome: CreditsScanHistory['outcome']): Tone => {
if (outcome === 'detected') return 'ok';
if (outcome === 'failed') return 'bad';
if (outcome === 'no_match') return 'warn';
return 'info';
};
const outcomeLabel = (outcome: CreditsScanHistory['outcome']): string =>
outcome === 'no_match' ? 'no match' : outcome;
const reasonLabel = (reason: string): string => ({
'live-playback': 'Live playback',
'tracearr-next': 'Next episode',
'tracearr-binge-prefetch': 'Binge look-ahead',
'multi-user-demand': 'Multiple viewers',
}[reason] ?? reason) || 'Unknown';
const episodeLabel = (season: number, episode: number): string =>
season > 0 && episode > 0 ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` : 'Episode';
export function CreditsPage() {
const query = useQuery<CreditsResponse>('/admin/api/credits?limit=150', { pollMs: 15_000 });
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<CreditsSettings>();
const [touched, setTouched] = useState(false);
useEffect(() => {
if (!touched && query.data) setDraft(query.data.settings);
}, [query.data, touched]);
const patch = (next: Partial<CreditsSettings>) => {
setDraft((current) => current ? { ...current, ...next } : current);
setTouched(true);
};
const save = () => {
if (!draft) return;
void run('save', async () => {
const next = await wrap(
() => api.put<CreditsResponse>('/admin/api/credits', draft),
'Credits scanning settings saved.',
);
if (next) {
query.set(next);
setDraft(next.settings);
setTouched(false);
}
});
};
const history = query.data?.history ?? [];
const pending = query.data?.pending ?? [];
const detected = history.filter((entry) => entry.outcome === 'detected').length;
const noMatch = history.filter((entry) => entry.outcome === 'no_match').length;
const failed = history.filter((entry) => entry.outcome === 'failed').length;
return (
<>
<PageHead
title="Credits detection"
intro="Control how far ahead Memby scans and see why each episode was selected, what the detector found, and when it may be tried again."
/>
<Banner message={query.error} />
{query.loading || !draft ? <Loading /> : (
<>
{!query.data?.enabled ? (
<Note tone="warn">
Credits detection is disabled in the gateway environment. These settings will be retained for the next time it is enabled.
</Note>
) : null}
<Tiles tiles={[
{ label: 'Waiting candidates', value: num(query.data?.queueDepth), icon: 'clock', tone: pending.length ? 'info' : undefined },
{ label: 'Detected in this history', value: num(detected), icon: 'check', tone: 'ok' },
{ label: 'No match', value: num(noMatch), icon: 'search', tone: noMatch ? 'warn' : undefined },
{ label: 'Failed', value: num(failed), icon: 'alert', tone: failed ? 'bad' : undefined },
]} />
<Grid cols="wide">
<Card
title="Candidate controls"
intro="The worker remains single-file and scans one episode at a time. These values control what is allowed to wait and how far prediction looks ahead."
icon="sliders"
tone="note"
footer={<Button variant="primary" icon="check" busy={busy === 'save'} disabled={!touched} onClick={save}>Save settings</Button>}
>
<div className="fields">
<Field label="Candidate limit" hint="Maximum episodes waiting in the priority queue. Stronger candidates displace weaker ones when it is full.">
<input type="number" min={1} max={100} value={draft.candidateLimit} onChange={(event) => patch({ candidateLimit: Number(event.target.value) })} />
</Field>
<Field label="Ordinary look-ahead" hint="Episodes prepared ahead of a normally paced viewer.">
<input type="number" min={1} max={10} value={draft.prefetchEpisodes} onChange={(event) => patch({ prefetchEpisodes: Number(event.target.value) })} />
</Field>
<Field label="Maximum look-ahead" hint="Upper bound for fast binge viewing; must not be below the ordinary look-ahead.">
<input type="number" min={draft.prefetchEpisodes} max={20} value={draft.maxPrefetch} onChange={(event) => patch({ maxPrefetch: Number(event.target.value) })} />
</Field>
<Field label="Retry delay (hours)" hint="After any speculative attempt, keep that episode out of refreshes for this long. Set 0 to allow every refresh.">
<input type="number" min={0} max={720} value={draft.retryHours} onChange={(event) => patch({ retryHours: Number(event.target.value) })} />
</Field>
</div>
</Card>
<Card title="How selection works" icon="sparkle" tone="data">
<p className="muted">
Recent viewing predicts the next few episodes. Priority favours a programme playing now, then the next episode, fast viewing, and episodes several people are approaching.
</p>
<p className="muted">
A completed speculative attempt enters the retry delay even when no marker was found. Live playback can still raise an immediate candidate because somebody is waiting for it.
</p>
</Card>
</Grid>
<Card
title="Waiting candidates"
intro="The exact worker order after marker checks and retry cooldowns. A refresh may replace this list as household viewing changes."
icon="list"
tone="info"
>
<TableWrap>
<table>
<thead><tr><th>Episode</th><th>Reason</th><th className="num">Priority</th><th className="num">Viewers</th><th>Demand seen</th><th>Item ID</th></tr></thead>
<tbody>
{pending.length === 0 ? <EmptyRow columns={6}>No episodes are waiting to be scanned.</EmptyRow> : pending.map((candidate) => (
<tr key={candidate.itemId}>
<td>{episodeLabel(candidate.season, candidate.episode)}</td>
<td><Tag tone="info">{reasonLabel(candidate.reason)}</Tag></td>
<td className="num">{num(candidate.priority)}</td>
<td className="num">{num(candidate.userCount)}</td>
<td className="nowrap muted" title={when(candidate.lastViewed)}>{ago(candidate.lastViewed)}</td>
<td className="mono muted">{candidate.itemId}</td>
</tr>
))}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Scan history"
intro="Completed worker attempts, newest first. Repeated item IDs make an ineffective retry delay visible immediately."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead><tr><th>Finished</th><th>Programme</th><th>Selected because</th><th>Result</th><th>Marker</th><th>Evidence</th><th className="num">Took</th></tr></thead>
<tbody>
{history.length === 0 ? <EmptyRow columns={7}>No credits scans have completed yet.</EmptyRow> : history.map((entry) => (
<tr key={entry.id}>
<td className="nowrap muted" title={when(entry.finishedAt)}>{ago(entry.finishedAt)}</td>
<td>
<b>{entry.seriesName || entry.itemName || entry.itemId}</b>
<span className="table-sub">{episodeLabel(entry.season, entry.episode)}{entry.itemName && entry.seriesName ? ` · ${entry.itemName}` : ''}</span>
</td>
<td><Tag tone="info">{reasonLabel(entry.reason)}</Tag><span className="table-sub">priority {entry.priority}</span></td>
<td><Tag tone={outcomeTone(entry.outcome)}>{outcomeLabel(entry.outcome)}</Tag>{entry.error ? <span className="table-sub">{entry.error}</span> : null}</td>
<td className="nowrap">{entry.markerMs > 0 ? duration(entry.markerMs) : '—'}</td>
<td className="muted">{entry.method || 'visual'}{entry.confidence > 0 ? ` · ${percent(entry.confidence)}` : ''}{entry.frames > 0 ? ` · ${entry.frames} frames` : ''}</td>
<td className="num muted">{duration(entry.durationMs)}</td>
</tr>
))}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+202 -3
View File
@@ -220,6 +220,7 @@ a {
cursor: pointer;
font: inherit;
text-align: left;
transition: background .16s ease, border-color .16s ease, color .16s ease;
}
.topbar-status:hover:not(:disabled),
.topbar-status:focus-visible {
@@ -238,6 +239,7 @@ a {
}
.topbar-status[data-tone="ok"] .dot {
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-wash), 0 0 12px rgba(86, 211, 100, .34);
}
.topbar-status[data-tone="bad"] .dot {
background: var(--danger);
@@ -252,6 +254,111 @@ a {
white-space: nowrap;
}
/* The account is an identity first and an exit second. Keeping sign-out in this menu
prevents an unexplained door icon from competing with the operational controls. */
.account-menu {
position: relative;
flex: 0 0 auto;
}
.account-trigger {
max-width: 190px;
height: 38px;
padding: 0 9px 0 5px;
border-color: var(--line);
background: var(--surface);
color: var(--muted);
}
.account-trigger:hover:not(:disabled),
.account-menu[data-open="true"] .account-trigger {
border-color: rgba(86, 211, 100, .5);
background: var(--surface-hi);
color: var(--text);
}
.account-avatar {
display: grid;
place-items: center;
width: 27px;
height: 27px;
flex: 0 0 27px;
border: 1px solid rgba(86, 211, 100, .36);
border-radius: 50%;
background: var(--accent-wash);
color: var(--accent-ink);
font-size: 11px;
font-weight: 750;
}
.account-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12.5px;
}
.account-caret {
width: 12px;
height: 12px;
transition: transform .16s ease;
}
.account-menu[data-open="true"] .account-caret {
transform: rotate(180deg);
}
.account-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(280px, calc(100vw - 24px));
padding: 7px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface);
box-shadow: 0 18px 44px rgba(0, 0, 0, .55);
z-index: 40;
}
.account-identity {
display: flex;
align-items: center;
gap: 11px;
min-width: 0;
padding: 9px 10px 12px;
border-bottom: 1px solid var(--line-soft);
}
.account-avatar-large {
width: 34px;
height: 34px;
flex-basis: 34px;
font-size: 13px;
}
.account-identity span:last-child {
min-width: 0;
}
.account-identity small,
.account-identity b {
display: block;
}
.account-identity small {
color: var(--quiet);
font-size: 11px;
}
.account-identity b {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13.5px;
}
.account-panel form {
margin-top: 6px;
}
.account-panel form button {
justify-content: flex-start;
width: 100%;
height: 38px;
border-color: transparent;
background: transparent;
color: var(--muted);
}
.account-panel form button:hover:not(:disabled) {
background: var(--surface-hi);
color: var(--text);
}
@media (max-width: 900px) {
.topbar-version,
.topbar-status span {
@@ -575,6 +682,7 @@ a {
.page {
width: min(calc(100vw - var(--rail)), 1920px);
min-width: 0;
margin: var(--top) 0 0 calc(var(--rail) + max(0px, (100vw - var(--rail) - 1920px) / 2));
padding: clamp(24px, 2.4vw, 44px) clamp(22px, 3vw, 56px) 80px;
}
@@ -1088,6 +1196,16 @@ table {
border: 1px solid var(--line);
background: var(--surface);
}
.topbar-status[data-tone="ok"] {
border-color: rgba(86, 211, 100, .38);
background: var(--accent-wash);
color: var(--accent-ink);
}
.topbar-status[data-tone="bad"] {
border-color: rgba(229, 83, 75, .34);
background: var(--danger-wash);
color: var(--danger-ink);
}
.page {
width: 100%;
padding: clamp(24px, 3vw, 34px) max(24px, calc(var(--safe-right) + 10px)) max(64px, calc(var(--safe-bottom) + 36px)) max(24px, calc(var(--safe-left) + 10px));
@@ -1106,6 +1224,17 @@ table {
}
}
@media (max-width: 1100px) {
.account-trigger {
width: 40px;
padding: 0;
}
.account-name,
.account-caret {
display: none;
}
}
@media (max-width: 820px) {
.brand-word,
.topbar-status b {
@@ -1136,6 +1265,57 @@ table {
padding-left: 16px;
}
}
/* A phone gets two deliberate rows: navigation and live controls above, search below.
Compressing all six controls into one line made the search unusable at exactly the
widths where it is the quickest way around a long navigation drawer. */
@media (max-width: 680px) {
:root {
--top: calc(108px + var(--safe-top));
}
.topbar {
align-content: center;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: calc(var(--safe-top) + 8px) max(12px, var(--safe-right)) 8px max(12px, var(--safe-left));
}
.rail-toggle { order: 1; }
.topbar-brand { order: 2; }
.topbar-status {
order: 3;
margin-left: auto;
}
.bell { order: 4; }
.account-menu { order: 5; }
.topbar .omni {
order: 6;
flex: 1 0 100%;
max-width: none;
}
.brand-mark {
width: 32px;
height: 32px;
flex-basis: 32px;
}
.omni-input {
height: 38px;
}
.account-panel,
.bell-panel {
position: fixed;
top: calc(var(--top) + 8px);
}
.account-panel {
right: max(12px, var(--safe-right));
}
}
@media (max-width: 370px) {
.tiles {
grid-template-columns: minmax(0, 1fr);
}
}
th {
padding: 0 12px 8px 0;
border-bottom: 1px solid var(--line);
@@ -2610,7 +2790,7 @@ details summary {
/* iPad is an operating surface, not a large phone. Cards gain enough separation to be
parsed at arm's length, but data remains dense and two-column where portrait width can
genuinely support it. */
@media (min-width: 821px) and (max-width: 1400px) {
@media (pointer: coarse) and (min-width: 821px) and (max-width: 1400px) {
.page-head {
margin-bottom: 24px;
}
@@ -2718,7 +2898,7 @@ details summary {
}
.card-head-actions {
width: 100%;
margin-left: 41px;
margin-left: 0;
}
.card-foot > button,
.card-foot > .btn {
@@ -2815,7 +2995,7 @@ details summary {
.topbar .rail-toggle,
.topbar .bell-button,
.topbar .topbar-status,
.topbar form button {
.topbar .account-trigger {
width: 44px;
height: 44px;
padding: 0;
@@ -2885,6 +3065,25 @@ details summary {
}
}
/* Short laptop screens benefit from vertical density, while retaining the same readable
widths and full-size pointer targets. */
@media (pointer: fine) and (min-width: 821px) and (max-height: 800px) {
.page {
padding-top: 20px;
padding-bottom: 52px;
}
.page-head {
margin-bottom: 16px;
}
.card {
padding-top: 14px;
padding-bottom: 14px;
}
.card-head {
margin-bottom: 11px;
}
}
@media (hover: none) {
tbody tr:hover td,
.list-row:hover,