Release Memby 0.2.64
This commit is contained in:
@@ -1,3 +1,8 @@
|
|||||||
|
## 0.2.64 — 2026-08-14
|
||||||
|
- Added: Movies and TV Shows now have their own curated featured heroes, independently selected and scheduled by the Memby server.
|
||||||
|
- Added: Films and individual episodes can be reported from their detail pages, with permitted viewers able to request a controlled replacement copy.
|
||||||
|
- Improved: Media reports, replacement progress and related actions are visible to operators in the Admin Console and notification system.
|
||||||
|
|
||||||
## 0.2.63 — 2026-08-14
|
## 0.2.63 — 2026-08-14
|
||||||
- App bug fixes
|
- App bug fixes
|
||||||
|
|
||||||
|
|||||||
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
rel="icon"
|
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"
|
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="modulepreload" crossorigin href="/admin/assets/router-BwjLFE7Y.js">
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-CXwJRCVF.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-CXwJRCVF.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { JourneyViewerPage } from './pages/JourneyViewer';
|
|||||||
import { EngagementPage } from './pages/Engagement';
|
import { EngagementPage } from './pages/Engagement';
|
||||||
import { SearchesPage } from './pages/Searches';
|
import { SearchesPage } from './pages/Searches';
|
||||||
import { ViewsPage } from './pages/Views';
|
import { ViewsPage } from './pages/Views';
|
||||||
|
import { MediaReportsPage } from './pages/MediaReports';
|
||||||
|
|
||||||
/* The console's routing table.
|
/* The console's routing table.
|
||||||
*
|
*
|
||||||
@@ -91,6 +92,7 @@ export function App() {
|
|||||||
<Route path="views" element={<ViewsPage />} />
|
<Route path="views" element={<ViewsPage />} />
|
||||||
<Route path="engagement" element={<EngagementPage />} />
|
<Route path="engagement" element={<EngagementPage />} />
|
||||||
<Route path="searches" element={<SearchesPage />} />
|
<Route path="searches" element={<SearchesPage />} />
|
||||||
|
<Route path="media-reports" element={<MediaReportsPage />} />
|
||||||
|
|
||||||
{/* The old console redirected /admin/ to /admin/overview. Anything that
|
{/* The old console redirected /admin/ to /admin/overview. Anything that
|
||||||
still links there lands on the overview rather than on a 404. */}
|
still links there lands on the overview rather than on a 404. */}
|
||||||
|
|||||||
@@ -97,10 +97,36 @@ export interface HeroItem {
|
|||||||
|
|
||||||
export interface HeroPolicy {
|
export interface HeroPolicy {
|
||||||
pinnedItems: HeroItem[] | null;
|
pinnedItems: HeroItem[] | null;
|
||||||
|
items?: HeroItem[] | null;
|
||||||
primeSubtitle: string;
|
primeSubtitle: string;
|
||||||
|
placements?: Record<HeroPlacement, HeroPlacementPolicy>;
|
||||||
schedules?: HeroSchedule[] | null;
|
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 {
|
export interface RequestUsage {
|
||||||
userId: string;
|
userId: string;
|
||||||
requests: number;
|
requests: number;
|
||||||
@@ -116,6 +142,7 @@ export interface HeroSchedule {
|
|||||||
priority: number;
|
priority: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
placements?: HeroPlacement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MDBListSettings {
|
export interface MDBListSettings {
|
||||||
|
|||||||
@@ -256,6 +256,14 @@ export const nav: NavGroup[] = [
|
|||||||
id: 'reporting',
|
id: 'reporting',
|
||||||
label: 'Reporting',
|
label: 'Reporting',
|
||||||
items: [
|
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',
|
id: 'views',
|
||||||
path: '/admin/views',
|
path: '/admin/views',
|
||||||
|
|||||||
+62
-25
@@ -4,16 +4,24 @@ import { useAction } from '../lib/hooks';
|
|||||||
import { useGateway } from '../lib/gateway';
|
import { useGateway } from '../lib/gateway';
|
||||||
import { useToast } from '../lib/toast';
|
import { useToast } from '../lib/toast';
|
||||||
import { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead } from '../components/ui';
|
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 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() {
|
export function HeroPage() {
|
||||||
const { status, error, loading, reload } = useGateway();
|
const { status, error, loading, reload } = useGateway();
|
||||||
const { wrap, show } = useToast();
|
const { wrap, show } = useToast();
|
||||||
const { busy, run } = useAction();
|
const { busy, run } = useAction();
|
||||||
const [pins, setPins] = useState<HeroItem[]>([]);
|
const [placement, setPlacement] = useState<HeroPlacement>('home');
|
||||||
const [subtitle, setSubtitle] = useState('');
|
const [placements, setPlacements] = useState<Record<HeroPlacement, HeroPlacementPolicy>>({
|
||||||
|
home: emptyPlacement(), movies: emptyPlacement(), tv_shows: emptyPlacement(),
|
||||||
|
});
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [queryText, setQueryText] = useState('');
|
const [queryText, setQueryText] = useState('');
|
||||||
const [results, setResults] = useState<HeroItem[] | null>(null);
|
const [results, setResults] = useState<HeroItem[] | null>(null);
|
||||||
@@ -24,8 +32,11 @@ export function HeroPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The poll must not take an unsaved arrangement away, which is what `dirty` guards.
|
// The poll must not take an unsaved arrangement away, which is what `dirty` guards.
|
||||||
if (dirty || !policy) return;
|
if (dirty || !policy) return;
|
||||||
setPins((policy.pinnedItems ?? []).slice(0, MAX_PINS));
|
setPlacements({
|
||||||
setSubtitle(policy.primeSubtitle ?? '');
|
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 ?? []);
|
setSchedules(policy.schedules ?? []);
|
||||||
}, [policy, dirty]);
|
}, [policy, dirty]);
|
||||||
|
|
||||||
@@ -39,14 +50,20 @@ export function HeroPage() {
|
|||||||
if (payload) setResults(payload.items ?? []);
|
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) => {
|
const add = (item: HeroItem) => {
|
||||||
if (pins.some((pin) => pin.id === item.id)) return;
|
if (pins.some((pin) => pin.id === item.id)) return;
|
||||||
if (pins.length >= MAX_PINS) {
|
if (pins.length >= MAX_PINS) {
|
||||||
show('Remove a pinned title before adding another.', 'bad');
|
show('Remove a pinned title before adding another.', 'bad');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPins((current) => [...current, item]);
|
setCurrent({ pinnedItems: [...pins, item] });
|
||||||
setDirty(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = () =>
|
const save = () =>
|
||||||
@@ -54,8 +71,10 @@ export function HeroPage() {
|
|||||||
await wrap(
|
await wrap(
|
||||||
() =>
|
() =>
|
||||||
api.post('/admin/api/hero-policy', {
|
api.post('/admin/api/hero-policy', {
|
||||||
pinnedItemIds: pins.map((item) => item.id),
|
placements: Object.fromEntries(Object.entries(placements).map(([name, value]) => [name, {
|
||||||
primeSubtitle: subtitle.trim(),
|
pinnedItemIds: (value.pinnedItems ?? []).map((item) => item.id),
|
||||||
|
primeSubtitle: value.primeSubtitle.trim(),
|
||||||
|
}])),
|
||||||
schedules,
|
schedules,
|
||||||
}),
|
}),
|
||||||
'Hero saved.',
|
'Hero saved.',
|
||||||
@@ -67,8 +86,8 @@ export function HeroPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Home hero"
|
title="Featured content"
|
||||||
intro="Choose films or television shows for the launcher spotlight while recent releases fill the remaining places."
|
intro="Manage an independent, backend-resolved hero for Home, Movies and TV Shows."
|
||||||
/>
|
/>
|
||||||
<Banner message={error} />
|
<Banner message={error} />
|
||||||
|
|
||||||
@@ -76,9 +95,30 @@ export function HeroPage() {
|
|||||||
<Loading rows={2} />
|
<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
|
<Card
|
||||||
title="Pinned titles"
|
title={`${PLACEMENTS.find((entry) => entry.id === placement)?.label} hero`}
|
||||||
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."
|
intro={`Pinned ${PLACEMENTS.find((entry) => entry.id === placement)?.type} lead this section only. Empty places use this placement’s automatic selection.`}
|
||||||
icon="star"
|
icon="star"
|
||||||
tone="note"
|
tone="note"
|
||||||
footer={
|
footer={
|
||||||
@@ -88,8 +128,7 @@ export function HeroPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPins([]);
|
setCurrent({ pinnedItems: [] });
|
||||||
setDirty(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Clear pins
|
Clear pins
|
||||||
@@ -109,8 +148,7 @@ export function HeroPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
icon="close"
|
icon="close"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPins((current) => current.filter((pin) => pin.id !== item.id));
|
setCurrent({ pinnedItems: pins.filter((pin) => pin.id !== item.id) });
|
||||||
setDirty(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{index + 1}. {item.name}
|
{index + 1}. {item.name}
|
||||||
@@ -127,11 +165,10 @@ export function HeroPage() {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={160}
|
maxLength={160}
|
||||||
value={subtitle}
|
value={current.primeSubtitle}
|
||||||
placeholder="Leave blank for the automatic reason"
|
placeholder="Leave blank for the automatic reason"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setSubtitle(event.target.value);
|
setCurrent({ primeSubtitle: event.target.value });
|
||||||
setDirty(true);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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 Memby’s automatic hero." icon="clock" tone="info">
|
<Card title="Scheduled heroes" intro="Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Memby’s automatic hero." icon="clock" tone="info">
|
||||||
{schedules.length === 0 ? <Empty>No scheduled heroes yet.</Empty> : (
|
{schedules.length === 0 ? <Empty>No scheduled heroes yet.</Empty> : (
|
||||||
<div className="stack">{schedules.map((schedule) => {
|
<div className="stack">{schedules.map((schedule) => {
|
||||||
const item = [...pins, ...(results ?? [])].find((candidate) => candidate.id === schedule.itemId);
|
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}</span><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
|
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>
|
})}</div>
|
||||||
)}
|
)}
|
||||||
{pins.length > 0 ? <Button size="sm" icon="plus" onClick={() => {
|
{pins.length > 0 ? <Button size="sm" icon="plus" onClick={() => {
|
||||||
const first = pins[0]; if (!first) return;
|
const first = pins[0]; if (!first) return;
|
||||||
const start = new Date(); const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
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>}
|
}}>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>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="Find a title"
|
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"
|
icon="search"
|
||||||
tone="info"
|
tone="info"
|
||||||
>
|
>
|
||||||
@@ -183,7 +220,7 @@ export function HeroPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="plus"
|
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)}
|
onClick={() => add(item)}
|
||||||
>
|
>
|
||||||
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
|
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
|
||||||
|
|||||||
@@ -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>}</>;
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// A release workflow can derive the app version from its Git tag without editing the
|
||||||
// source tree. Local builds keep using the checked-in default.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.63"
|
val defaultVersionName = "0.2.64"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -515,6 +515,12 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The gateway resolves placement, policy and scheduling; the television only draws it. */
|
||||||
|
suspend fun getActiveHero(placement: String): List<HomeRow> {
|
||||||
|
if (!ServerConfig.isGateway) return emptyList()
|
||||||
|
return requireGateway().activeHero(placement).rows
|
||||||
|
}
|
||||||
|
|
||||||
/** A shuffled set of movies & shows that actually have a backdrop image. */
|
/** A shuffled set of movies & shows that actually have a backdrop image. */
|
||||||
suspend fun getScreensaverItems(limit: Int = 200): List<BaseItem> {
|
suspend fun getScreensaverItems(limit: Int = 200): List<BaseItem> {
|
||||||
if (ServerConfig.isGateway) {
|
if (ServerConfig.isGateway) {
|
||||||
@@ -905,6 +911,15 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
).title
|
).title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Records a problem against one concrete library movie or episode. */
|
||||||
|
suspend fun reportMediaProblem(itemId: String, reason: String, comment: String, requestReplacement: Boolean) {
|
||||||
|
check(ServerConfig.isGateway) { "Media reporting requires the Memby gateway" }
|
||||||
|
requireGateway().reportMediaProblem(
|
||||||
|
itemId,
|
||||||
|
com.ponzischeme89.memby.data.model.GatewayMediaReport(reason, comment.trim(), requestReplacement),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** This viewer's own asks, newest first, with the state each has reached. */
|
/** This viewer's own asks, newest first, with the state each has reached. */
|
||||||
suspend fun getMyRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList {
|
suspend fun getMyRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList {
|
||||||
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
|
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
|
||||||
|
|||||||
@@ -392,6 +392,13 @@ data class GatewayRows(
|
|||||||
val rows: List<HomeRow> = emptyList(),
|
val rows: List<HomeRow> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GatewayActiveHero(
|
||||||
|
val placement: String = "",
|
||||||
|
val source: String = "automatic",
|
||||||
|
val rows: List<HomeRow> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
/** Normalised third-party rating shared by cards, banners, and detail pages. */
|
/** Normalised third-party rating shared by cards, banners, and detail pages. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MediaRating(
|
data class MediaRating(
|
||||||
@@ -550,6 +557,13 @@ data class GatewayMediaRequestResult(
|
|||||||
val title: String = "",
|
val title: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GatewayMediaReport(
|
||||||
|
val reason: String,
|
||||||
|
val comment: String = "",
|
||||||
|
val requestReplacement: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayPrerollSchedule(
|
data class GatewayPrerollSchedule(
|
||||||
val today: List<GatewayPrerollEntry> = emptyList(),
|
val today: List<GatewayPrerollEntry> = emptyList(),
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import com.ponzischeme89.memby.data.model.GatewayFeatures
|
|||||||
import com.ponzischeme89.memby.data.model.GatewayDevices
|
import com.ponzischeme89.memby.data.model.GatewayDevices
|
||||||
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
import com.ponzischeme89.memby.data.model.GatewayHome
|
||||||
|
import com.ponzischeme89.memby.data.model.GatewayActiveHero
|
||||||
import com.ponzischeme89.memby.data.model.GatewayItems
|
import com.ponzischeme89.memby.data.model.GatewayItems
|
||||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
|
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
|
||||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequest
|
import com.ponzischeme89.memby.data.model.GatewayMediaRequest
|
||||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequestResult
|
import com.ponzischeme89.memby.data.model.GatewayMediaRequestResult
|
||||||
|
import com.ponzischeme89.memby.data.model.GatewayMediaReport
|
||||||
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
|
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
|
||||||
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
|
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
|
||||||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||||
@@ -68,6 +70,9 @@ interface GatewayApi {
|
|||||||
@GET("v1/home")
|
@GET("v1/home")
|
||||||
suspend fun home(@Query("limit") limit: Int): GatewayHome
|
suspend fun home(@Query("limit") limit: Int): GatewayHome
|
||||||
|
|
||||||
|
@GET("v1/heroes/active")
|
||||||
|
suspend fun activeHero(@Query("placement") placement: String): GatewayActiveHero
|
||||||
|
|
||||||
@GET("v1/screensaver")
|
@GET("v1/screensaver")
|
||||||
suspend fun screensaver(@Query("limit") limit: Int): GatewayItems
|
suspend fun screensaver(@Query("limit") limit: Int): GatewayItems
|
||||||
|
|
||||||
@@ -115,6 +120,12 @@ interface GatewayApi {
|
|||||||
@POST("v1/requests")
|
@POST("v1/requests")
|
||||||
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
|
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
|
||||||
|
|
||||||
|
@POST("v1/items/{id}/report")
|
||||||
|
suspend fun reportMediaProblem(
|
||||||
|
@Path("id") itemId: String,
|
||||||
|
@Body body: GatewayMediaReport,
|
||||||
|
)
|
||||||
|
|
||||||
@GET("v1/requests")
|
@GET("v1/requests")
|
||||||
suspend fun myRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList
|
suspend fun myRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ internal fun DetailPageScaffold(
|
|||||||
confirmation: String? = null,
|
confirmation: String? = null,
|
||||||
pageListState: LazyListState = remember(item.id) { LazyListState() },
|
pageListState: LazyListState = remember(item.id) { LazyListState() },
|
||||||
onZoneFocused: (DetailZone) -> Unit = {},
|
onZoneFocused: (DetailZone) -> Unit = {},
|
||||||
|
footer: (@Composable () -> Unit)? = null,
|
||||||
content: @Composable BoxScope.(DetailTab) -> Unit,
|
content: @Composable BoxScope.(DetailTab) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -448,6 +449,15 @@ internal fun DetailPageScaffold(
|
|||||||
Box(Modifier.fillMaxSize()) { content(visibleTab) }
|
Box(Modifier.fillMaxSize()) { content(visibleTab) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
footer?.let { footerContent ->
|
||||||
|
item(key = "footer") {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxWidth().padding(
|
||||||
|
start = DetailSideGutter, end = DetailSideGutter, bottom = 64.dp,
|
||||||
|
),
|
||||||
|
) { footerContent() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmation?.let {
|
confirmation?.let {
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ internal fun EpisodeDetailContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
footer = { MediaReportControl(item) },
|
||||||
) {
|
) {
|
||||||
EpisodeSeasonPane(
|
EpisodeSeasonPane(
|
||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
|
|||||||
@@ -1963,6 +1963,9 @@ private fun HomeScreen(
|
|||||||
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
|
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
var quickMenuRowId by remember { mutableStateOf<String?>(null) }
|
var quickMenuRowId by remember { mutableStateOf<String?>(null) }
|
||||||
var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
|
var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var sectionHeroRows by remember(settings.userId) {
|
||||||
|
mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap())
|
||||||
|
}
|
||||||
var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
|
var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
|
||||||
var myShowsLoading by remember(settings.userId) { mutableStateOf(true) }
|
var myShowsLoading by remember(settings.userId) { mutableStateOf(true) }
|
||||||
var myShowsError by remember(settings.userId) { mutableStateOf<String?>(null) }
|
var myShowsError by remember(settings.userId) { mutableStateOf<String?>(null) }
|
||||||
@@ -2316,16 +2319,42 @@ private fun HomeScreen(
|
|||||||
// The server rows are passed in *unfiltered*, beside the browse rows the launcher
|
// The server rows are passed in *unfiltered*, beside the browse rows the launcher
|
||||||
// draws: serverHomeRows drops the hero row, because it is consumed here rather than
|
// draws: serverHomeRows drops the hero row, because it is consumed here rather than
|
||||||
// rendered as a shelf, so this is the only thing that can still see it.
|
// rendered as a shelf, so this is the only thing that can still see it.
|
||||||
val homeHeroMovies = remember(rows, homeContent.rows, selectedDestination, heroDay) {
|
// Warm both section heroes while Home is being read. Navigation then normally draws
|
||||||
if (selectedDestination == BrowseDestination.HOME) {
|
// from memory, while the gateway's user/placement cache keeps this cheap across TVs.
|
||||||
selectHomeHeroMovies(rows, heroDay, homeContent.rows)
|
LaunchedEffect(settings.userId) {
|
||||||
} else {
|
listOf(
|
||||||
emptyList()
|
BrowseDestination.MOVIES to "movies",
|
||||||
|
BrowseDestination.SHOWS to "tv_shows",
|
||||||
|
).forEach { (destination, placement) ->
|
||||||
|
launch {
|
||||||
|
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
|
||||||
|
sectionHeroRows = sectionHeroRows + (destination to resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(selectedDestination, settings.userId) {
|
||||||
|
val placement = when (selectedDestination) {
|
||||||
|
BrowseDestination.MOVIES -> "movies"
|
||||||
|
BrowseDestination.SHOWS -> "tv_shows"
|
||||||
|
else -> null
|
||||||
|
} ?: return@LaunchedEffect
|
||||||
|
if (sectionHeroRows.containsKey(selectedDestination)) return@LaunchedEffect
|
||||||
|
runCatching { repo.getActiveHero(placement) }.onSuccess { resolved ->
|
||||||
|
sectionHeroRows = sectionHeroRows + (selectedDestination to resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val contextualHeroItems = remember(rows, homeContent.rows, sectionHeroRows, selectedDestination, heroDay) {
|
||||||
|
when (selectedDestination) {
|
||||||
|
BrowseDestination.HOME -> selectHomeHeroMovies(rows, heroDay, homeContent.rows)
|
||||||
|
BrowseDestination.MOVIES, BrowseDestination.SHOWS ->
|
||||||
|
serverHeroPicks(sectionHeroRows[selectedDestination].orEmpty())
|
||||||
|
else -> emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val showHomeGreeting = selectedDestination == BrowseDestination.HOME &&
|
val showHomeGreeting = selectedDestination == BrowseDestination.HOME &&
|
||||||
shouldShowHomeGreeting(
|
shouldShowHomeGreeting(
|
||||||
hasHero = homeHeroMovies.isNotEmpty(),
|
hasHero = contextualHeroItems.isNotEmpty(),
|
||||||
focusedRowId = focusedHomeRowId,
|
focusedRowId = focusedHomeRowId,
|
||||||
rowIds = rows.map(HomeBrowseRow::id),
|
rowIds = rows.map(HomeBrowseRow::id),
|
||||||
)
|
)
|
||||||
@@ -2377,10 +2406,10 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
selectedDestination,
|
selectedDestination,
|
||||||
homeHeroMovies.firstOrNull()?.item?.id,
|
contextualHeroItems.firstOrNull()?.item?.id,
|
||||||
rows.firstOrNull()?.items?.firstOrNull()?.id,
|
rows.firstOrNull()?.items?.firstOrNull()?.id,
|
||||||
) {
|
) {
|
||||||
val firstItem = homeHeroMovies.firstOrNull()?.item
|
val firstItem = contextualHeroItems.firstOrNull()?.item
|
||||||
?: rows.firstNotNullOfOrNull { it.items.firstOrNull() }
|
?: rows.firstNotNullOfOrNull { it.items.firstOrNull() }
|
||||||
firstItem?.let(homeViewModel::focusItem)
|
firstItem?.let(homeViewModel::focusItem)
|
||||||
if (firstItem != null && !initialFocusRequested) {
|
if (firstItem != null && !initialFocusRequested) {
|
||||||
@@ -2655,13 +2684,13 @@ private fun HomeScreen(
|
|||||||
verticalState.firstVisibleItemScrollOffset == 0
|
verticalState.firstVisibleItemScrollOffset == 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hasHomeHero = selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty()
|
val hasContextualHero = contextualHeroItems.isNotEmpty()
|
||||||
// Set by a card in a shelf taking focus and cleared by the hero taking it
|
// Set by a card in a shelf taking focus and cleared by the hero taking it
|
||||||
// back. Keyed on the destination so arriving at Home never inherits where
|
// back. Keyed on the destination so arriving at Home never inherits where
|
||||||
// focus happened to be on another one.
|
// focus happened to be on another one.
|
||||||
var rowFocusedBelowHero by remember(selectedDestination) { mutableStateOf(false) }
|
var rowFocusedBelowHero by remember(selectedDestination) { mutableStateOf(false) }
|
||||||
val showHomeHero = shouldShowHomeMovieHero(
|
val showHomeHero = shouldShowHomeMovieHero(
|
||||||
hasMovies = hasHomeHero,
|
hasMovies = hasContextualHero,
|
||||||
listAtTop = homeListAtTop,
|
listAtTop = homeListAtTop,
|
||||||
rowFocused = rowFocusedBelowHero,
|
rowFocused = rowFocusedBelowHero,
|
||||||
)
|
)
|
||||||
@@ -2747,7 +2776,7 @@ private fun HomeScreen(
|
|||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
if (showHomeHero) {
|
if (showHomeHero) {
|
||||||
HomeMovieHero(
|
HomeMovieHero(
|
||||||
movies = homeHeroMovies,
|
movies = contextualHeroItems,
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
contentEntryFocusRequester = contentFocusRequester,
|
contentEntryFocusRequester = contentFocusRequester,
|
||||||
returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID },
|
returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID },
|
||||||
@@ -2771,7 +2800,7 @@ private fun HomeScreen(
|
|||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
homeViewModel.focusItem(item)
|
homeViewModel.focusItem(item)
|
||||||
homeViewModel.trackJourney(
|
homeViewModel.trackJourney(
|
||||||
category = "content", action = "open", screen = "home",
|
category = "content", action = "open", screen = selectedDestination.name.lowercase(),
|
||||||
feature = "hero", source = HOME_HERO_ROW_ID, target = "details",
|
feature = "hero", source = HOME_HERO_ROW_ID, target = "details",
|
||||||
itemName = item.name, itemType = item.type,
|
itemName = item.name, itemType = item.type,
|
||||||
)
|
)
|
||||||
@@ -2935,7 +2964,7 @@ private fun HomeScreen(
|
|||||||
availableWidth = contentWidth,
|
availableWidth = contentWidth,
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
||||||
!hasHomeHero &&
|
!hasContextualHero &&
|
||||||
(selectedDestination != BrowseDestination.SHOWS ||
|
(selectedDestination != BrowseDestination.SHOWS ||
|
||||||
(!genreBrowserEnabled && myShows.isEmpty())) &&
|
(!genreBrowserEnabled && myShows.isEmpty())) &&
|
||||||
(selectedDestination != BrowseDestination.MOVIES ||
|
(selectedDestination != BrowseDestination.MOVIES ||
|
||||||
@@ -2943,7 +2972,7 @@ private fun HomeScreen(
|
|||||||
row.id == firstPopulatedRowId
|
row.id == firstPopulatedRowId
|
||||||
},
|
},
|
||||||
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
|
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
|
||||||
hasHomeHero && row.id == firstPopulatedRowId
|
hasContextualHero && row.id == firstPopulatedRowId
|
||||||
},
|
},
|
||||||
returnFocusItemId = returnItemId.takeIf { returnRowId == row.id },
|
returnFocusItemId = returnItemId.takeIf { returnRowId == row.id },
|
||||||
returnFocusRequester = cardReturnFocusRequester,
|
returnFocusRequester = cardReturnFocusRequester,
|
||||||
@@ -2967,7 +2996,7 @@ private fun HomeScreen(
|
|||||||
// not composed while a row holds focus, so there is
|
// not composed while a row holds focus, so there is
|
||||||
// nothing above for Compose's own focus search to
|
// nothing above for Compose's own focus search to
|
||||||
// find and the press would otherwise be dead.
|
// find and the press would otherwise be dead.
|
||||||
if (direction == RowFocusDirection.UP && hasHomeHero) {
|
if (direction == RowFocusDirection.UP && hasContextualHero) {
|
||||||
rowFocusedBelowHero = false
|
rowFocusedBelowHero = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
verticalState.animateScrollToItem(0)
|
verticalState.animateScrollToItem(0)
|
||||||
@@ -3719,7 +3748,7 @@ private fun HomeScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
kotlinx.coroutines.delay(16L)
|
kotlinx.coroutines.delay(16L)
|
||||||
val removalFocusRequester = if (
|
val removalFocusRequester = if (
|
||||||
selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty()
|
contextualHeroItems.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
heroRowEntryFocusRequester
|
heroRowEntryFocusRequester
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ internal fun MediaDetailContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
footer = { MediaReportControl(item) },
|
||||||
) { visibleTab ->
|
) { visibleTab ->
|
||||||
when (visibleTab) {
|
when (visibleTab) {
|
||||||
DetailTab.OVERVIEW -> DetailOverviewPane(item, credits, overviewPane)
|
DetailTab.OVERVIEW -> DetailOverviewPane(item, credits, overviewPane)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
|
import com.ponzischeme89.memby.data.ServerConfig
|
||||||
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
|
||||||
|
private val reportReasons = listOf(
|
||||||
|
"playback_problem" to "Playback problem", "poor_video_quality" to "Poor video quality",
|
||||||
|
"poor_audio_quality" to "Poor audio quality", "audio_out_of_sync" to "Audio out of sync",
|
||||||
|
"wrong_media" to "Wrong episode or movie", "missing_or_corrupt" to "Missing or corrupt content",
|
||||||
|
"subtitles_problem" to "Subtitles problem", "file_stops" to "File stops during playback",
|
||||||
|
"request_better_copy" to "Request a better or new copy", "other" to "Other",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The deliberately quiet last action on a detail page. It is absent on direct Emby mode. */
|
||||||
|
@Composable
|
||||||
|
internal fun MediaReportControl(item: BaseItem) {
|
||||||
|
if (!ServerConfig.isGateway || (item.type != "Movie" && item.type != "Episode")) return
|
||||||
|
var open by remember(item.id) { mutableStateOf(false) }
|
||||||
|
if (open) MediaReportDialog(item, onClose = { open = false })
|
||||||
|
Text(
|
||||||
|
"Report a problem",
|
||||||
|
color = DetailQuietText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { open = true }.padding(vertical = 22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MediaReportDialog(item: BaseItem, onClose: () -> Unit) {
|
||||||
|
var selected by remember { mutableStateOf("playback_problem") }
|
||||||
|
var replacement by remember { mutableStateOf(false) }
|
||||||
|
val mayReplace = ServiceLocator.repository.requestsPermitted
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
MediaReportDialogContent(item.name, mayReplace, selected, replacement, { selected = it }, { replacement = it }, onClose) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { ServiceLocator.repository.reportMediaProblem(item.id, selected, "", replacement) }
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun MediaReportDialogContent(
|
||||||
|
title: String, mayReplace: Boolean, selected: String, replacement: Boolean,
|
||||||
|
onSelect: (String) -> Unit, onReplacementChanged: (Boolean) -> Unit,
|
||||||
|
onClose: () -> Unit, onSend: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().background(Color(0xFF202126), RoundedCornerShape(16.dp)).padding(28.dp)) {
|
||||||
|
Text("Report a problem", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text(title, color = DetailQuietText, fontSize = 14.sp, modifier = Modifier.padding(bottom = 12.dp))
|
||||||
|
reportReasons.forEach { (value, label) ->
|
||||||
|
Row(Modifier.fillMaxWidth().clickable { onSelect(value) }.padding(vertical = 9.dp)) {
|
||||||
|
Text(if (selected == value) "●" else "○", color = DetailAccent, fontSize = 17.sp)
|
||||||
|
Spacer(Modifier.width(12.dp)); Text(label, color = Color.White, fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mayReplace) Row(Modifier.fillMaxWidth().clickable { onReplacementChanged(!replacement) }.padding(top = 12.dp, bottom = 18.dp)) {
|
||||||
|
Text(if (replacement) "☑" else "☐", color = DetailAccent, fontSize = 18.sp); Spacer(Modifier.width(12.dp))
|
||||||
|
Text("Request a replacement using the configured quality profile", color = Color.White, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
Text("Cancel", color = DetailQuietText, modifier = Modifier.clickable(onClick = onClose).padding(14.dp))
|
||||||
|
Spacer(Modifier.width(18.dp))
|
||||||
|
Text("Send report", color = DetailAccent, fontWeight = FontWeight.Bold, modifier = Modifier.clickable(onClick = onSend).padding(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/** Renders the permitted-user report flow to build/screenshots/media-report/. */
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||||
|
class MediaReportScreenshotTest {
|
||||||
|
@get:Rule val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Test fun replacementRequestDialog() {
|
||||||
|
compose.setContent {
|
||||||
|
MediaReportDialogContent(
|
||||||
|
title = "Severance — S02E04",
|
||||||
|
mayReplace = true,
|
||||||
|
selected = "playback_problem",
|
||||||
|
replacement = true,
|
||||||
|
onSelect = {}, onReplacementChanged = {}, onClose = {}, onSend = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
compose.onRoot().captureRoboImage("build/screenshots/media-report/media-report-replacement-dialog.png")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,21 +34,25 @@ type Event = store.AdminEvent
|
|||||||
// the console's type filter is built from what has actually been published, and an
|
// the console's type filter is built from what has actually been published, and an
|
||||||
// integration configured to send "everything" sends it.
|
// integration configured to send "everything" sends it.
|
||||||
const (
|
const (
|
||||||
TypeLogin = "auth.login"
|
TypeLogin = "auth.login"
|
||||||
TypeLoginFailed = "auth.login_failed"
|
TypeLoginFailed = "auth.login_failed"
|
||||||
TypeLogout = "auth.logout"
|
TypeLogout = "auth.logout"
|
||||||
TypeDeviceRegistered = "device.registered"
|
TypeDeviceRegistered = "device.registered"
|
||||||
TypeDeviceRemoved = "device.removed"
|
TypeDeviceRemoved = "device.removed"
|
||||||
TypeDeviceRenamed = "device.renamed"
|
TypeDeviceRenamed = "device.renamed"
|
||||||
TypeAdminSignIn = "admin.sign_in"
|
TypeAdminSignIn = "admin.sign_in"
|
||||||
TypeServerStarted = "server.started"
|
TypeServerStarted = "server.started"
|
||||||
TypeMaintenanceChanged = "server.maintenance"
|
TypeMaintenanceChanged = "server.maintenance"
|
||||||
TypeTaskCompleted = "task.completed"
|
TypeTaskCompleted = "task.completed"
|
||||||
TypeTaskFailed = "task.failed"
|
TypeTaskFailed = "task.failed"
|
||||||
TypeIntegrationFailed = "integration.failed"
|
TypeIntegrationFailed = "integration.failed"
|
||||||
TypeLibrarySync = "library.sync"
|
TypeLibrarySync = "library.sync"
|
||||||
TypeEmbyUnreachable = "emby.unreachable"
|
TypeEmbyUnreachable = "emby.unreachable"
|
||||||
TypeEmbyRecovered = "emby.recovered"
|
TypeEmbyRecovered = "emby.recovered"
|
||||||
|
TypeMediaReportCreated = "media.report.created"
|
||||||
|
TypeReplacementRequested = "media.replacement.requested"
|
||||||
|
TypeReplacementFailed = "media.replacement.failed"
|
||||||
|
TypeMediaReportResolved = "media.report.resolved"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Severity re-exports the store's vocabulary so a publisher needs only this package.
|
// Severity re-exports the store's vocabulary so a publisher needs only this package.
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ func (s *Server) adminRoutes() http.Handler {
|
|||||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||||
|
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
|
||||||
|
mux.Handle("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
|
||||||
mux.Handle("GET /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
mux.Handle("GET /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
||||||
mux.Handle("POST /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
mux.Handle("POST /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
||||||
mux.Handle("GET /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy))
|
mux.Handle("GET /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy))
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ func (s *Server) Routes() http.Handler {
|
|||||||
v1.Handle("DELETE /v1/auth/devices/{deviceID}", s.authed(s.handleDeleteDevice))
|
v1.Handle("DELETE /v1/auth/devices/{deviceID}", s.authed(s.handleDeleteDevice))
|
||||||
|
|
||||||
v1.Handle("GET /v1/home", s.authed(s.handleHome))
|
v1.Handle("GET /v1/home", s.authed(s.handleHome))
|
||||||
|
v1.Handle("GET /v1/heroes/active", s.authed(s.handleActiveHero))
|
||||||
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
||||||
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
|
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
|
||||||
// A genre is browsed, not searched: the chip is a filter and this is the route that
|
// A genre is browsed, not searched: the chip is a filter and this is the route that
|
||||||
@@ -252,6 +253,7 @@ func (s *Server) Routes() http.Handler {
|
|||||||
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
|
v1.Handle("GET /v1/items/{id}/trailers", s.authed(s.handleTrailers))
|
||||||
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
|
v1.Handle("POST /v1/items/{id}/trailers/resolve", s.authed(s.handleResolveTrailer))
|
||||||
v1.Handle("POST /v1/items/{id}/trailers/report", s.authed(s.handleTrailerReport))
|
v1.Handle("POST /v1/items/{id}/trailers/report", s.authed(s.handleTrailerReport))
|
||||||
|
v1.Handle("POST /v1/items/{id}/report", s.authed(s.handleMediaReport))
|
||||||
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
|
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
|
||||||
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
|
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
|
||||||
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
|
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
|
||||||
|
|||||||
@@ -678,10 +678,11 @@ func (s *Server) heroRow(
|
|||||||
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
|
s.loggerFor(ctx).Warn("hero policy unavailable", "error", err)
|
||||||
policy = store.HeroPolicy{}
|
policy = store.HeroPolicy{}
|
||||||
}
|
}
|
||||||
pinned := s.pinnedHeroCandidates(ctx, policy.PinnedItemIDs)
|
placementPolicy := policy.Placement(store.HeroPlacementHome)
|
||||||
|
pinned := s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs)
|
||||||
// Manual pins always lead. Schedules resolve on the gateway (never on a television),
|
// Manual pins always lead. Schedules resolve on the gateway (never on a television),
|
||||||
// then the existing automatic/release-aware selection fills any remaining places.
|
// then the existing automatic/release-aware selection fills any remaining places.
|
||||||
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, userID, now, location)
|
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, store.HeroPlacementHome, userID, now, location)
|
||||||
scheduled := s.pinnedHeroCandidates(ctx, scheduledIDs)
|
scheduled := s.pinnedHeroCandidates(ctx, scheduledIDs)
|
||||||
// Rank a pool, then draw the row out of it. Ranking straight to the row's length is
|
// Rank a pool, then draw the row out of it. Ranking straight to the row's length is
|
||||||
// what made the hero the same four cards for a week — see rotateHeroCandidates.
|
// what made the hero the same four cards for a week — see rotateHeroCandidates.
|
||||||
@@ -700,7 +701,7 @@ func (s *Server) heroRow(
|
|||||||
items = append(items, injectHeroFields(
|
items = append(items, injectHeroFields(
|
||||||
candidate.Item,
|
candidate.Item,
|
||||||
heroLabel(candidate, now),
|
heroLabel(candidate, now),
|
||||||
heroReasonForPosition(candidate, index, policy.PrimeSubtitle, now, location),
|
heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return &recommend.Row{
|
return &recommend.Row{
|
||||||
@@ -750,7 +751,7 @@ func mergePinnedHeroCandidates(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func activeHeroScheduleIDs(schedules []store.HeroSchedule, userID string, now time.Time, location *time.Location) []string {
|
func activeHeroScheduleIDs(schedules []store.HeroSchedule, placement, userID string, now time.Time, location *time.Location) []string {
|
||||||
type active struct {
|
type active struct {
|
||||||
id string
|
id string
|
||||||
priority int
|
priority int
|
||||||
@@ -761,6 +762,19 @@ func activeHeroScheduleIDs(schedules []store.HeroSchedule, userID string, now ti
|
|||||||
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) || now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
|
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) || now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
matchesPlacement := false
|
||||||
|
if len(schedule.Placements) == 0 && placement == store.HeroPlacementHome {
|
||||||
|
matchesPlacement = true
|
||||||
|
}
|
||||||
|
for _, candidate := range schedule.Placements {
|
||||||
|
if candidate == placement {
|
||||||
|
matchesPlacement = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matchesPlacement {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if len(schedule.Weekdays) > 0 {
|
if len(schedule.Weekdays) > 0 {
|
||||||
weekday := int(now.In(location).Weekday())
|
weekday := int(now.In(location).Weekday())
|
||||||
found := false
|
found := false
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type activeHeroResponse struct {
|
||||||
|
Placement string `json:"placement"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Rows []recommend.Row `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleActiveHero gives every section the same server-owned resolver as Home. The client
|
||||||
|
// supplies only a placement; schedules, priorities, pins and ranking remain gateway data.
|
||||||
|
func (s *Server) handleActiveHero(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||||
|
placement := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("placement")))
|
||||||
|
if !store.ValidHeroPlacement(placement) {
|
||||||
|
writeError(w, http.StatusBadRequest, "placement must be home, movies or tv_shows")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
location := s.cfg.RadarrLocation
|
||||||
|
if location == nil {
|
||||||
|
location = time.Local
|
||||||
|
}
|
||||||
|
key := cache.UserKey(sess.EmbyUserID, "hero:active:v1:"+placement+":"+heroRotationSlot(now, location))
|
||||||
|
if raw, err := s.cache.Get(r.Context(), key); err == nil {
|
||||||
|
w.Header().Set("X-Memby-Cache", "hit")
|
||||||
|
writeRaw(w, http.StatusOK, raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.resolveActiveHero(r.Context(), sess, placement, now)
|
||||||
|
if err != nil {
|
||||||
|
s.writeUpstreamError(r.Context(), w, err, "could not resolve the section hero")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not build the section hero")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.cache.Set(r.Context(), key, raw, s.cfg.SearchTTL); err != nil {
|
||||||
|
s.loggerFor(r.Context()).Warn("section hero cache write failed", "placement", placement, "error", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Memby-Cache", "miss")
|
||||||
|
writeRaw(w, http.StatusOK, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resolveActiveHero(ctx context.Context, sess store.Session, placement string, now time.Time) (activeHeroResponse, error) {
|
||||||
|
itemType := "Movie"
|
||||||
|
if placement == store.HeroPlacementTVShows {
|
||||||
|
itemType = "Series"
|
||||||
|
}
|
||||||
|
if placement == store.HeroPlacementHome {
|
||||||
|
itemType = "Movie,Series"
|
||||||
|
}
|
||||||
|
result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
|
||||||
|
"IncludeItemTypes": {itemType}, "Recursive": {"true"}, "Limit": {"64"},
|
||||||
|
"SortBy": {"DateCreated,PremiereDate,SortName"}, "SortOrder": {"Descending"},
|
||||||
|
}, fieldsRow))
|
||||||
|
if err != nil {
|
||||||
|
return activeHeroResponse{}, err
|
||||||
|
}
|
||||||
|
s.decorateItemRatings(ctx, result.Items)
|
||||||
|
rows := []recommend.Row{{ID: "hero-candidates-" + placement, Kind: "catalogue", Items: result.Items}}
|
||||||
|
|
||||||
|
policy, err := s.store.HeroPolicy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("section hero policy unavailable", "placement", placement, "error", err)
|
||||||
|
policy = store.HeroPolicy{}
|
||||||
|
}
|
||||||
|
placementPolicy := policy.Placement(placement)
|
||||||
|
pinned := filterHeroPlacement(s.pinnedHeroCandidates(ctx, placementPolicy.PinnedItemIDs), placement)
|
||||||
|
location := s.cfg.RadarrLocation
|
||||||
|
if location == nil {
|
||||||
|
location = time.Local
|
||||||
|
}
|
||||||
|
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, placement, sess.EmbyUserID, now, location)
|
||||||
|
scheduled := filterHeroPlacement(s.pinnedHeroCandidates(ctx, scheduledIDs), placement)
|
||||||
|
|
||||||
|
var candidates []heroCandidate
|
||||||
|
if placement == store.HeroPlacementTVShows {
|
||||||
|
candidates = append(s.heroPremiereCandidates(ctx, now), heroSeriesCandidates(rows)...)
|
||||||
|
candidates = filterHeroPlacement(candidates, placement)
|
||||||
|
} else if placement == store.HeroPlacementMovies {
|
||||||
|
candidates = s.heroMoviePlacementCandidates(ctx, rows, now)
|
||||||
|
} else {
|
||||||
|
candidates = s.heroCandidates(ctx, rows, now)
|
||||||
|
}
|
||||||
|
pool := rankHeroCandidates(candidates, now, heroPoolLimit)
|
||||||
|
organic := rotateHeroCandidates(pool, heroVariationSeed(sess.EmbyUserID, heroRotationSlot(now, location)+":"+placement), heroRowLimit)
|
||||||
|
ranked := mergePinnedHeroCandidates(append(pinned, scheduled...), candidates, organic, heroRowLimit)
|
||||||
|
|
||||||
|
source := "automatic"
|
||||||
|
if len(pinned) > 0 {
|
||||||
|
source = "manual"
|
||||||
|
} else if len(scheduled) > 0 {
|
||||||
|
source = "scheduled"
|
||||||
|
}
|
||||||
|
response := activeHeroResponse{Placement: placement, Source: source, Rows: []recommend.Row{}}
|
||||||
|
if len(ranked) == 0 {
|
||||||
|
response.Source = "fallback"
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
items := make([]json.RawMessage, 0, len(ranked))
|
||||||
|
for index, candidate := range ranked {
|
||||||
|
items = append(items, injectHeroFields(candidate.Item, heroLabel(candidate, now), heroReasonForPosition(candidate, index, placementPolicy.PrimeSubtitle, now, location)))
|
||||||
|
}
|
||||||
|
response.Rows = append(response.Rows, recommend.Row{ID: "hero-" + placement, Title: "Featured", Kind: heroRowKind, Items: items})
|
||||||
|
s.loggerFor(ctx).Debug("section hero resolved", "placement", placement, "source", source, "items", len(items))
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movies needs Radarr's real availability date, but never needs to wait for Sonarr. Home
|
||||||
|
// intentionally combines both; keeping this path narrow is what makes section preloading
|
||||||
|
// inexpensive even when one integration is unavailable.
|
||||||
|
func (s *Server) heroMoviePlacementCandidates(ctx context.Context, rows []recommend.Row, now time.Time) []heroCandidate {
|
||||||
|
movies, facts := heroMovieCandidates(rows)
|
||||||
|
var releases heroReleaseIndex
|
||||||
|
var providers map[string]string
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() { defer wg.Done(); releases = s.heroReleaseIndex(ctx, now) }()
|
||||||
|
go func() { defer wg.Done(); providers = s.heroProviderIDs(ctx, facts) }()
|
||||||
|
wg.Wait()
|
||||||
|
for index := range movies {
|
||||||
|
fact := facts[movies[index].ID]
|
||||||
|
if release, ok := releases.lookup(providers[movies[index].ID], fact.Name, heroYearOf(fact)); ok {
|
||||||
|
movies[index].ReleasedAt = release.at
|
||||||
|
movies[index].Estimated = release.estimated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return movies
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterHeroPlacement(candidates []heroCandidate, placement string) []heroCandidate {
|
||||||
|
out := make([]heroCandidate, 0, len(candidates))
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
isMovie := candidate.Kind == heroMovie
|
||||||
|
if placement == store.HeroPlacementMovies && isMovie {
|
||||||
|
out = append(out, candidate)
|
||||||
|
}
|
||||||
|
if placement == store.HeroPlacementTVShows && !isMovie {
|
||||||
|
out = append(out, candidate)
|
||||||
|
}
|
||||||
|
if placement == store.HeroPlacementHome {
|
||||||
|
out = append(out, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func heroSeriesCandidates(rows []recommend.Row) []heroCandidate {
|
||||||
|
out := make([]heroCandidate, 0, heroCandidateLimit)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, row := range rows {
|
||||||
|
for _, raw := range row.Items {
|
||||||
|
fact, ok := heroFactsOf(raw)
|
||||||
|
if !ok || seen[fact.ID] || !fact.Playable || !strings.EqualFold(fact.Type, "Series") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[fact.ID] = true
|
||||||
|
rating, rated := heroRatingOf(raw)
|
||||||
|
out = append(out, heroCandidate{ID: fact.ID, Name: fact.Name, Kind: heroSeries, Item: raw, ReleasedAt: fact.Premiere, Rating: rating, Rated: rated})
|
||||||
|
if len(out) == heroCandidateLimit {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -17,9 +17,16 @@ type heroAdminItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type heroAdminPolicy struct {
|
type heroAdminPolicy struct {
|
||||||
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
||||||
PrimeSubtitle string `json:"primeSubtitle"`
|
Items []heroAdminItem `json:"items"`
|
||||||
Schedules []store.HeroSchedule `json:"schedules"`
|
PrimeSubtitle string `json:"primeSubtitle"`
|
||||||
|
Placements map[string]heroAdminPlacement `json:"placements"`
|
||||||
|
Schedules []store.HeroSchedule `json:"schedules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type heroAdminPlacement struct {
|
||||||
|
PinnedItems []heroAdminItem `json:"pinnedItems"`
|
||||||
|
PrimeSubtitle string `json:"primeSubtitle"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminHeroItem(raw json.RawMessage) (heroAdminItem, bool) {
|
func adminHeroItem(raw json.RawMessage) (heroAdminItem, bool) {
|
||||||
@@ -35,12 +42,20 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
|
|||||||
policy, err := s.store.HeroPolicy(ctx)
|
policy, err := s.store.HeroPolicy(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(ctx).Warn("hero policy unavailable to admin", "error", err)
|
s.loggerFor(ctx).Warn("hero policy unavailable to admin", "error", err)
|
||||||
return heroAdminPolicy{PinnedItems: []heroAdminItem{}}
|
return heroAdminPolicy{PinnedItems: []heroAdminItem{}, Placements: map[string]heroAdminPlacement{}}
|
||||||
}
|
}
|
||||||
items, err := s.store.LibraryItemsByID(ctx, policy.PinnedItemIDs)
|
allIDs := []string{}
|
||||||
|
for _, placement := range policy.Placements {
|
||||||
|
allIDs = append(allIDs, placement.PinnedItemIDs...)
|
||||||
|
}
|
||||||
|
for _, schedule := range policy.Schedules {
|
||||||
|
allIDs = append(allIDs, schedule.ItemID)
|
||||||
|
}
|
||||||
|
allIDs = uniqueHeroIDs(allIDs)
|
||||||
|
items, err := s.store.LibraryItemsByID(ctx, allIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(ctx).Warn("pinned hero titles unavailable to admin", "error", err)
|
s.loggerFor(ctx).Warn("pinned hero titles unavailable to admin", "error", err)
|
||||||
return heroAdminPolicy{PinnedItems: []heroAdminItem{}}
|
return heroAdminPolicy{PinnedItems: []heroAdminItem{}, Placements: map[string]heroAdminPlacement{}}
|
||||||
}
|
}
|
||||||
byID := make(map[string]heroAdminItem, len(items))
|
byID := make(map[string]heroAdminItem, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
@@ -49,15 +64,26 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
out := heroAdminPolicy{
|
out := heroAdminPolicy{
|
||||||
PinnedItems: make([]heroAdminItem, 0, len(policy.PinnedItemIDs)),
|
Placements: make(map[string]heroAdminPlacement, len(policy.Placements)),
|
||||||
PrimeSubtitle: policy.PrimeSubtitle,
|
Items: []heroAdminItem{},
|
||||||
Schedules: policy.Schedules,
|
Schedules: policy.Schedules,
|
||||||
}
|
}
|
||||||
for _, id := range policy.PinnedItemIDs {
|
for _, id := range allIDs {
|
||||||
if item, ok := byID[id]; ok {
|
if item, ok := byID[id]; ok {
|
||||||
out.PinnedItems = append(out.PinnedItems, item)
|
out.Items = append(out.Items, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for name, placement := range policy.Placements {
|
||||||
|
adminPlacement := heroAdminPlacement{PinnedItems: []heroAdminItem{}, PrimeSubtitle: placement.PrimeSubtitle}
|
||||||
|
for _, id := range placement.PinnedItemIDs {
|
||||||
|
if item, ok := byID[id]; ok {
|
||||||
|
adminPlacement.PinnedItems = append(adminPlacement.PinnedItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Placements[name] = adminPlacement
|
||||||
|
}
|
||||||
|
home := out.Placements[store.HeroPlacementHome]
|
||||||
|
out.PinnedItems, out.PrimeSubtitle = home.PinnedItems, home.PrimeSubtitle
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,38 +105,85 @@ func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
var request struct {
|
var request struct {
|
||||||
PinnedItemIDs []string `json:"pinnedItemIds"`
|
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||||
PrimeSubtitle string `json:"primeSubtitle"`
|
PrimeSubtitle string `json:"primeSubtitle"`
|
||||||
Schedules []store.HeroSchedule `json:"schedules"`
|
Placements map[string]struct {
|
||||||
|
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||||
|
PrimeSubtitle string `json:"primeSubtitle"`
|
||||||
|
} `json:"placements"`
|
||||||
|
Schedules []store.HeroSchedule `json:"schedules"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid hero policy")
|
writeError(w, http.StatusBadRequest, "invalid hero policy")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ids := uniqueHeroIDs(request.PinnedItemIDs)
|
if request.Placements == nil {
|
||||||
if len(ids) > 4 {
|
request.Placements = map[string]struct {
|
||||||
writeError(w, http.StatusBadRequest, "the hero can pin at most four titles")
|
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||||
return
|
PrimeSubtitle string `json:"primeSubtitle"`
|
||||||
|
}{
|
||||||
|
store.HeroPlacementHome: {PinnedItemIDs: request.PinnedItemIDs, PrimeSubtitle: request.PrimeSubtitle},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
items, err := s.store.LibraryItemsByID(r.Context(), ids)
|
allIDs := []string{}
|
||||||
|
placementPolicies := map[string]store.HeroPlacementPolicy{}
|
||||||
|
for name, placement := range request.Placements {
|
||||||
|
if !store.ValidHeroPlacement(name) {
|
||||||
|
writeError(w, http.StatusBadRequest, "unknown hero placement")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := uniqueHeroIDs(placement.PinnedItemIDs)
|
||||||
|
if len(ids) > 4 {
|
||||||
|
writeError(w, http.StatusBadRequest, "each hero can pin at most four titles")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
placementPolicies[name] = store.HeroPlacementPolicy{PinnedItemIDs: ids, PrimeSubtitle: placement.PrimeSubtitle}
|
||||||
|
allIDs = append(allIDs, ids...)
|
||||||
|
}
|
||||||
|
for _, schedule := range request.Schedules {
|
||||||
|
allIDs = append(allIDs, schedule.ItemID)
|
||||||
|
}
|
||||||
|
items, err := s.store.LibraryItemsByID(r.Context(), uniqueHeroIDs(allIDs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(r.Context()).Error("hero title validation failed", "error", err)
|
s.loggerFor(r.Context()).Error("hero title validation failed", "error", err)
|
||||||
writeError(w, http.StatusInternalServerError, "could not validate hero titles")
|
writeError(w, http.StatusInternalServerError, "could not validate hero titles")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
valid := map[string]bool{}
|
valid := map[string]heroAdminItem{}
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
if item, ok := adminHeroItem(raw); ok {
|
if item, ok := adminHeroItem(raw); ok {
|
||||||
valid[item.ID] = true
|
valid[item.ID] = item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for name, placement := range placementPolicies {
|
||||||
if !valid[id] {
|
for _, id := range placement.PinnedItemIDs {
|
||||||
writeError(w, http.StatusBadRequest, "every pinned item must be a playable library film or series")
|
item, ok := valid[id]
|
||||||
|
if !ok || (name == store.HeroPlacementMovies && !strings.EqualFold(item.Type, "Movie")) || (name == store.HeroPlacementTVShows && !strings.EqualFold(item.Type, "Series")) {
|
||||||
|
writeError(w, http.StatusBadRequest, "every pinned item must match its hero placement")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, schedule := range request.Schedules {
|
||||||
|
item, ok := valid[strings.TrimSpace(schedule.ItemID)]
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "every scheduled hero must be a playable library film or series")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
placements := schedule.Placements
|
||||||
|
if len(placements) == 0 {
|
||||||
|
placements = []string{store.HeroPlacementHome}
|
||||||
|
}
|
||||||
|
for _, name := range placements {
|
||||||
|
if !store.ValidHeroPlacement(name) ||
|
||||||
|
(name == store.HeroPlacementMovies && !strings.EqualFold(item.Type, "Movie")) ||
|
||||||
|
(name == store.HeroPlacementTVShows && !strings.EqualFold(item.Type, "Series")) {
|
||||||
|
writeError(w, http.StatusBadRequest, "every scheduled item must match its hero placement")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
policy := store.HeroPolicy{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle, Schedules: request.Schedules}
|
policy := store.HeroPolicy{Placements: placementPolicies, Schedules: request.Schedules}
|
||||||
if err := s.store.SetHeroPolicy(r.Context(), policy); err != nil {
|
if err := s.store.SetHeroPolicy(r.Context(), policy); err != nil {
|
||||||
s.loggerFor(r.Context()).Error("hero policy write failed", "error", err)
|
s.loggerFor(r.Context()).Error("hero policy write failed", "error", err)
|
||||||
writeError(w, http.StatusInternalServerError, "could not save hero policy")
|
writeError(w, http.StatusInternalServerError, "could not save hero policy")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var heroNow = time.Date(2026, 8, 7, 20, 0, 0, 0, time.UTC)
|
var heroNow = time.Date(2026, 8, 7, 20, 0, 0, 0, time.UTC)
|
||||||
@@ -630,6 +632,16 @@ func TestAdminHeroSearchAcceptsFilmsAndSeriesOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHeroPlacementFiltersNeverCrossMediaTypes(t *testing.T) {
|
||||||
|
candidates := []heroCandidate{{ID: "film", Kind: heroMovie}, {ID: "show", Kind: heroSeries}}
|
||||||
|
if got := heroIDs(filterHeroPlacement(candidates, store.HeroPlacementMovies)); !reflect.DeepEqual(got, []string{"film"}) {
|
||||||
|
t.Fatalf("movie hero candidates = %v", got)
|
||||||
|
}
|
||||||
|
if got := heroIDs(filterHeroPlacement(candidates, store.HeroPlacementTVShows)); !reflect.DeepEqual(got, []string{"show"}) {
|
||||||
|
t.Fatalf("television hero candidates = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func heroIDs(candidates []heroCandidate) []string {
|
func heroIDs(candidates []heroCandidate) []string {
|
||||||
ids := make([]string, 0, len(candidates))
|
ids := make([]string, 0, len(candidates))
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mediaReportReasons = map[string]bool{
|
||||||
|
"playback_problem": true, "poor_video_quality": true, "poor_audio_quality": true,
|
||||||
|
"audio_out_of_sync": true, "wrong_media": true, "missing_or_corrupt": true,
|
||||||
|
"subtitles_problem": true, "file_stops": true, "request_better_copy": true, "other": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
type mediaReportPayload struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
RequestReplacement bool `json:"requestReplacement"`
|
||||||
|
}
|
||||||
|
type reportItem struct {
|
||||||
|
Type string `json:"Type"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
SeriesName string `json:"SeriesName"`
|
||||||
|
SeriesID string `json:"SeriesId"`
|
||||||
|
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||||
|
IndexNumber int `json:"IndexNumber"`
|
||||||
|
ProviderIDs map[string]string `json:"ProviderIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMediaReport(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||||
|
if s.store == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "media reporting is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request mediaReportPayload
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&request); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid media report")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Reason = strings.TrimSpace(request.Reason)
|
||||||
|
request.Comment = strings.TrimSpace(request.Comment)
|
||||||
|
if !mediaReportReasons[request.Reason] || len(request.Comment) > 500 {
|
||||||
|
writeError(w, http.StatusBadRequest, "choose a valid report category and keep the comment under 500 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemID := r.PathValue("id")
|
||||||
|
raw, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds,SeriesId,SeriesName,ParentIndexNumber,IndexNumber")
|
||||||
|
if err != nil {
|
||||||
|
s.writeUpstreamError(r.Context(), w, err, "could not identify the media item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var item reportItem
|
||||||
|
if err := json.Unmarshal(raw, &item); err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "could not read the media item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaType := strings.ToLower(item.Type)
|
||||||
|
if mediaType != "movie" && mediaType != "episode" {
|
||||||
|
writeError(w, http.StatusBadRequest, "only movies and episodes can be reported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report, err := s.store.CreateMediaReport(r.Context(), store.MediaReport{EmbyItemID: itemID, MediaType: mediaType, Title: item.Name, SeriesTitle: item.SeriesName, SeasonNumber: item.ParentIndexNumber, EpisodeNumber: item.IndexNumber, Reason: request.Reason, Comment: request.Comment, ReportedByUserID: sess.EmbyUserID, ReportedByUsername: sess.Username, ReportedByDevice: sess.DeviceName, ReplacementRequested: request.RequestReplacement})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusConflict, "this media item already has an open report")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeMediaReportCreated, Severity: adminevents.SeverityWarning, Title: "Media issue reported", Summary: fmt.Sprintf("%s reported %s with %s.", sess.Username, reportLabel(report), request.Reason), Link: "/admin/media-reports", Metadata: adminevents.Meta(map[string]any{"reportId": report.ID, "itemId": itemID})})
|
||||||
|
if request.RequestReplacement {
|
||||||
|
if !s.requestAllowed(r, sess) {
|
||||||
|
s.loggerFor(r.Context()).Warn("replacement not permitted", "report", report.ID, "user", sess.Username)
|
||||||
|
writeJSON(w, http.StatusCreated, report)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.requestReplacement(r, report, item); err != nil {
|
||||||
|
s.loggerFor(r.Context()).Warn("media replacement request failed", "report", report.ID, "error", err)
|
||||||
|
s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeReplacementFailed, Severity: adminevents.SeverityWarning, Title: "Replacement search failed", Summary: fmt.Sprintf("%s needs an operator review: %v", reportLabel(report), err), Link: "/admin/media-reports"})
|
||||||
|
} else {
|
||||||
|
s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeReplacementRequested, Severity: adminevents.SeverityInfo, Title: "Replacement requested", Summary: fmt.Sprintf("A new copy of %s was requested through %s.", reportLabel(report), map[string]string{"movie": "Radarr", "episode": "Sonarr"}[report.MediaType]), Link: "/admin/media-reports"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) requestReplacement(r *http.Request, report store.MediaReport, item reportItem) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
if report.MediaType == "movie" {
|
||||||
|
if !s.radarrEnabled(ctx) {
|
||||||
|
return fmt.Errorf("Radarr is unavailable")
|
||||||
|
}
|
||||||
|
tmdb, _ := strconv.Atoi(item.ProviderIDs["Tmdb"])
|
||||||
|
movies, err := s.radarr.Movies(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, movie := range movies {
|
||||||
|
if movie.TMDBID == tmdb && tmdb > 0 {
|
||||||
|
return s.radarr.SearchMovie(ctx, movie.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("movie is not tracked by Radarr")
|
||||||
|
}
|
||||||
|
if !s.sonarrEnabled(ctx) {
|
||||||
|
return fmt.Errorf("Sonarr is unavailable")
|
||||||
|
}
|
||||||
|
tvdb, _ := strconv.Atoi(item.ProviderIDs["Tvdb"])
|
||||||
|
series, err := s.sonarr.Series(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, show := range series {
|
||||||
|
if show.TVDBID == tvdb && tvdb > 0 {
|
||||||
|
episodes, err := s.sonarr.Episodes(ctx, show.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, episode := range episodes {
|
||||||
|
if episode.SeasonNumber == item.ParentIndexNumber && episode.EpisodeNumber == item.IndexNumber {
|
||||||
|
return s.sonarr.SearchEpisode(ctx, episode.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("episode is not tracked by Sonarr")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("series is not tracked by Sonarr")
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportLabel(r store.MediaReport) string {
|
||||||
|
if r.MediaType == "episode" {
|
||||||
|
return fmt.Sprintf("%s S%02dE%02d", r.SeriesTitle, r.SeasonNumber, r.EpisodeNumber)
|
||||||
|
}
|
||||||
|
return r.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminMediaReports(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reports, err := s.store.MediaReports(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not load media reports")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"reports": reports})
|
||||||
|
}
|
||||||
|
func (s *Server) handleAdminMediaReportStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id < 1 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid report")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(r.Body).Decode(&request) != nil || !map[string]bool{"acknowledged": true, "resolved": true, "dismissed": true}[request.Status] {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid report status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetMediaReportStatus(r.Context(), id, request.Status, "administrator"); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not update report")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Status == "resolved" {
|
||||||
|
s.publishAdmin(r.Context(), adminevents.Event{Type: adminevents.TypeMediaReportResolved, Title: "Media report resolved", Summary: fmt.Sprintf("Media report %d was resolved.", id), Link: "/admin/media-reports"})
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -53,6 +53,17 @@ type Movie struct {
|
|||||||
MinimumAvailability string `json:"minimumAvailability,omitempty"`
|
MinimumAvailability string `json:"minimumAvailability,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchMovie asks Radarr to search exactly one tracked film. It does not delete or
|
||||||
|
// unmonitor the existing file; Radarr retains it until its normal import policy wins.
|
||||||
|
func (c *Client) SearchMovie(ctx context.Context, movieID int) error {
|
||||||
|
if movieID <= 0 {
|
||||||
|
return fmt.Errorf("radarr: invalid movie id")
|
||||||
|
}
|
||||||
|
return c.post(ctx, "/api/v3/command", map[string]any{
|
||||||
|
"name": "MoviesSearch", "movieIds": []int{movieID},
|
||||||
|
}, &struct{}{})
|
||||||
|
}
|
||||||
|
|
||||||
type RootFolder struct {
|
type RootFolder struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ type Episode struct {
|
|||||||
EpisodeFile *EpisodeFile `json:"episodeFile"`
|
EpisodeFile *EpisodeFile `json:"episodeFile"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchEpisode asks Sonarr to search exactly one episode. It deliberately uses the
|
||||||
|
// episode ids command rather than a series command, so a replacement cannot search a
|
||||||
|
// season or the show's backlog.
|
||||||
|
func (c *Client) SearchEpisode(ctx context.Context, episodeID int) error {
|
||||||
|
if episodeID <= 0 {
|
||||||
|
return fmt.Errorf("sonarr: invalid episode id")
|
||||||
|
}
|
||||||
|
return c.post(ctx, "/api/v3/command", map[string]any{
|
||||||
|
"name": "EpisodeSearch", "episodeIds": []int{episodeID},
|
||||||
|
}, &struct{}{})
|
||||||
|
}
|
||||||
|
|
||||||
type APIError struct {
|
type APIError struct {
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Body string
|
Body string
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaReport struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
EmbyItemID string `json:"embyItemId"`
|
||||||
|
MediaType string `json:"mediaType"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
SeriesTitle string `json:"seriesTitle,omitempty"`
|
||||||
|
SeasonNumber int `json:"seasonNumber,omitempty"`
|
||||||
|
EpisodeNumber int `json:"episodeNumber,omitempty"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
ReportedByUserID string `json:"reportedByUserId"`
|
||||||
|
ReportedByUsername string `json:"reportedByUsername"`
|
||||||
|
ReportedByDevice string `json:"reportedByDevice,omitempty"`
|
||||||
|
ReplacementRequested bool `json:"replacementRequested"`
|
||||||
|
ReplacementStatus string `json:"replacementStatus,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ArrItemID int `json:"arrItemId,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
Actions []MediaReportAction `json:"actions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaReportAction struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
Actor string `json:"actor,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateMediaReport(ctx context.Context, report MediaReport) (MediaReport, error) {
|
||||||
|
err := s.pool.QueryRow(ctx, `INSERT INTO media_reports
|
||||||
|
(emby_item_id, media_type, title, series_title, season_number, episode_number, reason, comment,
|
||||||
|
reported_by_user_id, reported_by_username, reported_by_device, replacement_requested)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
|
RETURNING id, status, created_at, updated_at`, report.EmbyItemID, report.MediaType, report.Title,
|
||||||
|
report.SeriesTitle, report.SeasonNumber, report.EpisodeNumber, report.Reason, report.Comment,
|
||||||
|
report.ReportedByUserID, report.ReportedByUsername, report.ReportedByDevice, report.ReplacementRequested,
|
||||||
|
).Scan(&report.ID, &report.Status, &report.CreatedAt, &report.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return report, fmt.Errorf("store: create media report: %w", err)
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, `INSERT INTO media_report_actions (report_id, action, actor) VALUES ($1, 'reported', $2)`, report.ID, report.ReportedByUsername)
|
||||||
|
if err != nil {
|
||||||
|
return report, fmt.Errorf("store: record media report action: %w", err)
|
||||||
|
}
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MediaReports(ctx context.Context) ([]MediaReport, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT id, emby_item_id, media_type, title, series_title, season_number, episode_number, reason, comment,
|
||||||
|
reported_by_user_id, reported_by_username, reported_by_device, replacement_requested, replacement_status, status, arr_item_id, created_at, updated_at
|
||||||
|
FROM media_reports ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list media reports: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
reports := []MediaReport{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r MediaReport
|
||||||
|
if err := rows.Scan(&r.ID, &r.EmbyItemID, &r.MediaType, &r.Title, &r.SeriesTitle, &r.SeasonNumber, &r.EpisodeNumber, &r.Reason, &r.Comment, &r.ReportedByUserID, &r.ReportedByUsername, &r.ReportedByDevice, &r.ReplacementRequested, &r.ReplacementStatus, &r.Status, &r.ArrItemID, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan media report: %w", err)
|
||||||
|
}
|
||||||
|
reports = append(reports, r)
|
||||||
|
}
|
||||||
|
return reports, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetMediaReportStatus(ctx context.Context, id int64, status, actor string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `UPDATE media_reports SET status=$2, updated_at=now() WHERE id=$1`, id, status)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: update media report: %w", err)
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, `INSERT INTO media_report_actions (report_id, action, actor) VALUES ($1,$2,$3)`, id, status, actor)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -657,3 +657,42 @@ CREATE TABLE IF NOT EXISTS integration_deliveries (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS integration_deliveries_idx
|
CREATE INDEX IF NOT EXISTS integration_deliveries_idx
|
||||||
ON integration_deliveries (integration_id, attempted_at DESC);
|
ON integration_deliveries (integration_id, attempted_at DESC);
|
||||||
|
|
||||||
|
-- A viewer's report about one concrete Emby movie or episode. A replacement is a
|
||||||
|
-- separate state on the report so an ordinary playback complaint can never start a
|
||||||
|
-- download. The uniqueness constraint is the first duplicate guard: one open workflow
|
||||||
|
-- owns an item until an operator resolves or dismisses it.
|
||||||
|
CREATE TABLE IF NOT EXISTS media_reports (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
emby_item_id TEXT NOT NULL,
|
||||||
|
media_type TEXT NOT NULL, -- movie | episode
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
series_title TEXT NOT NULL DEFAULT '',
|
||||||
|
season_number INT NOT NULL DEFAULT 0,
|
||||||
|
episode_number INT NOT NULL DEFAULT 0,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
reported_by_user_id TEXT NOT NULL,
|
||||||
|
reported_by_username TEXT NOT NULL,
|
||||||
|
reported_by_device TEXT NOT NULL DEFAULT '',
|
||||||
|
replacement_requested BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
replacement_status TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'new',
|
||||||
|
arr_item_id INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS media_reports_open_item_idx
|
||||||
|
ON media_reports (emby_item_id) WHERE status IN ('new', 'acknowledged', 'replacement_requested', 'downloading');
|
||||||
|
CREATE INDEX IF NOT EXISTS media_reports_created_idx ON media_reports (created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS media_report_actions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
report_id BIGINT NOT NULL REFERENCES media_reports(id) ON DELETE CASCADE,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
actor TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS media_report_actions_report_idx
|
||||||
|
ON media_report_actions (report_id, created_at ASC);
|
||||||
|
|||||||
@@ -85,24 +85,78 @@ const MDBListSettingsKey = "mdblist_settings"
|
|||||||
// HeroPolicy stores only Emby ids and the optional prime-card copy. Names and artwork
|
// HeroPolicy stores only Emby ids and the optional prime-card copy. Names and artwork
|
||||||
// remain library data, so a metadata correction appears without rewriting operator policy.
|
// remain library data, so a metadata correction appears without rewriting operator policy.
|
||||||
type HeroPolicy struct {
|
type HeroPolicy struct {
|
||||||
PinnedItemIDs []string `json:"pinnedItemIds"`
|
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||||
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
|
LegacyPinnedMovieIDs []string `json:"pinnedMovieIds,omitempty"`
|
||||||
PrimeSubtitle string `json:"primeSubtitle"`
|
PrimeSubtitle string `json:"primeSubtitle"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
Placements map[string]HeroPlacementPolicy `json:"placements,omitempty"`
|
||||||
Schedules []HeroSchedule `json:"schedules"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
Schedules []HeroSchedule `json:"schedules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeroPlacementPolicy is one independently resolved spotlight. Keeping the placement as
|
||||||
|
// a map key means another section can be added without changing the stored document.
|
||||||
|
type HeroPlacementPolicy struct {
|
||||||
|
PinnedItemIDs []string `json:"pinnedItemIds"`
|
||||||
|
PrimeSubtitle string `json:"primeSubtitle"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HeroSchedule is resolved by the gateway for every home response. Times are UTC RFC3339;
|
// HeroSchedule is resolved by the gateway for every home response. Times are UTC RFC3339;
|
||||||
// weekdays use the local calendar day (Sunday=0) and an empty list means every day.
|
// weekdays use the local calendar day (Sunday=0) and an empty list means every day.
|
||||||
type HeroSchedule struct {
|
type HeroSchedule struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ItemID string `json:"itemId"`
|
ItemID string `json:"itemId"`
|
||||||
StartAt time.Time `json:"startAt"`
|
StartAt time.Time `json:"startAt"`
|
||||||
EndAt time.Time `json:"endAt"`
|
EndAt time.Time `json:"endAt"`
|
||||||
Weekdays []int `json:"weekdays,omitempty"`
|
Weekdays []int `json:"weekdays,omitempty"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
UserID string `json:"userId,omitempty"`
|
UserID string `json:"userId,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
Placements []string `json:"placements,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
HeroPlacementHome = "home"
|
||||||
|
HeroPlacementMovies = "movies"
|
||||||
|
HeroPlacementTVShows = "tv_shows"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidHeroPlacement(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case HeroPlacementHome, HeroPlacementMovies, HeroPlacementTVShows:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normaliseHeroPlacementPolicy(policy HeroPlacementPolicy) HeroPlacementPolicy {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
ids := make([]string, 0, min(len(policy.PinnedItemIDs), 4))
|
||||||
|
for _, id := range policy.PinnedItemIDs {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || seen[id] || len(ids) == 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
policy.PinnedItemIDs = ids
|
||||||
|
policy.PrimeSubtitle = strings.TrimSpace(policy.PrimeSubtitle)
|
||||||
|
if runes := []rune(policy.PrimeSubtitle); len(runes) > 160 {
|
||||||
|
policy.PrimeSubtitle = string(runes[:160])
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (policy HeroPolicy) Placement(name string) HeroPlacementPolicy {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if placement, ok := policy.Placements[name]; ok {
|
||||||
|
return placement
|
||||||
|
}
|
||||||
|
if name == HeroPlacementHome {
|
||||||
|
return HeroPlacementPolicy{PinnedItemIDs: policy.PinnedItemIDs, PrimeSubtitle: policy.PrimeSubtitle}
|
||||||
|
}
|
||||||
|
return HeroPlacementPolicy{PinnedItemIDs: []string{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
||||||
@@ -126,6 +180,27 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
|||||||
if len(runes) > 160 {
|
if len(runes) > 160 {
|
||||||
policy.PrimeSubtitle = string(runes[:160])
|
policy.PrimeSubtitle = string(runes[:160])
|
||||||
}
|
}
|
||||||
|
if policy.Placements == nil {
|
||||||
|
policy.Placements = map[string]HeroPlacementPolicy{}
|
||||||
|
}
|
||||||
|
if _, exists := policy.Placements[HeroPlacementHome]; !exists {
|
||||||
|
policy.Placements[HeroPlacementHome] = HeroPlacementPolicy{PinnedItemIDs: policy.PinnedItemIDs, PrimeSubtitle: policy.PrimeSubtitle}
|
||||||
|
}
|
||||||
|
cleanPlacements := make(map[string]HeroPlacementPolicy, len(policy.Placements))
|
||||||
|
for name, placement := range policy.Placements {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if ValidHeroPlacement(name) {
|
||||||
|
cleanPlacements[name] = normaliseHeroPlacementPolicy(placement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{HeroPlacementHome, HeroPlacementMovies, HeroPlacementTVShows} {
|
||||||
|
if _, exists := cleanPlacements[name]; !exists {
|
||||||
|
cleanPlacements[name] = HeroPlacementPolicy{PinnedItemIDs: []string{}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
policy.Placements = cleanPlacements
|
||||||
|
home := policy.Placement(HeroPlacementHome)
|
||||||
|
policy.PinnedItemIDs, policy.PrimeSubtitle = home.PinnedItemIDs, home.PrimeSubtitle
|
||||||
cleanSchedules := make([]HeroSchedule, 0, len(policy.Schedules))
|
cleanSchedules := make([]HeroSchedule, 0, len(policy.Schedules))
|
||||||
seenSchedules := map[string]bool{}
|
seenSchedules := map[string]bool{}
|
||||||
for _, schedule := range policy.Schedules {
|
for _, schedule := range policy.Schedules {
|
||||||
@@ -149,6 +224,19 @@ func normalizeHeroPolicy(policy HeroPolicy) HeroPolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
schedule.Weekdays = weekdays
|
schedule.Weekdays = weekdays
|
||||||
|
placements := make([]string, 0, len(schedule.Placements))
|
||||||
|
seenPlacements := map[string]bool{}
|
||||||
|
for _, placement := range schedule.Placements {
|
||||||
|
placement = strings.ToLower(strings.TrimSpace(placement))
|
||||||
|
if ValidHeroPlacement(placement) && !seenPlacements[placement] {
|
||||||
|
seenPlacements[placement] = true
|
||||||
|
placements = append(placements, placement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(placements) == 0 {
|
||||||
|
placements = []string{HeroPlacementHome}
|
||||||
|
}
|
||||||
|
schedule.Placements = placements
|
||||||
cleanSchedules = append(cleanSchedules, schedule)
|
cleanSchedules = append(cleanSchedules, schedule)
|
||||||
}
|
}
|
||||||
policy.Schedules = cleanSchedules
|
policy.Schedules = cleanSchedules
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) {
|
func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) {
|
||||||
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
|
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
|
||||||
@@ -50,6 +53,37 @@ func TestHeroPolicyReadsTheEarlierMovieOnlyShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHeroPolicyMigratesHomeAndKeepsPlacementsIndependent(t *testing.T) {
|
||||||
|
got := normalizeHeroPolicy(HeroPolicy{
|
||||||
|
PinnedItemIDs: []string{"home-1"},
|
||||||
|
Placements: map[string]HeroPlacementPolicy{
|
||||||
|
HeroPlacementMovies: {PinnedItemIDs: []string{"film-1"}},
|
||||||
|
HeroPlacementTVShows: {PinnedItemIDs: []string{"series-1"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if got.Placement(HeroPlacementHome).PinnedItemIDs[0] != "home-1" {
|
||||||
|
t.Fatalf("legacy Home policy was not migrated: %+v", got.Placements)
|
||||||
|
}
|
||||||
|
if got.Placement(HeroPlacementMovies).PinnedItemIDs[0] != "film-1" ||
|
||||||
|
got.Placement(HeroPlacementTVShows).PinnedItemIDs[0] != "series-1" {
|
||||||
|
t.Fatalf("placement policies crossed: %+v", got.Placements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroSchedulesDefaultToHomeAndNormalisePlacementNames(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
got := normalizeHeroPolicy(HeroPolicy{Schedules: []HeroSchedule{
|
||||||
|
{ID: "old", ItemID: "one", StartAt: now, EndAt: now.Add(time.Hour), Enabled: true},
|
||||||
|
{ID: "new", ItemID: "two", StartAt: now, EndAt: now.Add(time.Hour), Enabled: true, Placements: []string{" MOVIES ", "movies", "bad"}},
|
||||||
|
}})
|
||||||
|
if len(got.Schedules[0].Placements) != 1 || got.Schedules[0].Placements[0] != HeroPlacementHome {
|
||||||
|
t.Fatalf("old schedule placements = %v", got.Schedules[0].Placements)
|
||||||
|
}
|
||||||
|
if len(got.Schedules[1].Placements) != 1 || got.Schedules[1].Placements[0] != HeroPlacementMovies {
|
||||||
|
t.Fatalf("new schedule placements = %v", got.Schedules[1].Placements)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
|
func TestMDBListSettingsAreOptionalAndNormalizeSources(t *testing.T) {
|
||||||
defaults := DefaultMDBListSettings()
|
defaults := DefaultMDBListSettings()
|
||||||
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
|
if defaults.Enabled || defaults.APIKey != "" || len(defaults.Sources) == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user