Release Memby 0.2.64

This commit is contained in:
ponzischeme89
2026-08-14 13:32:14 +12:00
parent ad0c74f880
commit 05d95f51cd
33 changed files with 1142 additions and 112 deletions
+5
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
rel="icon" 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>
+2
View File
@@ -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. */}
+27
View File
@@ -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 {
+8
View File
@@ -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
View File
@@ -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 placements 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 Membys 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 Membys 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'}
+22
View File
@@ -0,0 +1,22 @@
import { api } from '../api/client';
import type { MediaReport } from '../api/types';
import { useAction, useQuery } from '../lib/hooks';
import { when } from '../lib/format';
import { Button, Card, Empty, Loading, PageHead, TableWrap, Tag } from '../components/ui';
const label = (report: MediaReport) => report.mediaType === 'episode'
? `${report.seriesTitle} S${String(report.seasonNumber).padStart(2, '0')}E${String(report.episodeNumber).padStart(2, '0')}`
: report.title;
export function MediaReportsPage() {
const query = useQuery<{ reports: MediaReport[] }>('/admin/api/media-reports', { pollMs: 15_000 });
const { busy, run } = useAction();
const setStatus = (report: MediaReport, status: string) => run(`${report.id}-${status}`, async () => {
await api.post(`/admin/api/media-reports/${report.id}/status`, { status });
await query.reload();
});
return <><PageHead title="Media reports" intro="Viewer-reported problems and the individual replacement searches they asked Memby to start." />
{query.loading ? <Loading rows={4} /> : <Card title="Open and recent reports" intro="A replacement always targets one film or one episode. Existing files stay in place while Radarr or Sonarr applies its normal import policy." icon="inbox">
{!query.data?.reports.length ? <Empty>No media problems have been reported.</Empty> : <TableWrap><table><thead><tr><th>Media</th><th>Report</th><th>Viewer</th><th>Replacement</th><th>Status</th><th>Reported</th><th>Actions</th></tr></thead><tbody>{query.data.reports.map((report) => <tr key={report.id}><td><b>{label(report)}</b><br /><span className="muted">{report.title}</span></td><td>{report.reason.replaceAll('_', ' ')}{report.comment ? <><br /><span className="muted">{report.comment}</span></> : null}</td><td>{report.reportedByUsername}<br /><span className="muted">{report.reportedByDevice || 'Unknown device'}</span></td><td><Tag tone={report.replacementRequested ? 'note' : undefined}>{report.replacementRequested ? report.replacementStatus || 'Requested' : 'Not requested'}</Tag></td><td><Tag tone={report.status === 'resolved' ? 'ok' : report.status === 'dismissed' ? undefined : 'warn'}>{report.status}</Tag></td><td className="nowrap muted">{when(report.createdAt)}</td><td><Button size="sm" variant="quiet" busy={busy === `${report.id}-acknowledged`} onClick={() => void setStatus(report, 'acknowledged')}>Acknowledge</Button>{' '}<Button size="sm" variant="quiet" busy={busy === `${report.id}-resolved`} onClick={() => void setStatus(report, 'resolved')}>Resolve</Button>{' '}<Button size="sm" variant="quiet" busy={busy === `${report.id}-dismissed`} onClick={() => void setStatus(report, 'dismissed')}>Dismiss</Button></td></tr>)}</tbody></table></TableWrap>}
</Card>}</>;
}
+1 -1
View File
@@ -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")
}
}
@@ -49,6 +49,10 @@ const (
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.
+2
View File
@@ -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))
+2
View File
@@ -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))
+18 -4
View File
@@ -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
+182
View File
@@ -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
}
+90 -17
View File
@@ -18,10 +18,17 @@ type heroAdminItem struct {
type heroAdminPolicy struct { type heroAdminPolicy struct {
PinnedItems []heroAdminItem `json:"pinnedItems"` PinnedItems []heroAdminItem `json:"pinnedItems"`
Items []heroAdminItem `json:"items"`
PrimeSubtitle string `json:"primeSubtitle"` PrimeSubtitle string `json:"primeSubtitle"`
Placements map[string]heroAdminPlacement `json:"placements"`
Schedules []store.HeroSchedule `json:"schedules"` 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) {
fact, ok := heroFactsOf(raw) fact, ok := heroFactsOf(raw)
if !ok || !fact.Playable || if !ok || !fact.Playable ||
@@ -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
} }
@@ -81,36 +107,83 @@ 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"`
Placements map[string]struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
PrimeSubtitle string `json:"primeSubtitle"`
} `json:"placements"`
Schedules []store.HeroSchedule `json:"schedules"` 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"`
PrimeSubtitle string `json:"primeSubtitle"`
}{
store.HeroPlacementHome: {PinnedItemIDs: request.PinnedItemIDs, PrimeSubtitle: request.PrimeSubtitle},
}
}
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 return
} }
items, err := s.store.LibraryItemsByID(r.Context(), ids) 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 return
} }
} }
policy := store.HeroPolicy{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle, Schedules: request.Schedules} }
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
}
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{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")
+12
View File
@@ -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 {
+168
View File
@@ -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)
}
+11
View File
@@ -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"`
} }
+12
View File
@@ -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
+84
View File
@@ -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
}
+39
View File
@@ -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);
+88
View File
@@ -88,10 +88,18 @@ 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"`
Placements map[string]HeroPlacementPolicy `json:"placements,omitempty"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
Schedules []HeroSchedule `json:"schedules"` 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 {
@@ -103,6 +111,52 @@ type HeroSchedule struct {
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
+35 -1
View File
@@ -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 {