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
+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>}</>;
}