Release Memby 0.2.64

This commit is contained in:
ponzischeme89
2026-08-14 13:32:14 +12:00
parent ad0c74f880
commit 05d95f51cd
33 changed files with 1142 additions and 112 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
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-BpHOHb2g.js"></script>
<script type="module" crossorigin src="/admin/assets/index-Ol_zP3CI.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-BwjLFE7Y.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-CXwJRCVF.css">
</head>
+2
View File
@@ -33,6 +33,7 @@ import { JourneyViewerPage } from './pages/JourneyViewer';
import { EngagementPage } from './pages/Engagement';
import { SearchesPage } from './pages/Searches';
import { ViewsPage } from './pages/Views';
import { MediaReportsPage } from './pages/MediaReports';
/* The console's routing table.
*
@@ -91,6 +92,7 @@ export function App() {
<Route path="views" element={<ViewsPage />} />
<Route path="engagement" element={<EngagementPage />} />
<Route path="searches" element={<SearchesPage />} />
<Route path="media-reports" element={<MediaReportsPage />} />
{/* The old console redirected /admin/ to /admin/overview. Anything that
still links there lands on the overview rather than on a 404. */}
+27
View File
@@ -97,10 +97,36 @@ export interface HeroItem {
export interface HeroPolicy {
pinnedItems: HeroItem[] | null;
items?: HeroItem[] | null;
primeSubtitle: string;
placements?: Record<HeroPlacement, HeroPlacementPolicy>;
schedules?: HeroSchedule[] | null;
}
export type HeroPlacement = 'home' | 'movies' | 'tv_shows';
export interface HeroPlacementPolicy {
pinnedItems: HeroItem[] | null;
primeSubtitle: string;
}
export interface MediaReport {
id: number;
mediaType: 'movie' | 'episode';
title: string;
seriesTitle?: string;
seasonNumber?: number;
episodeNumber?: number;
reason: string;
comment?: string;
reportedByUsername: string;
reportedByDevice?: string;
replacementRequested: boolean;
replacementStatus?: string;
status: string;
createdAt: string;
}
export interface RequestUsage {
userId: string;
requests: number;
@@ -116,6 +142,7 @@ export interface HeroSchedule {
priority: number;
userId?: string;
enabled: boolean;
placements?: HeroPlacement[];
}
export interface MDBListSettings {
+8
View File
@@ -256,6 +256,14 @@ export const nav: NavGroup[] = [
id: 'reporting',
label: 'Reporting',
items: [
{
id: 'media-reports',
path: '/admin/media-reports',
label: 'Media reports',
title: 'Media reports',
intro: 'Problems viewers reported with a film or episode.',
icon: 'inbox',
},
{
id: 'views',
path: '/admin/views',
+62 -25
View File
@@ -4,16 +4,24 @@ import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead } from '../components/ui';
import type { HeroItem, HeroSchedule } from '../api/types';
import type { HeroItem, HeroPlacement, HeroPlacementPolicy, HeroSchedule } from '../api/types';
const MAX_PINS = 4;
const PLACEMENTS: Array<{ id: HeroPlacement; label: string; type: string }> = [
{ id: 'home', label: 'Home', type: 'films and television shows' },
{ id: 'movies', label: 'Movies', type: 'films' },
{ id: 'tv_shows', label: 'TV Shows', type: 'television shows' },
];
const emptyPlacement = (): HeroPlacementPolicy => ({ pinnedItems: [], primeSubtitle: '' });
export function HeroPage() {
const { status, error, loading, reload } = useGateway();
const { wrap, show } = useToast();
const { busy, run } = useAction();
const [pins, setPins] = useState<HeroItem[]>([]);
const [subtitle, setSubtitle] = useState('');
const [placement, setPlacement] = useState<HeroPlacement>('home');
const [placements, setPlacements] = useState<Record<HeroPlacement, HeroPlacementPolicy>>({
home: emptyPlacement(), movies: emptyPlacement(), tv_shows: emptyPlacement(),
});
const [dirty, setDirty] = useState(false);
const [queryText, setQueryText] = useState('');
const [results, setResults] = useState<HeroItem[] | null>(null);
@@ -24,8 +32,11 @@ export function HeroPage() {
useEffect(() => {
// The poll must not take an unsaved arrangement away, which is what `dirty` guards.
if (dirty || !policy) return;
setPins((policy.pinnedItems ?? []).slice(0, MAX_PINS));
setSubtitle(policy.primeSubtitle ?? '');
setPlacements({
home: policy.placements?.home ?? { pinnedItems: policy.pinnedItems ?? [], primeSubtitle: policy.primeSubtitle ?? '' },
movies: policy.placements?.movies ?? emptyPlacement(),
tv_shows: policy.placements?.tv_shows ?? emptyPlacement(),
});
setSchedules(policy.schedules ?? []);
}, [policy, dirty]);
@@ -39,14 +50,20 @@ export function HeroPage() {
if (payload) setResults(payload.items ?? []);
});
const current = placements[placement];
const pins = current.pinnedItems ?? [];
const setCurrent = (next: Partial<HeroPlacementPolicy>) => {
setPlacements((value) => ({ ...value, [placement]: { ...value[placement], ...next } }));
setDirty(true);
};
const add = (item: HeroItem) => {
if (pins.some((pin) => pin.id === item.id)) return;
if (pins.length >= MAX_PINS) {
show('Remove a pinned title before adding another.', 'bad');
return;
}
setPins((current) => [...current, item]);
setDirty(true);
setCurrent({ pinnedItems: [...pins, item] });
};
const save = () =>
@@ -54,8 +71,10 @@ export function HeroPage() {
await wrap(
() =>
api.post('/admin/api/hero-policy', {
pinnedItemIds: pins.map((item) => item.id),
primeSubtitle: subtitle.trim(),
placements: Object.fromEntries(Object.entries(placements).map(([name, value]) => [name, {
pinnedItemIds: (value.pinnedItems ?? []).map((item) => item.id),
primeSubtitle: value.primeSubtitle.trim(),
}])),
schedules,
}),
'Hero saved.',
@@ -67,8 +86,8 @@ export function HeroPage() {
return (
<>
<PageHead
title="Home hero"
intro="Choose films or television shows for the launcher spotlight while recent releases fill the remaining places."
title="Featured content"
intro="Manage an independent, backend-resolved hero for Home, Movies and TV Shows."
/>
<Banner message={error} />
@@ -76,9 +95,30 @@ export function HeroPage() {
<Loading rows={2} />
) : (
<>
<Grid>
{PLACEMENTS.map((option) => {
const configured = placements[option.id];
const activeSchedule = schedules
.filter((entry) => entry.enabled && (entry.placements ?? ['home']).includes(option.id) && new Date(entry.startAt) <= new Date() && new Date(entry.endAt) > new Date())
.sort((left, right) => right.priority - left.priority)[0];
const source = (configured.pinnedItems ?? []).length ? 'Manual' : activeSchedule ? 'Scheduled' : 'Automatic';
const scheduledItem = policy?.items?.find((item) => item.id === activeSchedule?.itemId);
const preview = (configured.pinnedItems ?? []).map((item) => item.name).join(', ') || scheduledItem?.name || activeSchedule?.itemId || 'Resolved for each viewer';
return <Card key={option.id} title={option.label} intro={`${source} · ${preview}`} tone={option.id === placement ? 'info' : undefined}>
<Button size="sm" variant="quiet" onClick={() => setPlacement(option.id)}>Manage {option.label}</Button>
</Card>;
})}
</Grid>
<div className="tabs" role="tablist" aria-label="Hero placement">
{PLACEMENTS.map((option) => (
<Button key={option.id} variant={placement === option.id ? 'primary' : 'quiet'} onClick={() => setPlacement(option.id)}>
{option.label}
</Button>
))}
</div>
<Card
title="Pinned titles"
intro="Pinned films and series lead the four-card launcher grid in this order. Empty places are filled by Memby's existing mix of recent digital releases, premieres and highly rated library titles. Pinning changes placement only; labels and reasons remain natural."
title={`${PLACEMENTS.find((entry) => entry.id === placement)?.label} hero`}
intro={`Pinned ${PLACEMENTS.find((entry) => entry.id === placement)?.type} lead this section only. Empty places use this placements automatic selection.`}
icon="star"
tone="note"
footer={
@@ -88,8 +128,7 @@ export function HeroPage() {
</Button>
<Button
onClick={() => {
setPins([]);
setDirty(true);
setCurrent({ pinnedItems: [] });
}}
>
Clear pins
@@ -109,8 +148,7 @@ export function HeroPage() {
size="sm"
icon="close"
onClick={() => {
setPins((current) => current.filter((pin) => pin.id !== item.id));
setDirty(true);
setCurrent({ pinnedItems: pins.filter((pin) => pin.id !== item.id) });
}}
>
{index + 1}. {item.name}
@@ -127,11 +165,10 @@ export function HeroPage() {
<input
type="text"
maxLength={160}
value={subtitle}
value={current.primeSubtitle}
placeholder="Leave blank for the automatic reason"
onChange={(event) => {
setSubtitle(event.target.value);
setDirty(true);
setCurrent({ primeSubtitle: event.target.value });
}}
/>
</Field>
@@ -140,20 +177,20 @@ export function HeroPage() {
<Card title="Scheduled heroes" intro="Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Membys automatic hero." icon="clock" tone="info">
{schedules.length === 0 ? <Empty>No scheduled heroes yet.</Empty> : (
<div className="stack">{schedules.map((schedule) => {
const item = [...pins, ...(results ?? [])].find((candidate) => candidate.id === schedule.itemId);
return <div className="row" key={schedule.id}><b>{item?.name ?? schedule.itemId}</b><span className="muted">{new Date(schedule.startAt).toLocaleString()} {new Date(schedule.endAt).toLocaleString()} · priority {schedule.priority}</span><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
const item = [...(policy?.items ?? []), ...pins, ...(results ?? [])].find((candidate) => candidate.id === schedule.itemId);
return <div className="row" key={schedule.id}><b>{item?.name ?? schedule.itemId}</b><span className="muted">{new Date(schedule.startAt).toLocaleString()} {new Date(schedule.endAt).toLocaleString()} · priority {schedule.priority} · {(schedule.placements ?? ['home']).map((value) => PLACEMENTS.find((entry) => entry.id === value)?.label).join(', ')}</span><div className="chips">{PLACEMENTS.map((option) => <Button key={option.id} size="sm" variant={(schedule.placements ?? ['home']).includes(option.id) ? 'primary' : 'quiet'} onClick={() => { setSchedules((all) => all.map((entry) => { if (entry.id !== schedule.id) return entry; const selected = entry.placements ?? ['home']; const next = selected.includes(option.id) ? selected.filter((value) => value !== option.id) : [...selected, option.id]; return { ...entry, placements: next.length ? next : [option.id] }; })); setDirty(true); }}>{option.label}</Button>)}</div><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
})}</div>
)}
{pins.length > 0 ? <Button size="sm" icon="plus" onClick={() => {
const first = pins[0]; if (!first) return;
const start = new Date(); const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
setSchedules((current) => [...current, { id: crypto.randomUUID(), itemId: first.id, startAt: start.toISOString(), endAt: end.toISOString(), priority: 0, enabled: true }]); setDirty(true);
setSchedules((current) => [...current, { id: crypto.randomUUID(), itemId: first.id, startAt: start.toISOString(), endAt: end.toISOString(), priority: 0, enabled: true, placements: [placement] }]); setDirty(true);
}}>Schedule first pinned title for two hours</Button> : <p className="hint">Pin or search for a title first, then add it to a schedule.</p>}
</Card>
<Card
title="Find a title"
intro="Search the imported Emby catalogue. Up to four films or series can be pinned."
intro={`Search the imported Emby catalogue. Add a result to the selected ${PLACEMENTS.find((entry) => entry.id === placement)?.label} placement; switch tabs to show it in more than one section.`}
icon="search"
tone="info"
>
@@ -183,7 +220,7 @@ export function HeroPage() {
<Button
size="sm"
icon="plus"
disabled={pins.some((pin) => pin.id === item.id)}
disabled={pins.some((pin) => pin.id === item.id) || (placement === 'movies' && item.type !== 'Movie') || (placement === 'tv_shows' && item.type !== 'Series')}
onClick={() => add(item)}
>
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
+22
View File
@@ -0,0 +1,22 @@
import { api } from '../api/client';
import type { MediaReport } from '../api/types';
import { useAction, useQuery } from '../lib/hooks';
import { when } from '../lib/format';
import { Button, Card, Empty, Loading, PageHead, TableWrap, Tag } from '../components/ui';
const label = (report: MediaReport) => report.mediaType === 'episode'
? `${report.seriesTitle} S${String(report.seasonNumber).padStart(2, '0')}E${String(report.episodeNumber).padStart(2, '0')}`
: report.title;
export function MediaReportsPage() {
const query = useQuery<{ reports: MediaReport[] }>('/admin/api/media-reports', { pollMs: 15_000 });
const { busy, run } = useAction();
const setStatus = (report: MediaReport, status: string) => run(`${report.id}-${status}`, async () => {
await api.post(`/admin/api/media-reports/${report.id}/status`, { status });
await query.reload();
});
return <><PageHead title="Media reports" intro="Viewer-reported problems and the individual replacement searches they asked Memby to start." />
{query.loading ? <Loading rows={4} /> : <Card title="Open and recent reports" intro="A replacement always targets one film or one episode. Existing files stay in place while Radarr or Sonarr applies its normal import policy." icon="inbox">
{!query.data?.reports.length ? <Empty>No media problems have been reported.</Empty> : <TableWrap><table><thead><tr><th>Media</th><th>Report</th><th>Viewer</th><th>Replacement</th><th>Status</th><th>Reported</th><th>Actions</th></tr></thead><tbody>{query.data.reports.map((report) => <tr key={report.id}><td><b>{label(report)}</b><br /><span className="muted">{report.title}</span></td><td>{report.reason.replaceAll('_', ' ')}{report.comment ? <><br /><span className="muted">{report.comment}</span></> : null}</td><td>{report.reportedByUsername}<br /><span className="muted">{report.reportedByDevice || 'Unknown device'}</span></td><td><Tag tone={report.replacementRequested ? 'note' : undefined}>{report.replacementRequested ? report.replacementStatus || 'Requested' : 'Not requested'}</Tag></td><td><Tag tone={report.status === 'resolved' ? 'ok' : report.status === 'dismissed' ? undefined : 'warn'}>{report.status}</Tag></td><td className="nowrap muted">{when(report.createdAt)}</td><td><Button size="sm" variant="quiet" busy={busy === `${report.id}-acknowledged`} onClick={() => void setStatus(report, 'acknowledged')}>Acknowledge</Button>{' '}<Button size="sm" variant="quiet" busy={busy === `${report.id}-resolved`} onClick={() => void setStatus(report, 'resolved')}>Resolve</Button>{' '}<Button size="sm" variant="quiet" busy={busy === `${report.id}-dismissed`} onClick={() => void setStatus(report, 'dismissed')}>Dismiss</Button></td></tr>)}</tbody></table></TableWrap>}
</Card>}</>;
}