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