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
+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,