This commit is contained in:
ponzischeme89
2026-08-28 23:00:02 +12:00
parent 3e89036f7b
commit d5632e844a
66 changed files with 2870 additions and 689 deletions
+12
View File
@@ -150,6 +150,18 @@ MEMBY_EMBY_HEALTH_INTERVAL=60s
# settings page overrides it without a redeployment. # settings page overrides it without a redeployment.
MEMBY_SLOW_REQUEST_THRESHOLD=500ms MEMBY_SLOW_REQUEST_THRESHOLD=500ms
# Which reverse proxies the gateway believes when working out where a request came from,
# for the admin console's Remote IP column and the login/trailer logs. X-Forwarded-For
# and X-Real-IP are only honoured when the immediate peer is one of these, so a direct
# client cannot spoof its address with a header. Blank trusts loopback and the private
# ranges (10/8, 172.16/12, 192.168/16, fc00::/7, link-local) — every proxy a home
# deployment puts in front of Memby. Set a comma-separated list of addresses or CIDR
# ranges to narrow or widen it (add your CDN's ranges here if TLS terminates off-site),
# or "none" to trust no proxy. The reverse proxy must be configured to send the headers
# — e.g. nginx: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; and
# proxy_set_header X-Real-IP $remote_addr;
MEMBY_TRUSTED_PROXIES=
# Optional Tracearr-powered For You signals. Create a read-only public API key in # Optional Tracearr-powered For You signals. Create a read-only public API key in
# Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers. # Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers.
MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/ MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/
+18 -3
View File
@@ -967,9 +967,24 @@ it hides itself on devices that actually support it.
**Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events **Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events
with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s, with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s,
on `ON_STOP`, and on dispose. Fire-and-forget by design — `reportRowEvents` swallows on `ON_STOP`, and on dispose. Aggregates are read at query time in `store.RowStats`; raw
failures, because telemetry must never surface on a TV. Aggregates are read at query time events are pruned after 90 days. Three things keep the focus/dwell figures honest:
in `store.RowStats`; raw events are pruned after 90 days.
- **The periodic flush *checkpoints* dwell, it does not end it** (`checkpointFocus`): a
viewer sitting on one row for two minutes must contribute the whole two minutes, not one
~20s chunk ending at the first flush and nothing after until they move to a different
row. `endFocus` (genuine departure — a selection, the rail, the hero, an overlay, leaving
home) is the only thing that clears the measurement, and the UI calls it explicitly
(`notifyFocusLeftRows`) so time spent above the shelves is never credited to the last row
focused.
- **A batch the upload cannot deliver is put back** (`restore`), so a transient network
failure costs a delay rather than the batch — still bounded by `maxBuffered`, still lost
to a crash. `reportRowEvents` hands the events back through `onUnsent` on failure or
before a session exists; it stays silent on screen and on the direct path.
- **The server drops free text in the controlled fields.** Row id, kind and item id are
vocabulary the television composes (`safeAnalyticsValue` in `toRowEvent`), so an event
carrying anything else was misassembled and a pathological value would distort the
aggregates — dropped whole, as the journey path does.
**Server logging** answers "who did what, from which television, on which build". Three **Server logging** answers "who did what, from which television, on which build". Three
pieces make that true and each is easy to undo: pieces make that true and each is easy to undo:
+9
View File
@@ -257,6 +257,8 @@ export interface RemoteSectionDefinition {
component: string; component: string;
maxItems?: number; maxItems?: number;
destination?: string; destination?: string;
/** Card shape override for a mediaRow: "poster", "thumb", or absent for automatic. */
layout?: string;
settings?: Record<string, unknown>; settings?: Record<string, unknown>;
} }
@@ -802,6 +804,10 @@ export interface GatewaySettings {
embyHealthSeconds: number; embyHealthSeconds: number;
slowRequestMillis: number; slowRequestMillis: number;
librarySyncMinutes: number; librarySyncMinutes: number;
homeTtlSeconds: number;
recommendTtlHours: number;
/** null means "whatever was deployed"; 0 is a legitimate override (midnight). */
forYouRebuildHour: number | null;
updatedAt?: string; updatedAt?: string;
updatedBy?: string; updatedBy?: string;
} }
@@ -818,6 +824,9 @@ export interface GatewaySettingValues {
embyHealthSeconds: number; embyHealthSeconds: number;
slowRequestMillis: number; slowRequestMillis: number;
librarySyncMinutes: number; librarySyncMinutes: number;
homeTtlSeconds: number;
recommendTtlHours: number;
forYouRebuildHour: number;
} }
export interface GatewaySettingsResponse { export interface GatewaySettingsResponse {
@@ -0,0 +1,385 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { GatewaySettings, GatewaySettingsResponse } from '../api/types';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Field, KeyValue, Loading, Note, Tag } from './ui';
/* The gateway's own server-level settings, rendered as a section rather than a page so it
* can sit on Client configuration beside the thin-client control plane and still be
* reachable on its own at /admin/settings.
*
* Everything here is an *override* of the value the container was started with: blank (or
* zero) means "whatever .env says", which is printed beside each field. The two alert
* windows, the health probe and the slow-request threshold can additionally be switched
* off, which is a third state distinct from "deployed" — the word off in the field. */
const OVERRIDE_OFF = -1;
function numberFieldValue(value: number): string {
if (value === 0) return '';
if (value < 0) return 'off';
return String(value);
}
function parseNumberField(raw: string, offAllowed: boolean): number {
const text = raw.trim().toLowerCase();
if (text === '') return 0;
if (offAllowed && (text === 'off' || text === 'none' || text === '0')) return OVERRIDE_OFF;
const parsed = Number.parseInt(text, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
/** The For You rebuild hour is a plain 023 with no "off": an empty field is "deployed"
* (null on the wire) and every other value is the hour it runs at. */
function parseHourField(raw: string): number | null {
const text = raw.trim();
if (text === '') return null;
const parsed = Number.parseInt(text, 10);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 23) return null;
return parsed;
}
function describe(value: number, unit: string): string {
if (value <= 0) return 'off';
return `${value} ${unit}${value === 1 ? '' : 's'}`;
}
function notificationDisplayLabel(value: string): string {
switch (value) {
case 'everywhere': return 'Everywhere';
case 'off': return 'Off';
default: return 'Home only';
}
}
interface Draft {
timezone: string;
logLevel: string;
sessionIdleDays: string;
sonarrAlertMinutes: string;
radarrAlertMinutes: string;
notificationDisplay: string;
embyHealthSeconds: string;
slowRequestMillis: string;
librarySyncMinutes: string;
homeTtlSeconds: string;
recommendTtlHours: string;
forYouRebuildHour: string;
}
function draftFrom(settings: GatewaySettings): Draft {
return {
timezone: settings.timezone ?? '',
logLevel: settings.logLevel ?? '',
sessionIdleDays: numberFieldValue(settings.sessionIdleDays),
sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes),
radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes),
notificationDisplay: settings.notificationDisplay || 'home_only',
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
slowRequestMillis: numberFieldValue(settings.slowRequestMillis),
librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes),
homeTtlSeconds: settings.homeTtlSeconds ? String(settings.homeTtlSeconds) : '',
recommendTtlHours: settings.recommendTtlHours ? String(settings.recommendTtlHours) : '',
forYouRebuildHour: settings.forYouRebuildHour == null ? '' : String(settings.forYouRebuildHour),
};
}
function bodyFrom(draft: Draft): GatewaySettings {
return {
timezone: draft.timezone.trim(),
logLevel: draft.logLevel.trim(),
sessionIdleDays: parseNumberField(draft.sessionIdleDays, false),
sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true),
radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true),
notificationDisplay: draft.notificationDisplay,
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true),
slowRequestMillis: parseNumberField(draft.slowRequestMillis, true),
librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true),
homeTtlSeconds: parseNumberField(draft.homeTtlSeconds, false),
recommendTtlHours: parseNumberField(draft.recommendTtlHours, false),
forYouRebuildHour: parseHourField(draft.forYouRebuildHour),
};
}
export function GatewaySettingsSection() {
const { data, error, loading, reload } = useQuery<GatewaySettingsResponse>('/admin/api/gateway-settings');
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<Draft | null>(null);
// A refresh must never take a half-typed field away: the draft is adopted once, and
// after that the operator owns it until they save or discard.
useEffect(() => {
if (!draft && data) setDraft(draftFrom(data.settings));
}, [data, draft]);
const set = <K extends keyof Draft>(key: K, value: string) =>
setDraft((current) => (current ? { ...current, [key]: value } : current));
const save = () =>
run('save', async () => {
if (!draft) return;
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', bodyFrom(draft)),
'Gateway settings saved.',
);
// Adopt what the server stored rather than what was typed — normalisation clamps and
// refuses. A failed save leaves the draft alone.
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const clearAll = () =>
run('clear', async () => {
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
timezone: '', logLevel: '', sessionIdleDays: 0,
sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0, slowRequestMillis: 0,
librarySyncMinutes: 0, notificationDisplay: 'home_only',
homeTtlSeconds: 0, recommendTtlHours: 0, forYouRebuildHour: null,
}),
'Every setting is back to its deployed or server default.',
);
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const deployed = data?.deployed;
const effective = data?.effective;
const levels = data?.logLevels ?? [];
const notificationDisplays = data?.notificationDisplays ?? ['everywhere', 'home_only', 'off'];
return (
<>
<Banner message={error} />
{loading || !draft || !deployed || !effective ? (
<Loading rows={2} />
) : (
<>
<Card
title="This gateway"
intro="The server process this console is served by, and what it currently believes."
icon="chip"
tone="info"
actions={<Tag tone="info">{data?.version ?? 'unknown'}</Tag>}
>
<KeyValue
rows={[
{ label: 'Server timezone', value: effective.timezone || 'not set' },
{ label: 'Log level', value: effective.logLevel },
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
{ label: 'Slow-request breakdown', value: describe(effective.slowRequestMillis, 'millisecond') },
{ label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') },
{ label: 'Home cache lifetime', value: describe(effective.homeTtlSeconds, 'second') },
{ label: 'Recommendation cache lifetime', value: describe(effective.recommendTtlHours, 'hour') },
{ label: 'For You rebuild hour', value: `${effective.forYouRebuildHour}:00` },
{ label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
{ label: 'Notification display', value: notificationDisplayLabel(effective.notificationDisplay) },
]}
/>
</Card>
<Card
title="Server settings"
intro="Environment-backed fields can be left empty to use the deployed value shown beneath them. Changes take effect immediately — nothing here needs a restart."
icon="sliders"
tone="note"
footer={
<>
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save settings
</Button>
<Button busy={busy === 'clear'} onClick={() => void clearAll()}>
Use defaults
</Button>
</>
}
>
<div className="fields">
<Field
label="Server timezone"
hint={`Deployed: ${deployed.timezone || 'not set'}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`}
>
<input
type="text"
value={draft.timezone}
placeholder={deployed.timezone}
onChange={(event) => set('timezone', event.target.value)}
/>
</Field>
<Field
label="Log level"
hint={`Deployed: ${deployed.logLevel}. Applies to the running process at once, so debug can be turned on to watch something happen.`}
>
<select value={draft.logLevel} onChange={(event) => set('logLevel', event.target.value)}>
<option value="">Deployed ({deployed.logLevel})</option>
{levels.map((level) => (
<option key={level} value={level}>{level}</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Sign a television out after (days)"
hint={`Deployed: ${deployed.sessionIdleDays} days. A session row holds a live Emby token, so this is how long a set nobody uses keeps working credentials.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sessionIdleDays}
placeholder={String(deployed.sessionIdleDays)}
onChange={(event) => set('sessionIdleDays', event.target.value)}
/>
</Field>
<Field
label="Slow-request breakdown above (ms)"
hint={`Deployed: ${deployed.slowRequestMillis || 'off'}. A request slower than this logs where its time went — Emby, Postgres, Redis, row assembly. Lower it while chasing one slow screen; type off to stop annotating altogether.`}
>
<input
type="text"
inputMode="numeric"
value={draft.slowRequestMillis}
placeholder={String(deployed.slowRequestMillis)}
onChange={(event) => set('slowRequestMillis', event.target.value)}
/>
</Field>
<Field
label="Emby health probe (seconds)"
hint={`Deployed: ${deployed.embyHealthSeconds || 'off'}. How often the gateway asks Emby whether it is answering. Type off to stop probing, which also removes the outage bar from every television.`}
>
<input
type="text"
inputMode="numeric"
value={draft.embyHealthSeconds}
placeholder={String(deployed.embyHealthSeconds)}
onChange={(event) => set('embyHealthSeconds', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Catalogue sweep (minutes)"
hint={`Deployed: ${deployed.librarySyncMinutes || 'off'}. How often the gateway asks Emby what has changed. With the Sonarr and Radarr webhooks wired up a new file is in the catalogue within a minute of landing, and this is only reconciliation for media they do not manage — 360 is a sensible choice then. Without them it is the only way anything is found.`}
>
<input
type="text"
inputMode="numeric"
value={draft.librarySyncMinutes}
placeholder={String(deployed.librarySyncMinutes)}
onChange={(event) => set('librarySyncMinutes', event.target.value)}
/>
</Field>
<Field
label="Home cache lifetime (seconds)"
hint={`Deployed: ${deployed.homeTtlSeconds}. How long a built launcher payload is served before it is rebuilt. Lower for fresher rows at the cost of more Emby traffic; it cannot be switched off.`}
>
<input
type="text"
inputMode="numeric"
value={draft.homeTtlSeconds}
placeholder={String(deployed.homeTtlSeconds)}
onChange={(event) => set('homeTtlSeconds', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Recommendation cache lifetime (hours)"
hint={`Deployed: ${deployed.recommendTtlHours}. How long a personalised recommendation pool stays warm. Long by design — the rotation within it is daily.`}
>
<input
type="text"
inputMode="numeric"
value={draft.recommendTtlHours}
placeholder={String(deployed.recommendTtlHours)}
onChange={(event) => set('recommendTtlHours', event.target.value)}
/>
</Field>
<Field
label="For You rebuild hour (023)"
hint={`Deployed: ${deployed.forYouRebuildHour}:00. The household-local hour the daily For You rebuild is due at. Leave empty for the deployed hour.`}
>
<input
type="text"
inputMode="numeric"
value={draft.forYouRebuildHour}
placeholder={String(deployed.forYouRebuildHour)}
onChange={(event) => set('forYouRebuildHour', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Notification display"
hint="Controls informational banners for every viewer and television. Everywhere also permits playback overlays; Home only keeps them off active movies, episodes and trailers; Off hides them throughout Memby."
>
<select
value={draft.notificationDisplay}
onChange={(event) => set('notificationDisplay', event.target.value)}
>
{notificationDisplays.map((value) => (
<option key={value} value={value}>{notificationDisplayLabel(value)}</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Episode alert window (minutes)"
hint={`Deployed: ${deployed.sonarrAlertMinutes || 'off'}. How long a "just aired" notice stays on offer to a set that was switched off at the time. Type off to stop announcing them.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sonarrAlertMinutes}
placeholder={String(deployed.sonarrAlertMinutes)}
onChange={(event) => set('sonarrAlertMinutes', event.target.value)}
/>
</Field>
<Field
label="Film alert window (minutes)"
hint={`Deployed: ${deployed.radarrAlertMinutes || 'off'}. The same, for a film Radarr has just imported.`}
>
<input
type="text"
inputMode="numeric"
value={draft.radarrAlertMinutes}
placeholder={String(deployed.radarrAlertMinutes)}
onChange={(event) => set('radarrAlertMinutes', event.target.value)}
/>
</Field>
</div>
<Note tone="note">
These settings live in the database and survive a restart. A deployment rewrites
<code>.env</code>, not this document, so environment-backed values can then disagree;
permanent environment changes belong in <code>.env.example</code> as well.
</Note>
{data?.settings.updatedBy ? (
<Note>
Last changed by {data.settings.updatedBy}
{data.settings.updatedAt ? ` on ${new Date(data.settings.updatedAt).toLocaleString('en-NZ')}` : ''}.
</Note>
) : null}
</Card>
</>
)}
</>
);
}
+2 -3
View File
@@ -232,9 +232,8 @@ export function Layout() {
<span className="account-avatar account-avatar-large" aria-hidden="true">{initial}</span> <span className="account-avatar account-avatar-large" aria-hidden="true">{initial}</span>
<span><small>Signed in as</small><b>{currentUser}</b></span> <span><small>Signed in as</small><b>{currentUser}</b></span>
</div> </div>
{/* The gateway's own settings live here rather than on the rail: the rail {/* Gateway settings now live on Client configuration in the rail; this is a
is the household — its users, its content, its televisions — and this shortcut to the standalone view of the same editor. */}
is the server process those pages are served by. */}
<NavLink to="/admin/settings" role="menuitem" onClick={() => setAccountOpen(false)}> <NavLink to="/admin/settings" role="menuitem" onClick={() => setAccountOpen(false)}>
<Icon name="sliders" /> <Icon name="sliders" />
Gateway settings Gateway settings
+31 -1
View File
@@ -53,6 +53,11 @@ function describeRow(row: RemoteSectionDefinition, rowTypes: RowTypeDefinition[]
return row.type ? `Custom · ${row.type}` : 'Custom'; return row.type ? `Custom · ${row.type}` : 'Custom';
} }
const LAYOUT_LABEL: Record<string, string> = {
poster: 'Poster cards',
thumb: 'Thumbnail cards',
};
export function RowsEditor({ export function RowsEditor({
page, page,
rows, rows,
@@ -142,7 +147,7 @@ export function RowsEditor({
isNew: true, isNew: true,
row: { row: {
id: '', type: availableTypes[0]?.type ?? 'custom', title: '', enabled: true, id: '', type: availableTypes[0]?.type ?? 'custom', title: '', enabled: true,
position: 0, dataSource: '', component: '', maxItems: 20, destination: '', position: 0, dataSource: '', component: '', maxItems: 20, destination: '', layout: '',
}, },
})} })}
> >
@@ -165,6 +170,7 @@ export function RowsEditor({
<div className="chips"> <div className="chips">
<Chip>{describeRow(row, rowTypes)}</Chip> <Chip>{describeRow(row, rowTypes)}</Chip>
{row.maxItems ? <Chip>{row.maxItems} items</Chip> : null} {row.maxItems ? <Chip>{row.maxItems} items</Chip> : null}
{row.layout && LAYOUT_LABEL[row.layout] ? <Chip tone="note">{LAYOUT_LABEL[row.layout]}</Chip> : null}
{row.destination ? <Chip tone="note">{row.destination}</Chip> : null} {row.destination ? <Chip tone="note">{row.destination}</Chip> : null}
</div> </div>
</div> </div>
@@ -290,6 +296,7 @@ function RowEditorDialog({
const [customDataSource, setCustomDataSource] = useState(row.dataSource); const [customDataSource, setCustomDataSource] = useState(row.dataSource);
const [customComponent, setCustomComponent] = useState(row.component || 'mediaRow'); const [customComponent, setCustomComponent] = useState(row.component || 'mediaRow');
const [customDestination, setCustomDestination] = useState(row.destination ?? ''); const [customDestination, setCustomDestination] = useState(row.destination ?? '');
const [layout, setLayout] = useState(row.layout ?? '');
// A row saved by an older console, or through the raw JSON view, can carry a type this // A row saved by an older console, or through the raw JSON view, can carry a type this
// page's catalogue does not offer (moved off this page, or never catalogued at all). It // page's catalogue does not offer (moved off this page, or never catalogued at all). It
@@ -306,6 +313,12 @@ function RowEditorDialog({
const valid = trimmedTitle.length > 0 && validMaxItems && const valid = trimmedTitle.length > 0 && validMaxItems &&
(!isCustom || (customDataSource.trim().length > 0 && customComponent.trim().length > 0)); (!isCustom || (customDataSource.trim().length > 0 && customComponent.trim().length > 0));
// Layout only means something for a horizontal card row. A genre browser or a paged
// library grid draws neither poster nor thumbnail cards, so the control is hidden and any
// stray value is dropped on save.
const resolvedComponent = isCustom ? customComponent.trim() : rowType?.component ?? '';
const layoutApplies = resolvedComponent === 'mediaRow';
const submit = () => { const submit = () => {
if (!valid) return; if (!valid) return;
const resolvedType = rowType?.type ?? 'custom'; const resolvedType = rowType?.type ?? 'custom';
@@ -324,6 +337,7 @@ function RowEditorDialog({
component: isCustom ? customComponent.trim() : rowType!.component, component: isCustom ? customComponent.trim() : rowType!.component,
maxItems, maxItems,
destination, destination,
...(layoutApplies && (layout === 'poster' || layout === 'thumb') ? { layout } : {}),
}); });
}; };
@@ -359,6 +373,22 @@ function RowEditorDialog({
</div> </div>
{rowType?.description ? <p className="hint">{rowType.description}</p> : null} {rowType?.description ? <p className="hint">{rowType.description}</p> : null}
{layoutApplies ? (
<div className="fields">
<Field
label="Card layout"
hint="Automatic shows episodes as wide thumbnails and everything else as upright posters. Override to force one shape — thumbnails are the same wide cards Continue Watching uses."
grow
>
<select value={layout} onChange={(event) => setLayout(event.target.value)}>
<option value="">Automatic</option>
<option value="poster">Poster cards</option>
<option value="thumb">Thumbnail cards</option>
</select>
</Field>
</div>
) : null}
{rowType?.requiresMatchingDestination ? ( {rowType?.requiresMatchingDestination ? (
<Note>Browses the {PAGE_LABEL[page]} catalogue this row can only ever point at the page it lives on.</Note> <Note>Browses the {PAGE_LABEL[page]} catalogue this row can only ever point at the page it lives on.</Note>
) : null} ) : null}
+32
View File
@@ -91,6 +91,38 @@ export function Grid({ cols, children }: { cols?: '2' | 'wide'; children: ReactN
); );
} }
/** Tabs splits one page into a handful of named views, anchored on a hairline under the
* page head. It is for a page that is genuinely several things about one subject — the
* client control plane, its rows, its server settings — where a single scroll buries the
* section somebody came for. Segments is the control for an exclusive *choice*; this is
* navigation within a page. The active tab rides `?tab=` so a view is linkable. */
export function Tabs<T extends string>({
tabs,
active,
onChange,
}: {
tabs: readonly { id: T; label: string; icon?: IconName }[];
active: T;
onChange: (id: T) => void;
}) {
return (
<nav className="tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === active}
onClick={() => onChange(tab.id)}
>
{tab.icon ? <Icon name={tab.icon} /> : null}
{tab.label}
</button>
))}
</nav>
);
}
/* ---------- tiles ---------- */ /* ---------- tiles ---------- */
export interface TileSpec { export interface TileSpec {
+8 -2
View File
@@ -44,6 +44,13 @@ export const nav: NavGroup[] = [
item('journey-viewer', '/admin/journeys/:userId', 'Journey', 'User journey', 'One viewers journey through Memby.', 'journey', { hidden: true }), item('journey-viewer', '/admin/journeys/:userId', 'Journey', 'User journey', 'One viewers journey through Memby.', 'journey', { hidden: true }),
], ],
}, },
{
id: 'config', label: 'Configuration', defaultCollapsed: false, collapsible: false,
items: [
item('features', '/admin/features', 'Client configuration', 'Client configuration',
'Everything the thin TV client renders, and the gateways own server-level settings.', 'sliders'),
],
},
{ {
id: 'history', label: 'History', defaultCollapsed: false, id: 'history', label: 'History', defaultCollapsed: false,
items: [ items: [
@@ -98,7 +105,6 @@ export const nav: NavGroup[] = [
{ {
id: 'rules', label: 'Rules', defaultCollapsed: false, id: 'rules', label: 'Rules', defaultCollapsed: false,
items: [ items: [
item('features', '/admin/features', 'Client configuration', 'Client configuration', 'Control everything the thin TV client renders.', 'sliders'),
item('notification-settings', '/admin/notification-settings', 'Notification rules', 'TV notifications', 'Choose where notifications appear and who receives them.', 'bell'), item('notification-settings', '/admin/notification-settings', 'Notification rules', 'TV notifications', 'Choose where notifications appear and who receives them.', 'bell'),
item('webhooks', '/admin/integrations/webhooks', 'Event webhooks', 'Event webhooks', 'Send administrative events to external services.', 'send'), item('webhooks', '/admin/integrations/webhooks', 'Event webhooks', 'Event webhooks', 'Send administrative events to external services.', 'send'),
], ],
@@ -113,7 +119,7 @@ export const nav: NavGroup[] = [
item('integrations', '/admin/integrations', 'Integrations', 'Integrations', 'External services Memby depends on.', 'plug'), item('integrations', '/admin/integrations', 'Integrations', 'Integrations', 'External services Memby depends on.', 'plug'),
item('integration', '/admin/integrations/:integrationId', 'Integration', 'Integration', 'One external service, its settings and run history.', 'plug', { hidden: true }), item('integration', '/admin/integrations/:integrationId', 'Integration', 'Integration', 'One external service, its settings and run history.', 'plug', { hidden: true }),
item('maintenance', '/admin/maintenance', 'Maintenance', 'Maintenance', 'Take Memby offline or schedule quiet time.', 'wrench'), item('maintenance', '/admin/maintenance', 'Maintenance', 'Maintenance', 'Take Memby offline or schedule quiet time.', 'wrench'),
item('gateway-settings', '/admin/settings', 'Gateway settings', 'Gateway settings', 'Timezone, logging and other server-level settings.', 'sliders', { hidden: true }), item('gateway-settings', '/admin/settings', 'Gateway settings', 'Gateway settings', 'Timezone, logging and other server-level settings. Also editable on Client configuration.', 'sliders', { hidden: true }),
], ],
}, },
]; ];
+15 -1
View File
@@ -103,6 +103,9 @@ interface WatchTime {
matched: boolean; matched: boolean;
tracearrUsername?: string; tracearrUsername?: string;
weekMs: number; weekMs: number;
/* This week against the same point last week, so the card can say whether viewing is
up or down without the operator holding last week's figure in their head. */
weekTrend?: { direction: 'up' | 'down' | 'steady' | 'none'; priorMs: number; deltaMs: number };
monthMs: number; monthMs: number;
totalMs: number; totalMs: number;
weekSessions: number; weekSessions: number;
@@ -145,6 +148,17 @@ function cssColour(value: string | undefined): string {
return `#${hex.slice(2)}${hex.slice(0, 2)}`; return `#${hex.slice(2)}${hex.slice(0, 2)}`;
} }
/* weekTrendClause is the " · ▲ 1h more than last week" tail on the this-week tile. Neutral
wording — more television is a fact, not a verdict — and empty when there is nothing to
compare against. */
function weekTrendClause(trend: WatchTime['weekTrend']): string {
if (!trend || trend.direction === 'none') return '';
if (trend.direction === 'steady') return ' · level with last week';
const glyph = trend.direction === 'up' ? '▲' : '▼';
const word = trend.direction === 'up' ? 'more' : 'less';
return ` · ${glyph} ${watchTime(Math.abs(trend.deltaMs))} ${word} than last week`;
}
type Pending = type Pending =
| { kind: 'remove-device'; deviceId: string; name: string } | { kind: 'remove-device'; deviceId: string; name: string }
| { kind: 'remove-account' } | { kind: 'remove-account' }
@@ -494,7 +508,7 @@ export function AccountPage() {
<Tiles <Tiles
tiles={[ tiles={[
{ {
label: `this week · ${num(account.watchTime.weekSessions)} session${account.watchTime.weekSessions === 1 ? '' : 's'}`, label: `this week · ${num(account.watchTime.weekSessions)} session${account.watchTime.weekSessions === 1 ? '' : 's'}${weekTrendClause(account.watchTime.weekTrend)}`,
value: watchTime(account.watchTime.weekMs), value: watchTime(account.watchTime.weekMs),
icon: 'pulse', icon: 'pulse',
tone: 'data', tone: 'data',
+15 -2
View File
@@ -7,7 +7,8 @@ import { ago, initials, num, presence, recent, watchTime, when } from '../lib/fo
import { Banner, Button, Card, Confirm, EmptyRow, Loading, PageHead, TableWrap, Tag, Tiles, Toggle } from '../components/ui'; import { Banner, Button, Card, Confirm, EmptyRow, Loading, PageHead, TableWrap, Tag, Tiles, Toggle } from '../components/ui';
interface Device { id: string; name: string; version: string; lastSeen: string; lastIp?: string; } interface Device { id: string; name: string; version: string; lastSeen: string; lastIp?: string; }
interface WatchTime { matched: boolean; weekMs: number; monthMs: number; } interface WeekTrend { direction: 'up' | 'down' | 'steady' | 'none'; priorMs: number; deltaMs: number; }
interface WatchTime { matched: boolean; weekMs: number; monthMs: number; weekTrend?: WeekTrend; }
interface Account { interface Account {
id: string; username: string; initials: string; shortName: string; lastSeen: string; devices: Device[] | null; id: string; username: string; initials: string; shortName: string; lastSeen: string; devices: Device[] | null;
enabled: boolean; lastIp?: string; notifications: { enabled: boolean; [key: string]: unknown }; watchTime?: WatchTime; enabled: boolean; lastIp?: string; notifications: { enabled: boolean; [key: string]: unknown }; watchTime?: WatchTime;
@@ -18,6 +19,18 @@ interface StatusResponse { updatePolicy?: { latestVersion?: string }; }
function seenAt(value: string | undefined): number { const at = value ? new Date(value).getTime() : 0; return Number.isFinite(at) ? at : 0; } function seenAt(value: string | undefined): number { const at = value ? new Date(value).getTime() : 0; return Number.isFinite(at) ? at : 0; }
function NotMeasured() { return <span className="muted" title="No Tracearr sessions matched to this person"></span>; } function NotMeasured() { return <span className="muted" title="No Tracearr sessions matched to this person"></span>; }
/* Whether this week's viewing is up or down on the same point last week. Neutral by
design — more television is not a verdict — so it is the quiet caption the version
column already uses, not a coloured tag. Absent when there is nothing to compare. */
function WeekTrend({ trend }: { trend?: WeekTrend }) {
if (!trend || trend.direction === 'none') return null;
if (trend.direction === 'steady') return <span className="table-sub" title="About the same as this point last week"> level with last week</span>;
const glyph = trend.direction === 'up' ? '▲' : '▼';
const word = trend.direction === 'up' ? 'more' : 'less';
const size = watchTime(Math.abs(trend.deltaMs));
return <span className="table-sub" title={`${size} ${word} than this point last week (was ${watchTime(trend.priorMs)})`}>{glyph} {size} vs last week</span>;
}
export function AccountsPage() { export function AccountsPage() {
const { data, error, loading, reload } = useQuery<AccountsResponse>('/admin/api/accounts', { pollMs: 60_000 }); const { data, error, loading, reload } = useQuery<AccountsResponse>('/admin/api/accounts', { pollMs: 60_000 });
const { data: status } = useQuery<StatusResponse>('/admin/api/status', { pollMs: 60_000 }); const { data: status } = useQuery<StatusResponse>('/admin/api/status', { pollMs: 60_000 });
@@ -62,7 +75,7 @@ export function AccountsPage() {
<td className="num"><button type="button" className="link-button" onClick={() => setExpanded(open ? null : account.id)}>{num(list.length)}</button></td> <td className="num"><button type="button" className="link-button" onClick={() => setExpanded(open ? null : account.id)}>{num(list.length)}</button></td>
<td><span title={first?.version ? `Latest recorded version on ${first.name}` : 'Version not reported'}>{first?.version || <span className="muted">Unknown</span>}</span>{first?.version && current(first.version) ? <Tag tone="ok">current</Tag> : first?.version && latest ? <Tag tone="warn">update</Tag> : null}{list.length > 1 ? <span className="table-sub">{list.length - 1} more device{list.length === 2 ? '' : 's'}</span> : null}</td> <td><span title={first?.version ? `Latest recorded version on ${first.name}` : 'Version not reported'}>{first?.version || <span className="muted">Unknown</span>}</span>{first?.version && current(first.version) ? <Tag tone="ok">current</Tag> : first?.version && latest ? <Tag tone="warn">update</Tag> : null}{list.length > 1 ? <span className="table-sub">{list.length - 1} more device{list.length === 2 ? '' : 's'}</span> : null}</td>
<td className="mono" title="Last recorded IP address">{first?.lastIp || account.lastIp || <span className="muted"></span>}</td> <td className="mono" title="Last recorded IP address">{first?.lastIp || account.lastIp || <span className="muted"></span>}</td>
<td className="num">{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}</td><td className="num muted">{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}</td> <td className="num">{watched?.matched ? <>{watchTime(watched.weekMs)}<WeekTrend trend={watched.weekTrend} /></> : <NotMeasured />}</td><td className="num muted">{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}</td>
<td className="nowrap muted" title={when(account.lastSeen)}>{ago(account.lastSeen)}</td> <td className="nowrap muted" title={when(account.lastSeen)}>{ago(account.lastSeen)}</td>
<td><Toggle label={account.notifications?.enabled ? 'ON' : 'OFF'} checked={Boolean(account.notifications?.enabled)} disabled={busy === `notifications-${account.id}`} onChange={(next) => void saveNotifications(account, next)} /></td> <td><Toggle label={account.notifications?.enabled ? 'ON' : 'OFF'} checked={Boolean(account.notifications?.enabled)} disabled={busy === `notifications-${account.id}`} onChange={(next) => void saveNotifications(account, next)} /></td>
<td><Toggle label={account.enabled ? 'Enabled' : 'Disabled'} checked={account.enabled} disabled={busy === `enabled-${account.id}`} onChange={() => setConfirm(account)} /></td> <td><Toggle label={account.enabled ? 'Enabled' : 'Disabled'} checked={account.enabled} disabled={busy === `enabled-${account.id}`} onChange={() => setConfirm(account)} /></td>
+269 -219
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../api/client'; import { api } from '../api/client';
import { useAction } from '../lib/hooks'; import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway'; import { useGateway } from '../lib/gateway';
@@ -15,22 +16,39 @@ import {
Loading, Loading,
PageHead, PageHead,
PlainTiles, PlainTiles,
Tabs,
Tag, Tag,
Toggle, Toggle,
} from '../components/ui'; } from '../components/ui';
import { RowsEditor } from '../components/RowsEditor'; import { RowsEditor } from '../components/RowsEditor';
import { GatewaySettingsSection } from '../components/GatewaySettingsSection';
import type { RemoteSectionDefinition } from '../api/types'; import type { RemoteSectionDefinition } from '../api/types';
/* The three configuration keys that hold a page's row composition. They are edited /* One page for everything the household is served, split into four views so the section an
* visually through RowsEditor rather than through the generic configuration list below, * operator came for is not at the bottom of a single scroll:
* which is why they are carved out of `configuration` before that list renders — a *
* technical JSON textarea for these three is exactly what the visual editor replaces. */ * Overview — is anything wrong, and the whole-plane actions (safe mode, rollback).
* Page rows — what Home, Movies and TV show and in what order.
* Features — the optional feature flags and the typed configuration values.
* Server — the gateway process's own settings (timezone, logging, cache lifetimes).
*
* The active view rides `?tab=` so a link goes straight to it. */
const ROW_CONFIGURATION_KEYS: Record<'home' | 'movies' | 'tv', string> = { const ROW_CONFIGURATION_KEYS: Record<'home' | 'movies' | 'tv', string> = {
home: 'home.sectionDefinitions', home: 'home.sectionDefinitions',
movies: 'movies.sectionDefinitions', movies: 'movies.sectionDefinitions',
tv: 'tv.sectionDefinitions', tv: 'tv.sectionDefinitions',
}; };
const TABS = [
{ id: 'overview', label: 'Overview', icon: 'overview' },
{ id: 'rows', label: 'Page rows', icon: 'list' },
{ id: 'features', label: 'Features', icon: 'sliders' },
{ id: 'server', label: 'Server settings', icon: 'chip' },
] as const;
type TabId = (typeof TABS)[number]['id'];
interface Pending { interface Pending {
action: 'safe-mode' | 'rollback' | 'reset' | 'feature'; action: 'safe-mode' | 'rollback' | 'reset' | 'feature';
title: string; title: string;
@@ -45,13 +63,21 @@ export function FeaturesPage() {
const { wrap } = useToast(); const { wrap } = useToast();
const { busy, run } = useAction(); const { busy, run } = useAction();
const [pending, setPending] = useState<Pending | null>(null); const [pending, setPending] = useState<Pending | null>(null);
const [params, setParams] = useSearchParams();
const requested = params.get('tab');
const tab: TabId = TABS.some((entry) => entry.id === requested) ? (requested as TabId) : 'overview';
const setTab = (next: TabId) => {
const copy = new URLSearchParams(params);
if (next === 'overview') copy.delete('tab');
else copy.set('tab', next);
setParams(copy, { replace: true });
};
const policy = status?.features; const policy = status?.features;
const features = policy?.features ?? []; const features = policy?.features ?? [];
const rowTypes = policy?.rowTypes ?? []; const rowTypes = policy?.rowTypes ?? [];
const rowConfiguration = policy?.configuration ?? []; const rowConfiguration = policy?.configuration ?? [];
// Everything but the three row-composition values, which the visual editors below
// render instead of the generic list.
const rowKeys = new Set(Object.values(ROW_CONFIGURATION_KEYS)); const rowKeys = new Set(Object.values(ROW_CONFIGURATION_KEYS));
const configuration = rowConfiguration.filter((item) => !rowKeys.has(item.key)); const configuration = rowConfiguration.filter((item) => !rowKeys.has(item.key));
const rowsFor = (key: string) => (rowConfiguration.find((item) => item.key === key)?.value as RemoteSectionDefinition[] | undefined) ?? []; const rowsFor = (key: string) => (rowConfiguration.find((item) => item.key === key)?.value as RemoteSectionDefinition[] | undefined) ?? [];
@@ -104,238 +130,262 @@ export function FeaturesPage() {
}); });
}; };
const saveValue = (key: string, value: unknown) =>
void run(key, async () => {
await wrap(
() =>
api.post('/admin/api/features', {
action: 'save',
expectedRevision: revision,
values: valuesFor(key, value),
overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])),
}),
'Configuration saved.',
);
await reload();
});
return ( return (
<> <>
<PageHead <PageHead
title="Client configuration" title="Client configuration"
intro="One server-owned control plane for what Memby renders: flags, values, page composition and contextual discovery." intro="Everything the household is served: the thin-client control plane, its page rows, and the gateway's own server-level settings."
/> />
<Banner message={error} /> <Banner message={error} />
<Tabs tabs={TABS} active={tab} onChange={setTab} />
{loading || !policy ? ( {tab === 'overview' ? (
<Loading /> loading || !policy ? (
) : ( <Loading />
<> ) : (
<Card <>
title="Thin client control plane" <Card
intro="The gateway decides which sections, heroes and discovery rows are delivered. The TV remains a fast renderer of known components and safely ignores anything newer." title="Thin client control plane"
icon="tv" intro="The gateway decides which sections, heroes and discovery rows are delivered. The TV remains a fast renderer of known components and safely ignores anything newer."
> icon="tv"
<div className="chips"> >
<a className="chip" href="/admin/hero">Hero sources and pinned overrides</a> <div className="chips">
<a className="chip" href="/admin/recommendations">For You and recommendation pools</a> <a className="chip" href="/admin/hero">Hero sources and pinned overrides</a>
<a className="chip" href="/admin/engagement">Contextual row engagement signals</a> <a className="chip" href="/admin/recommendations">For You and recommendation pools</a>
<a className="chip" href="/admin/clients">Client versions and capabilities</a> <a className="chip" href="/admin/engagement">Contextual row engagement signals</a>
</div> <a className="chip" href="/admin/clients">Client versions and capabilities</a>
</Card> </div>
</Card>
<RowsEditor <Card
page="home" title="Control plane"
rows={rowsFor(ROW_CONFIGURATION_KEYS.home)} intro="Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional."
rowTypes={rowTypes} icon="sliders"
busy={busy === ROW_CONFIGURATION_KEYS.home} tone="ok"
onSave={saveRows(ROW_CONFIGURATION_KEYS.home)} actions={
/> <Button
<RowsEditor variant={safeMode ? undefined : 'danger'}
page="movies" busy={busy === 'safe'}
rows={rowsFor(ROW_CONFIGURATION_KEYS.movies)} onClick={() =>
rowTypes={rowTypes} safeMode
busy={busy === ROW_CONFIGURATION_KEYS.movies} ? void act('leave-safe-mode', 'safe', 'Safe mode ended.')
onSave={saveRows(ROW_CONFIGURATION_KEYS.movies)} : setPending({
/> action: 'safe-mode',
<RowsEditor title: 'Enable safe mode?',
page="tv" body:
rows={rowsFor(ROW_CONFIGURATION_KEYS.tv)} 'Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.',
rowTypes={rowTypes} label: 'Enable safe mode',
busy={busy === ROW_CONFIGURATION_KEYS.tv} })
onSave={saveRows(ROW_CONFIGURATION_KEYS.tv)} }
/> >
{safeMode ? 'Leave safe mode' : 'Enable safe mode'}
</Button>
}
>
<PlainTiles
tiles={[
{
label: 'features active',
value: `${features.filter((feature) => feature.enabled).length} / ${features.length}`,
},
{
label: 'explicit overrides',
value: num(features.filter((feature) => feature.source === 'override').length),
},
{
label: 'televisions reporting the control plane',
value: `${capable} / ${clients.length}`,
},
{ label: 'published revision', value: `r${num(revision)}` },
]}
/>
</Card>
<Card <Card>
title="Control plane" <div className="row">
intro="Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional." <Button
icon="sliders" disabled={!policy.canRollback}
tone="ok" onClick={() =>
actions={ setPending({
<Button action: 'rollback',
variant={safeMode ? undefined : 'danger'} title: 'Roll back one revision?',
busy={busy === 'safe'} body: 'The previous published feature revision is restored on every television.',
onClick={() => label: 'Roll back',
safeMode })
? void act('leave-safe-mode', 'safe', 'Safe mode ended.') }
: setPending({ >
action: 'safe-mode', Roll back one revision
title: 'Enable safe mode?', </Button>
body: <Button
'Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.', onClick={() =>
label: 'Enable safe mode', setPending({
}) action: 'reset',
} title: 'Clear every override?',
> body: 'All features return to their safe software defaults.',
{safeMode ? 'Leave safe mode' : 'Enable safe mode'} label: 'Clear overrides',
</Button> })
} }
> >
<PlainTiles Clear all overrides
tiles={[ </Button>
{ <span className="spacer" />
label: 'features active', {safeMode ? (
value: `${features.filter((feature) => feature.enabled).length} / ${features.length}`, <Tag tone="warn">safe mode · optional features off</Tag>
}, ) : (
{ <Tag tone="ok">live · revision r{num(revision)}</Tag>
label: 'explicit overrides', )}
value: num(features.filter((feature) => feature.source === 'override').length), </div>
}, </Card>
{ </>
label: 'televisions reporting the control plane', )
value: `${capable} / ${clients.length}`, ) : null}
},
{ label: 'published revision', value: `r${num(revision)}` }, {tab === 'rows' ? (
]} loading || !policy ? (
<Loading />
) : (
<>
<RowsEditor
page="home"
rows={rowsFor(ROW_CONFIGURATION_KEYS.home)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.home}
onSave={saveRows(ROW_CONFIGURATION_KEYS.home)}
/> />
</Card> <RowsEditor
page="movies"
rows={rowsFor(ROW_CONFIGURATION_KEYS.movies)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.movies}
onSave={saveRows(ROW_CONFIGURATION_KEYS.movies)}
/>
<RowsEditor
page="tv"
rows={rowsFor(ROW_CONFIGURATION_KEYS.tv)}
rowTypes={rowTypes}
busy={busy === ROW_CONFIGURATION_KEYS.tv}
onSave={saveRows(ROW_CONFIGURATION_KEYS.tv)}
/>
</>
)
) : null}
<Card {tab === 'features' ? (
title="Central configuration" loading || !policy ? (
intro="Typed values use the same revisioned control plane as feature flags. Global values are safe defaults; user, device and experimental scopes are available to the client contract as features grow." <Loading />
icon="sliders" ) : (
> <>
<div className="stack">
{configuration.map((item) => {
const value = item.value;
return (
<div className="row" key={item.key}>
<div className="grow">
<strong>{item.name}</strong>
<div className="hint">{item.description} · {item.scopes.join(', ')}</div>
<Chip>{item.key}</Chip>
</div>
{item.type === 'boolean' ? (
<Toggle
label={value ? 'On' : 'Off'}
checked={Boolean(value)}
disabled={busy === item.key}
onChange={(next) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, next), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})}
/>
) : item.type === 'enum' ? (
<select value={String(value)} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, event.target.value), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})}>
{(item.options ?? []).map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : item.type === 'json' ? (
<textarea
rows={4}
value={JSON.stringify(value, null, 2)}
disabled={busy === item.key}
aria-label={item.name}
onChange={(event) => {
try {
const next = JSON.parse(event.target.value);
void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, next), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
});
} catch { /* wait for valid JSON before publishing */ }
}}
/>
) : item.type === 'string' ? (
<input type="text" value={String(value ?? '')} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, event.target.value), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})} />
) : (
<input type="number" value={Number(value)} min={item.min} max={item.max} disabled={busy === item.key} onChange={(event) => void run(item.key, async () => {
await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, Number(event.target.value)), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.');
await reload();
})} />
)}
</div>
);
})}
</div>
</Card>
<Grid cols="2">
{features.length === 0 ? ( {features.length === 0 ? (
<Card title="Nothing registered" icon="sliders"> <Card title="Feature flags" icon="sliders">
<Empty>No server features are registered.</Empty> <Empty>No server features are registered.</Empty>
</Card> </Card>
) : ( ) : (
features.map((feature) => ( <Grid cols="2">
<Card {features.map((feature) => (
key={feature.key} <Card
title={feature.name} key={feature.key}
intro={feature.description} title={feature.name}
actions={<Tag tone={feature.enabled ? 'ok' : undefined}>{feature.enabled ? 'active' : 'off'}</Tag>} intro={feature.description}
footer={<span className="hint"> {feature.recovery}</span>} actions={<Tag tone={feature.enabled ? 'ok' : undefined}>{feature.enabled ? 'active' : 'off'}</Tag>}
> footer={<span className="hint"> {feature.recovery}</span>}
<Toggle >
label={feature.enabled ? 'On' : 'Off'} <Toggle
hint="Changing this applies the feature policy to every compatible television." label={feature.enabled ? 'On' : 'Off'}
checked={feature.enabled} hint="Changing this applies the feature policy to every compatible television."
disabled={busy === feature.key} checked={feature.enabled}
onChange={(enabled) => setPending({ disabled={busy === feature.key}
action: 'feature', key: feature.key, enabled, onChange={(enabled) => setPending({
title: `${enabled ? 'Turn on' : 'Turn off'} ${feature.name}?`, action: 'feature', key: feature.key, enabled,
body: `${enabled ? 'Enable' : 'Disable'} this feature for every compatible television. ${feature.recovery}`, title: `${enabled ? 'Turn on' : 'Turn off'} ${feature.name}?`,
label: enabled ? 'Turn on' : 'Turn off', body: `${enabled ? 'Enable' : 'Disable'} this feature for every compatible television. ${feature.recovery}`,
})} label: enabled ? 'Turn on' : 'Turn off',
/> })}
<div className="chips"> />
<Chip>{feature.key}</Chip> <div className="chips">
<Chip>protocol {num(feature.minimumProtocol)}+</Chip> <Chip>{feature.key}</Chip>
<Chip tone={feature.compatible ? 'ok' : 'warn'}> <Chip>protocol {num(feature.minimumProtocol)}+</Chip>
{feature.compatible ? 'server compatible' : 'compatibility blocked'} <Chip tone={feature.compatible ? 'ok' : 'warn'}>
</Chip> {feature.compatible ? 'server compatible' : 'compatibility blocked'}
<Chip tone="note">{feature.area}</Chip> </Chip>
</div> <Chip tone="note">{feature.area}</Chip>
</Card> </div>
)) </Card>
))}
</Grid>
)} )}
</Grid>
<Card> <Card
<div className="row"> title="Configuration values"
<Button intro="Typed values on the same revisioned control plane as the flags. Global values are safe defaults; user, device and experimental scopes are available to the client contract as features grow."
disabled={!policy.canRollback} icon="sliders"
onClick={() => >
setPending({ {configuration.length === 0 ? (
action: 'rollback', <Empty>No configuration values are registered.</Empty>
title: 'Roll back one revision?',
body: 'The previous published feature revision is restored on every television.',
label: 'Roll back',
})
}
>
Roll back one revision
</Button>
<Button
onClick={() =>
setPending({
action: 'reset',
title: 'Clear every override?',
body: 'All features return to their safe software defaults.',
label: 'Clear overrides',
})
}
>
Clear all overrides
</Button>
<span className="spacer" />
{safeMode ? (
<Tag tone="warn">safe mode · optional features off</Tag>
) : ( ) : (
<Tag tone="ok">live · revision r{num(revision)}</Tag> <div className="stack">
{configuration.map((item) => {
const value = item.value;
return (
<div className="row" key={item.key}>
<div className="grow">
<strong>{item.name}</strong>
<div className="hint">{item.description} · {item.scopes.join(', ')}</div>
<Chip>{item.key}</Chip>
</div>
{item.type === 'boolean' ? (
<Toggle
label={value ? 'On' : 'Off'}
checked={Boolean(value)}
disabled={busy === item.key}
onChange={(next) => saveValue(item.key, next)}
/>
) : item.type === 'enum' ? (
<select value={String(value)} disabled={busy === item.key} onChange={(event) => saveValue(item.key, event.target.value)}>
{(item.options ?? []).map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : item.type === 'json' ? (
<textarea
rows={4}
value={JSON.stringify(value, null, 2)}
disabled={busy === item.key}
aria-label={item.name}
onChange={(event) => {
try {
saveValue(item.key, JSON.parse(event.target.value));
} catch { /* wait for valid JSON before publishing */ }
}}
/>
) : item.type === 'string' ? (
<input type="text" value={String(value ?? '')} disabled={busy === item.key} onChange={(event) => saveValue(item.key, event.target.value)} />
) : (
<input type="number" value={Number(value)} min={item.min} max={item.max} disabled={busy === item.key} onChange={(event) => saveValue(item.key, Number(event.target.value))} />
)}
</div>
);
})}
</div>
)} )}
</div> </Card>
</Card> </>
</> )
)} ) : null}
{tab === 'server' ? <GatewaySettingsSection /> : null}
{pending ? ( {pending ? (
<Confirm <Confirm
+193 -9
View File
@@ -1,10 +1,67 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { api } from '../api/client'; import { api } from '../api/client';
import { useAction } from '../lib/hooks'; import { useAction, useQuery } from '../lib/hooks';
import { useGateway } from '../lib/gateway'; import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast'; import { useToast } from '../lib/toast';
import { when } from '../lib/format'; import { when } from '../lib/format';
import { Banner, Button, Card, Empty, Loading, PageHead, TableWrap, Tag } from '../components/ui'; import type { Tone } from '../lib/format';
import {
Banner,
Button,
Card,
Empty,
EmptyRow,
Field,
Loading,
Meter,
PageHead,
TableWrap,
Tag,
} from '../components/ui';
/* One row of media_requests, with the person attached and the same derived status the
viewer's own page shows — the console and the television read it from the same
decorateRequests pass, so they can never disagree about a title's state. */
interface AdminMediaRequest {
userId: string;
username?: string;
mediaType: string;
foreignId: number;
title: string;
year?: number;
requestedAt: string;
status: string;
statusLabel: string;
statusDetail?: string;
progress?: number;
embyItemId?: string;
}
interface AdminRequestsResponse {
requests: AdminMediaRequest[] | null;
}
/* The status slugs come from the gateway; an unknown one falls through to the quiet
treatment rather than picking a colour that claims something. */
function statusTone(status: string): Tone {
switch (status) {
case 'available':
return 'ok';
case 'downloading':
case 'found':
case 'searching':
return 'info';
case 'processing':
case 'pending':
return 'note';
case 'failed':
return 'bad';
case 'unavailable':
return 'warn';
default:
return 'idle';
}
}
export function RequestsPage() { export function RequestsPage() {
const { status, error, loading, reload } = useGateway(); const { status, error, loading, reload } = useGateway();
@@ -12,8 +69,21 @@ export function RequestsPage() {
const { busy, run } = useAction(); const { busy, run } = useAction();
const users = status?.requestUsers ?? []; const users = status?.requestUsers ?? [];
const allowed = status?.requestPolicy?.allowedUserIds ?? []; const allowed = status?.requestPolicy?.allowedUserIds ?? [];
const usage = useMemo(() => new Map((status?.requestUsage ?? []).map((entry) => [entry.userId, entry])), [status?.requestUsage]); const usage = useMemo(
const toggle = (id: string) => run(`access-${id}`, async () => { () => new Map((status?.requestUsage ?? []).map((entry) => [entry.userId, entry])),
[status?.requestUsage],
);
const [owner, setOwner] = useState('');
const requests = useQuery<AdminRequestsResponse>(
`/admin/api/requests${owner ? `?userId=${encodeURIComponent(owner)}` : ''}`,
);
const rows = requests.data?.requests ?? [];
const nameFor = (id: string) =>
users.find((user) => user.id === id)?.username || id || 'unknown';
const toggle = (id: string) =>
run(`access-${id}`, async () => {
const next = allowed.includes(id) ? allowed.filter((entry) => entry !== id) : [...allowed, id]; const next = allowed.includes(id) ? allowed.filter((entry) => entry !== id) : [...allowed, id];
await wrap( await wrap(
() => api.post('/admin/api/request-policy', { allowedUserIds: next }), () => api.post('/admin/api/request-policy', { allowedUserIds: next }),
@@ -24,8 +94,12 @@ export function RequestsPage() {
return ( return (
<> <>
<PageHead title="Media requests" intro="Who can ask for something the library does not have." /> <PageHead
title="Media requests"
intro="Who can ask for something the library does not have, and what has been asked for."
/>
<Banner message={error} /> <Banner message={error} />
<Banner message={requests.error} />
{loading ? ( {loading ? (
<Loading rows={2} /> <Loading rows={2} />
@@ -64,9 +138,119 @@ export function RequestsPage() {
{users.length === 0 ? ( {users.length === 0 ? (
<Empty>No one has signed in yet.</Empty> <Empty>No one has signed in yet.</Empty>
) : ( ) : (
<TableWrap><table><thead><tr><th>User</th><th>Last seen</th><th className="num">Sent to services</th><th>Last request</th><th>Access</th></tr></thead><tbody> <TableWrap>
{users.map((user) => { const userUsage = usage.get(user.id); const granted = allowed.includes(user.id); return <tr key={user.id}><td><b>{user.username}</b></td><td className="muted nowrap">{when(user.lastSeen)}</td><td className="num">{userUsage?.requests ?? 0}</td><td className="muted nowrap">{userUsage?.lastRequest ? when(userUsage.lastRequest) : '—'}</td><td><Button size="sm" variant={granted ? 'quiet' : 'primary'} busy={busy === `access-${user.id}`} onClick={() => void toggle(user.id)}>{granted ? 'Remove access' : 'Give access'}</Button></td></tr>; })} <table>
</tbody></table></TableWrap> <thead>
<tr>
<th>User</th>
<th>Last seen</th>
<th className="num">Sent to services</th>
<th>Last request</th>
<th>Access</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const userUsage = usage.get(user.id);
const granted = allowed.includes(user.id);
return (
<tr key={user.id}>
<td>
<b>{user.username}</b>
</td>
<td className="muted nowrap">{when(user.lastSeen)}</td>
<td className="num">{userUsage?.requests ?? 0}</td>
<td className="muted nowrap">
{userUsage?.lastRequest ? when(userUsage.lastRequest) : '—'}
</td>
<td>
<Button
size="sm"
variant={granted ? 'quiet' : 'primary'}
busy={busy === `access-${user.id}`}
onClick={() => void toggle(user.id)}
>
{granted ? 'Remove access' : 'Give access'}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</TableWrap>
)}
</Card>
<Card
title="What has been asked for"
intro="Every recorded request across the household, newest first. The status is read live from Radarr, Sonarr and the library — the same answer the person who asked sees on their own page."
icon="list"
tone="data"
actions={
<Field label="Requester">
<select value={owner} onChange={(event) => setOwner(event.target.value)}>
<option value="">Everyone</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
</Field>
}
>
{requests.loading ? (
<Loading rows={3} />
) : (
<TableWrap>
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Requested by</th>
<th>Requested</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={5}>
{owner ? 'This viewer has not asked for anything.' : 'Nothing has been requested yet.'}
</EmptyRow>
) : (
rows.map((request) => (
<tr key={`${request.userId}:${request.mediaType}:${request.foreignId}`}>
<td>
<b>{request.title || `#${request.foreignId}`}</b>
{request.year ? <span className="muted"> ({request.year})</span> : null}
</td>
<td>
<Tag tone={request.mediaType === 'series' ? 'info' : 'data'}>
{request.mediaType === 'series' ? 'Series' : 'Movie'}
</Tag>
</td>
<td>{request.username || <Tag tone="warn">{nameFor(request.userId)}</Tag>}</td>
<td className="muted nowrap">{when(request.requestedAt)}</td>
<td>
<Tag tone={statusTone(request.status)}>
{request.statusLabel}
{typeof request.progress === 'number' ? ` · ${request.progress}%` : ''}
</Tag>
{typeof request.progress === 'number' ? (
<Meter value={request.progress} total={100} tone="info" />
) : null}
{request.statusDetail ? (
<div className="muted">{request.statusDetail}</div>
) : null}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
)} )}
</Card> </Card>
</> </>
+7 -326
View File
@@ -1,337 +1,18 @@
import { useEffect, useState } from 'react'; import { GatewaySettingsSection } from '../components/GatewaySettingsSection';
import { api } from '../api/client'; import { PageHead } from '../components/ui';
import type { GatewaySettings, GatewaySettingsResponse } from '../api/types';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Field, KeyValue, Loading, Note, PageHead, Tag } from '../components/ui';
/* The gateway's own settings, as distinct from every other page in this console. /* The gateway's own settings on their own page, at /admin/settings — kept as a standalone
* * address for the account menu and for administrative-event deep links. The same editor is
* Everything else here decides what the *televisions* do — which rows they draw, which * embedded in Client configuration, which is where it is discovered from the rail. */
* features they are offered, who may request a film. This page is about the server
* process: what day it thinks it is, how loudly it logs, how long it remembers a set that
* has not been switched on. That is why it is reached from the account menu rather than
* from the rail: it belongs beside "signed in as", not beside the household's content.
*
* Most fields amend `.env`: blank means "whatever this container was started with", which
* is printed beside the field. Notification display is server-native instead and has a
* Home-only default. */
/** OVERRIDE_OFF is the wire value for "switched off", which the three window settings need
* to distinguish from an empty field. See store.GatewaySettingsOff. */
const OVERRIDE_OFF = -1;
/** numberFieldValue renders one of those three: empty for "deployed", the word off for the
* sentinel, and the number otherwise. */
function numberFieldValue(value: number): string {
if (value === 0) return '';
if (value < 0) return 'off';
return String(value);
}
/** parseNumberField is its inverse, and is deliberately forgiving: an operator typing
* anything the server would refuse gets the deployed value back rather than an error
* about a field they were in the middle of. */
function parseNumberField(raw: string, offAllowed: boolean): number {
const text = raw.trim().toLowerCase();
if (text === '') return 0;
if (offAllowed && (text === 'off' || text === 'none' || text === '0')) return OVERRIDE_OFF;
const parsed = Number.parseInt(text, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function describe(value: number, unit: string): string {
if (value <= 0) return 'off';
return `${value} ${unit}${value === 1 ? '' : 's'}`;
}
function notificationDisplayLabel(value: string): string {
switch (value) {
case 'everywhere': return 'Everywhere';
case 'off': return 'Off';
default: return 'Home only';
}
}
interface Draft {
timezone: string;
logLevel: string;
sessionIdleDays: string;
sonarrAlertMinutes: string;
radarrAlertMinutes: string;
notificationDisplay: string;
embyHealthSeconds: string;
slowRequestMillis: string;
librarySyncMinutes: string;
}
function draftFrom(settings: GatewaySettings): Draft {
return {
timezone: settings.timezone ?? '',
logLevel: settings.logLevel ?? '',
sessionIdleDays: numberFieldValue(settings.sessionIdleDays),
sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes),
radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes),
notificationDisplay: settings.notificationDisplay || 'home_only',
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
slowRequestMillis: numberFieldValue(settings.slowRequestMillis),
librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes),
};
}
export function SettingsPage() { export function SettingsPage() {
const { data, error, loading, reload } = useQuery<GatewaySettingsResponse>('/admin/api/gateway-settings');
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<Draft | null>(null);
// The same rule the Maintenance page states: a refresh must never take a half-typed
// field away. The draft is adopted once, and after that the operator owns it until they
// save or discard.
useEffect(() => {
if (!draft && data) setDraft(draftFrom(data.settings));
}, [data, draft]);
const set = <K extends keyof Draft>(key: K, value: string) =>
setDraft((current) => (current ? { ...current, [key]: value } : current));
const save = () =>
run('save', async () => {
if (!draft) return;
const body: GatewaySettings = {
timezone: draft.timezone.trim(),
logLevel: draft.logLevel.trim(),
sessionIdleDays: parseNumberField(draft.sessionIdleDays, false),
sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true),
radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true),
notificationDisplay: draft.notificationDisplay,
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true),
slowRequestMillis: parseNumberField(draft.slowRequestMillis, true),
librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true),
};
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', body),
'Gateway settings saved.',
);
// Adopt what the server stored rather than what was typed: normalisation clamps and
// refuses, and a page still showing the rejected value would be lying about what is
// in force. A failed save leaves the draft alone — the operator's work is the one
// thing that must survive it.
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const clearAll = () =>
run('clear', async () => {
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
timezone: '', logLevel: '', sessionIdleDays: 0,
sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0, slowRequestMillis: 0,
librarySyncMinutes: 0, notificationDisplay: 'home_only',
}),
'Every setting is back to its deployed or server default.',
);
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const deployed = data?.deployed;
const effective = data?.effective;
const levels = data?.logLevels ?? [];
const notificationDisplays = data?.notificationDisplays ?? ['everywhere', 'home_only', 'off'];
return ( return (
<> <>
<PageHead <PageHead
title="Gateway settings" title="Gateway settings"
intro="Server-level settings for this gateway, changeable without a redeployment." intro="Server-level settings for this gateway, changeable without a redeployment. Also editable on Client configuration."
/> />
<Banner message={error} /> <GatewaySettingsSection />
{loading || !draft || !deployed || !effective ? (
<Loading rows={2} />
) : (
<>
<Card
title="This gateway"
intro="What the process is running and what it currently believes."
icon="chip"
tone="info"
actions={<Tag tone="info">{data?.version ?? 'unknown'}</Tag>}
>
<KeyValue
rows={[
{ label: 'Server timezone', value: effective.timezone || 'not set' },
{ label: 'Log level', value: effective.logLevel },
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
{ label: 'Slow-request breakdown', value: describe(effective.slowRequestMillis, 'millisecond') },
{ label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') },
{ label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
{ label: 'Notification display', value: notificationDisplayLabel(effective.notificationDisplay) },
]}
/>
</Card>
<Card
title="Server settings"
intro="Environment-backed fields can be left empty to use the deployed value shown beneath them. Changes take effect immediately — nothing here needs a restart."
icon="sliders"
tone="note"
footer={
<>
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save settings
</Button>
<Button busy={busy === 'clear'} onClick={() => void clearAll()}>
Use defaults
</Button>
</>
}
>
<div className="fields">
<Field
label="Server timezone"
hint={`Deployed: ${deployed.timezone || 'not set'}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`}
>
<input
type="text"
value={draft.timezone}
placeholder={deployed.timezone}
onChange={(event) => set('timezone', event.target.value)}
/>
</Field>
<Field
label="Log level"
hint={`Deployed: ${deployed.logLevel}. Applies to the running process at once, so debug can be turned on to watch something happen.`}
>
<select value={draft.logLevel} onChange={(event) => set('logLevel', event.target.value)}>
<option value="">Deployed ({deployed.logLevel})</option>
{levels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Sign a television out after (days)"
hint={`Deployed: ${deployed.sessionIdleDays} days. A session row holds a live Emby token, so this is how long a set nobody uses keeps working credentials.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sessionIdleDays}
placeholder={String(deployed.sessionIdleDays)}
onChange={(event) => set('sessionIdleDays', event.target.value)}
/>
</Field>
<Field
label="Slow-request breakdown above (ms)"
hint={`Deployed: ${deployed.slowRequestMillis || 'off'}. A request slower than this logs where its time went — Emby, Postgres, Redis, row assembly. Lower it while chasing one slow screen; type off to stop annotating altogether.`}
>
<input
type="text"
inputMode="numeric"
value={draft.slowRequestMillis}
placeholder={String(deployed.slowRequestMillis)}
onChange={(event) => set('slowRequestMillis', event.target.value)}
/>
</Field>
<Field
label="Emby health probe (seconds)"
hint={`Deployed: ${deployed.embyHealthSeconds || 'off'}. How often the gateway asks Emby whether it is answering. Type off to stop probing, which also removes the outage bar from every television.`}
>
<input
type="text"
inputMode="numeric"
value={draft.embyHealthSeconds}
placeholder={String(deployed.embyHealthSeconds)}
onChange={(event) => set('embyHealthSeconds', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Catalogue sweep (minutes)"
hint={`Deployed: ${deployed.librarySyncMinutes || 'off'}. How often the gateway asks Emby what has changed. With the Sonarr and Radarr webhooks wired up a new file is in the catalogue within a minute of landing, and this is only reconciliation for media they do not manage — 360 is a sensible choice then. Without them it is the only way anything is found.`}
>
<input
type="text"
inputMode="numeric"
value={draft.librarySyncMinutes}
placeholder={String(deployed.librarySyncMinutes)}
onChange={(event) => set('librarySyncMinutes', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Notification display"
hint="Controls informational banners for every viewer and television. Everywhere also permits playback overlays; Home only keeps them off active movies, episodes and trailers; Off hides them throughout Memby."
>
<select
value={draft.notificationDisplay}
onChange={(event) => set('notificationDisplay', event.target.value)}
>
{notificationDisplays.map((value) => (
<option key={value} value={value}>{notificationDisplayLabel(value)}</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Episode alert window (minutes)"
hint={`Deployed: ${deployed.sonarrAlertMinutes || 'off'}. How long a "just aired" notice stays on offer to a set that was switched off at the time. Type off to stop announcing them.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sonarrAlertMinutes}
placeholder={String(deployed.sonarrAlertMinutes)}
onChange={(event) => set('sonarrAlertMinutes', event.target.value)}
/>
</Field>
<Field
label="Film alert window (minutes)"
hint={`Deployed: ${deployed.radarrAlertMinutes || 'off'}. The same, for a film Radarr has just imported.`}
>
<input
type="text"
inputMode="numeric"
value={draft.radarrAlertMinutes}
placeholder={String(deployed.radarrAlertMinutes)}
onChange={(event) => set('radarrAlertMinutes', event.target.value)}
/>
</Field>
</div>
<Note tone="note">
These settings live in the database and survive a restart. A deployment rewrites
<code>.env</code>, not this document, so environment-backed values can then disagree;
permanent environment changes belong in <code>.env.example</code> as well.
</Note>
{data?.settings.updatedBy ? (
<Note>
Last changed by {data.settings.updatedBy}
{data.settings.updatedAt ? ` on ${new Date(data.settings.updatedAt).toLocaleString('en-NZ')}` : ''}.
</Note>
) : null}
</Card>
</>
)}
</> </>
); );
} }
+41
View File
@@ -786,6 +786,47 @@ a {
color: var(--text); color: var(--text);
} }
/* Tabs split a page that is genuinely several things about one subject. They sit on a
hairline directly under the page head; the active tab carries an accent underline. On a
narrow console the strip scrolls rather than wrapping. */
.tabs {
display: flex;
gap: 2px;
margin: -4px 0 22px;
border-bottom: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs button {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: none;
color: var(--muted);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.tabs button .ico {
width: 15px;
height: 15px;
}
.tabs button:hover {
color: var(--text);
}
.tabs button[aria-selected="true"] {
color: var(--text);
border-bottom-color: var(--accent);
}
@media (max-width: 1400px) { @media (max-width: 1400px) {
:root { :root {
--rail: 0px; --rail: 0px;
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?) val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO" ?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.39" val defaultVersionName = "0.3.40"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -2145,10 +2145,24 @@ class EmbyRepository internal constructor(
* Uploads a batch of row-engagement events. Silent on the direct path (nothing is * Uploads a batch of row-engagement events. Silent on the direct path (nothing is
* listening) and silent on failure telemetry must never surface on a TV. * listening) and silent on failure telemetry must never surface on a TV.
*/ */
fun reportRowEvents(events: List<GatewayRowEvent>) { fun reportRowEvents(
if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return events: List<GatewayRowEvent>,
onUnsent: (List<GatewayRowEvent>) -> Unit = {},
) {
if (events.isEmpty()) return
// Nothing is ever listening on the direct path, so drop rather than re-buffer.
if (!ServerConfig.isGateway) return
if (snapshot.token.isNullOrBlank()) {
// A session being (re)established: hand the batch back so it is retried on the
// next flush rather than dropped by the caller's drain.
onUnsent(events)
return
}
scope.launch { scope.launch {
runCatching { requireGateway().reportRowEvents(GatewayRowEvents(events)) } val delivered = runCatching {
requireGateway().reportRowEvents(GatewayRowEvents(events))
}.isSuccess
if (!delivered) onUnsent(events)
} }
} }
@@ -62,8 +62,13 @@ class RowAnalytics(
*/ */
fun rowFocused(rowId: String, rowKind: String, itemId: String) { fun rowFocused(rowId: String, rowKind: String, itemId: String) {
synchronized(lock) { synchronized(lock) {
if (focusedRowId == rowId) return if (focusedRowId == rowId) {
closeOpenFocus() // Still the same strip — extend the dwell, but remember the card the
// remote actually came to rest on rather than the one it entered on.
lastFocusedItemId = itemId
return
}
closeOpenFocus(reArm = false)
focusedRowId = rowId focusedRowId = rowId
focusedRowKind = rowKind focusedRowKind = rowKind
focusStartedAt = now() focusStartedAt = now()
@@ -92,11 +97,34 @@ class RowAnalytics(
} }
/** /**
* Closes the open focus measurement call when leaving the home screen, or before a * Closes the open focus measurement outright call when focus genuinely leaves the
* flush, so dwell is not lost while the viewer sits on one row. * rows (the rail, the hero, an overlay, a selection) or the home screen is left.
*/ */
fun endFocus() { fun endFocus() {
synchronized(lock) { closeOpenFocus() } synchronized(lock) { closeOpenFocus(reArm = false) }
}
/**
* Emits the dwell accrued so far and keeps measuring the same row. Used by the periodic
* flush: a viewer who sits on one row for two minutes should contribute the whole two
* minutes, not one chunk ending at the first flush and nothing after it until they
* move to a different row.
*/
fun checkpointFocus() {
synchronized(lock) { closeOpenFocus(reArm = true) }
}
/**
* Returns events to the buffer after an upload could not deliver them, so a transient
* network failure costs a delay rather than the batch. Bounded like every other write:
* if the buffer is now over capacity the oldest events are dropped.
*/
fun restore(events: List<GatewayRowEvent>) {
if (events.isEmpty()) return
synchronized(lock) {
buffer.addAll(0, events)
while (buffer.size > maxBuffered) buffer.removeAt(0)
}
} }
/** Returns everything buffered and clears it. */ /** Returns everything buffered and clears it. */
@@ -116,28 +144,37 @@ class RowAnalytics(
impressed.clear() impressed.clear()
impressedItems.clear() impressedItems.clear()
focusedRowId = null focusedRowId = null
focusStartedAt = 0
lastFocusedItemId = ""
} }
} }
private var lastFocusedItemId: String = "" private var lastFocusedItemId: String = ""
private fun closeOpenFocus() { /**
* @param reArm keep measuring the same row from now, rather than clearing it. A
* checkpoint flush re-arms; a genuine departure does not.
*/
private fun closeOpenFocus(reArm: Boolean) {
val rowId = focusedRowId ?: return val rowId = focusedRowId ?: return
val dwell = (now() - focusStartedAt).coerceAtLeast(0) val dwell = (now() - focusStartedAt).coerceAtLeast(0)
focusedRowId = null // Sub-threshold glances are D-pad travel, not attention. Drop them — and, when
// Sub-second glances are D-pad travel, not attention. Dropping them keeps the // re-arming, leave the clock running so the unrecorded fraction is not discarded
// numbers meaningful and the batches small. // on every flush.
if (dwell < MIN_DWELL_MS) return if (dwell >= MIN_DWELL_MS) {
add( add(
GatewayRowEvent( GatewayRowEvent(
rowId = rowId, rowId = rowId,
rowKind = focusedRowKind, rowKind = focusedRowKind,
event = EVENT_FOCUS, event = EVENT_FOCUS,
itemId = lastFocusedItemId, itemId = lastFocusedItemId,
dwellMs = dwell, dwellMs = dwell,
occurredAt = timestamp(), occurredAt = timestamp(),
), ),
) )
focusStartedAt = now()
}
if (!reArm) focusedRowId = null
} }
/** Caller already holds the lock. */ /** Caller already holds the lock. */
@@ -498,6 +498,13 @@ data class BaseItem(
@SerialName("MembyLifecycle") val membyLifecycle: String? = null, @SerialName("MembyLifecycle") val membyLifecycle: String? = null,
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null, @SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true, @SerialName("MembyPlayable") val membyPlayable: Boolean = true,
// Set by the gateway on a Continue Watching card for a title this viewer requested that
// has just become watchable, so it can wear a REQUEST READY tag until they start it.
// The label is the server's wording (the MembyAirLabel precedent); the flag alone is
// enough for an older build, which falls back to its own constant. Both default, so a
// cached home payload decodes unchanged.
@SerialName("MembyRequestReady") val membyRequestReady: Boolean = false,
@SerialName("MembyRequestReadyLabel") val membyRequestReadyLabel: String? = null,
@SerialName("MembySearchState") val membySearchState: String? = null, @SerialName("MembySearchState") val membySearchState: String? = null,
@SerialName("MembyRequestable") val membyRequestable: Boolean = false, @SerialName("MembyRequestable") val membyRequestable: Boolean = false,
@SerialName("MembyPosterURL") val membyPosterUrl: String? = null, @SerialName("MembyPosterURL") val membyPosterUrl: String? = null,
@@ -176,6 +176,12 @@ data class HomeRow(
val title: String = "", val title: String = "",
val kind: String = "", val kind: String = "",
val items: List<BaseItem> = emptyList(), val items: List<BaseItem> = emptyList(),
/**
* Operator-pinned card shape: "poster" forces upright poster cards, "thumb" forces the
* wide landscape cards Continue Watching uses. Empty (the default, and every row from a
* gateway that predates this) leaves the client's automatic choice alone.
*/
val layout: String = "",
) )
/** Everything the launcher renders, in one response. */ /** Everything the launcher renders, in one response. */
@@ -271,6 +271,12 @@ internal fun HomeContentPane(
// 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(homeState.selectedDestination) { mutableStateOf(false) } var rowFocusedBelowHero by remember(homeState.selectedDestination) { mutableStateOf(false) }
// The moment focus is no longer on a shelf — up to the hero, across to My Shows, or a
// destination switch — close the open dwell measurement, so minutes spent above the
// rows are not credited to whichever row was focused last.
LaunchedEffect(rowFocusedBelowHero) {
if (!rowFocusedBelowHero) homeViewModel.notifyFocusLeftRows()
}
val showHomeHero = shouldShowHomeMovieHero( val showHomeHero = shouldShowHomeMovieHero(
hasMovies = hasContextualHero, hasMovies = hasContextualHero,
listAtTop = homeListAtTop, listAtTop = homeListAtTop,
@@ -616,6 +616,7 @@ internal fun serverHomeRows(
"continue" -> CONTINUE_ROW_SECONDARY_METADATA "continue" -> CONTINUE_ROW_SECONDARY_METADATA
else -> BROWSE_ROW_SECONDARY_METADATA else -> BROWSE_ROW_SECONDARY_METADATA
}, },
cardLayout = row.layout,
) )
} }
// A server response is already context-ranked. Do not reapply the static bundled // A server response is already context-ranked. Do not reapply the static bundled
@@ -654,7 +654,7 @@ internal fun HomeScreen(
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, homeViewModel) { DisposableEffect(lifecycleOwner, homeViewModel) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics() if (event == Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics(endMeasurement = true)
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
onDispose { onDispose {
@@ -683,6 +683,9 @@ internal fun HomeScreen(
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
onRailFocusChanged = { onRailFocusChanged = {
homeState.navigationExpanded = it homeState.navigationExpanded = it
// Focus on the rail is focus off the shelves: end the open row dwell so
// it stops accruing while the viewer is in the navigation or an overlay.
if (it) homeViewModel.notifyFocusLeftRows()
}, },
onDestinationSelected = { destination -> onDestinationSelected = { destination ->
homeViewModel.trackJourney( homeViewModel.trackJourney(
@@ -262,17 +262,30 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) = fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
analytics.rowFocused(rowId, rowKind, itemId) analytics.rowFocused(rowId, rowKind, itemId)
fun trackRowSelected(rowId: String, rowKind: String, itemId: String) = fun trackRowSelected(rowId: String, rowKind: String, itemId: String) {
analytics.rowSelected(rowId, rowKind, itemId) analytics.rowSelected(rowId, rowKind, itemId)
// Opening something ends the current dwell — the viewer has left the row for a
// detail page, so time on that page must not be added to the row's total.
analytics.endFocus()
}
/** /**
* Closes the open dwell measurement and uploads. Called on a timer and when the home * Focus has moved off the shelves to the rail, the hero, My Shows or an overlay.
* screen stops, so time spent sitting on one row is not lost. * Closes the open dwell measurement so it stops accruing away from the rows.
*/ */
fun flushAnalytics() { fun notifyFocusLeftRows() = analytics.endFocus()
/**
* Emits accrued dwell and uploads. On a timer it *checkpoints* a viewer sitting on
* one row keeps accruing dwell across flushes rather than contributing a single ~20s
* chunk until they move and [endMeasurement] closes it outright for the cases where
* the home screen is genuinely being left. A batch the upload cannot deliver is put
* back for the next flush rather than lost to a transient network failure.
*/
fun flushAnalytics(endMeasurement: Boolean = false) {
if (analyticsPausedForPlayback) return if (analyticsPausedForPlayback) return
analytics.endFocus() if (endMeasurement) analytics.endFocus() else analytics.checkpointFocus()
repository.reportRowEvents(analytics.drain()) repository.reportRowEvents(analytics.drain()) { unsent -> analytics.restore(unsent) }
repository.reportJourneyEvents(journey.drain()) repository.reportJourneyEvents(journey.drain())
} }
@@ -310,7 +323,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
*/ */
val journeySink: JourneySink get() = journey val journeySink: JourneySink get() = journey
fun endJourney(screen: String) { journey.end(screen); flushAnalytics() } fun endJourney(screen: String) { journey.end(screen); flushAnalytics(endMeasurement = true) }
suspend fun endJourneyBeforeProfileSwitch(screen: String) { suspend fun endJourneyBeforeProfileSwitch(screen: String) {
analytics.endFocus() analytics.endFocus()
@@ -991,7 +1004,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
override fun onCleared() { override fun onCleared() {
analyticsPausedForPlayback = false analyticsPausedForPlayback = false
flushAnalytics() flushAnalytics(endMeasurement = true)
super.onCleared() super.onCleared()
} }
@@ -40,6 +40,7 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyMutedText
@@ -69,6 +70,12 @@ data class ResumableMediaCardModel(
val played: Boolean = false, val played: Boolean = false,
val favourite: Boolean = false, val favourite: Boolean = false,
val isNextUp: Boolean = false, val isNextUp: Boolean = false,
/**
* Non-null when this card is a title the viewer requested that has just become
* watchable. Holds the tag wording, which the gateway supplies; [REQUEST_READY_TAG] is
* the fallback for a payload from a gateway that predates the label field.
*/
val requestReadyTag: String? = null,
) { ) {
val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName) val episodeLabel: String? get() = episodeLabel(seasonNumber, episodeNumber, episodeName)
val progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks) val progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks)
@@ -76,6 +83,9 @@ data class ResumableMediaCardModel(
val nextUpLabel: String? get() = "Next up".takeIf { isNextUp } val nextUpLabel: String? get() = "Next up".takeIf { isNextUp }
} }
/** Client fallback for the request-ready tag when the gateway sent no wording. */
const val REQUEST_READY_TAG = "REQUEST READY"
internal fun BaseItem.toResumableMediaCardModel( internal fun BaseItem.toResumableMediaCardModel(
thumbUrl: String?, thumbUrl: String?,
backdropUrl: String?, backdropUrl: String?,
@@ -102,6 +112,11 @@ internal fun BaseItem.toResumableMediaCardModel(
primaryUrl = primaryUrl, primaryUrl = primaryUrl,
played = played, played = played,
favourite = isFavorite, favourite = isFavorite,
requestReadyTag = if (membyRequestReady) {
membyRequestReadyLabel?.trim()?.takeIf(String::isNotEmpty) ?: REQUEST_READY_TAG
} else {
null
},
// This adapter is used by the Continue Watching card. Within that merged row, an // This adapter is used by the Continue Watching card. Within that merged row, an
// unplayed episode with no playhead is the Next Up half; resumable episodes have a // unplayed episode with no playhead is the Next Up half; resumable episodes have a
// positive playhead and films are never supplied by Emby's Next Up feed. // positive playhead and films are never supplied by Emby's Next Up feed.
@@ -333,6 +348,22 @@ fun ResumableMediaCard(
} }
} }
} }
model.requestReadyTag?.let { tag ->
Text(
text = tag,
color = MembyAccentInk,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
maxLines = 1,
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(MembyAccent)
.padding(horizontal = 7.dp, vertical = 4.dp),
)
}
if (focused) MembyArtworkPlayCue(Modifier.align(Alignment.Center)) if (focused) MembyArtworkPlayCue(Modifier.align(Alignment.Center))
} }
Text( Text(
@@ -63,6 +63,11 @@ data class HomeBrowseRow(
val emptyMessage: String, val emptyMessage: String,
val showSecondaryMetadata: Boolean = true, val showSecondaryMetadata: Boolean = true,
val showWatchedEpisodeCount: Boolean = false, val showWatchedEpisodeCount: Boolean = false,
/**
* Operator-pinned card shape from the gateway's section definitions: "poster" or
* "thumb". Empty leaves [cardFormat]'s automatic choice alone.
*/
val cardLayout: String = "",
) )
private fun getHomeRowIcon(row: HomeBrowseRow): ImageVector = when { private fun getHomeRowIcon(row: HomeBrowseRow): ImageVector = when {
@@ -175,8 +180,12 @@ internal fun MediaRow(
when { when {
row.items.isEmpty() && row.loading -> { row.items.isEmpty() && row.loading -> {
val isPortrait = remember(row.kind) { val isPortrait = remember(row.kind, row.cardLayout) {
row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS when (row.cardLayout) {
"poster" -> true
"thumb", "landscape" -> false
else -> row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
}
} }
LazyRow( LazyRow(
contentPadding = PaddingValues( contentPadding = PaddingValues(
@@ -226,7 +235,7 @@ internal fun MediaRow(
items = row.items, items = row.items,
key = { _, item -> item.id }, key = { _, item -> item.id },
contentType = { _, item -> contentType = { _, item ->
if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape" if (cardFormat(row.kind, item, artworkStyle, row.cardLayout) == MediaCardFormat.PORTRAIT) "portrait" else "landscape"
}, },
) { index, item -> ) { index, item ->
val targetFocusRequester = when { val targetFocusRequester = when {
@@ -266,8 +275,8 @@ internal fun MediaRow(
{ currentOnItemLongPressed(currentItem) } { currentOnItemLongPressed(currentItem) }
} }
val format = remember(row.kind, item.id, item.isEpisode, artworkStyle) { val format = remember(row.kind, item.id, item.isEpisode, artworkStyle, row.cardLayout) {
cardFormat(row.kind, item, artworkStyle) cardFormat(row.kind, item, artworkStyle, row.cardLayout)
} }
if (row.kind == MediaRowKind.CONTINUE) { if (row.kind == MediaRowKind.CONTINUE) {
@@ -314,18 +323,29 @@ internal fun MediaRow(
} }
} }
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE } internal enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
private fun cardFormat( /**
* Resolves a card's shape. A per-row [rowLayout] pinned by the operator ("poster" /
* "thumb") wins over the viewer's global [artworkStyle], which in turn wins over the
* automatic rule (Continue Watching and episodes landscape, everything else poster).
* Pure so [MediaRowCardFormatTest] can pin the precedence.
*/
internal fun cardFormat(
kind: MediaRowKind, kind: MediaRowKind,
item: BaseItem, item: BaseItem,
artworkStyle: String = "automatic", artworkStyle: String = "automatic",
): MediaCardFormat = when (artworkStyle) { rowLayout: String = "",
): MediaCardFormat = when (rowLayout) {
"poster" -> MediaCardFormat.PORTRAIT "poster" -> MediaCardFormat.PORTRAIT
"backdrop" -> MediaCardFormat.LANDSCAPE "thumb", "landscape" -> MediaCardFormat.LANDSCAPE
else -> when { else -> when (artworkStyle) {
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE "poster" -> MediaCardFormat.PORTRAIT
item.isEpisode -> MediaCardFormat.LANDSCAPE "backdrop" -> MediaCardFormat.LANDSCAPE
else -> MediaCardFormat.PORTRAIT else -> when {
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
item.isEpisode -> MediaCardFormat.LANDSCAPE
else -> MediaCardFormat.PORTRAIT
}
} }
} }
@@ -46,6 +46,72 @@ class RowAnalyticsTest {
assertEquals(6_000L, focuses.single().dwellMs) assertEquals(6_000L, focuses.single().dwellMs)
} }
@Test
fun `a checkpoint emits dwell and keeps measuring the same row`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("recommended", "MOVIES", "item-1")
clock.advance(20_000)
collector.checkpointFocus()
clock.advance(20_000)
collector.checkpointFocus()
clock.advance(5_000)
collector.endFocus()
val dwell = collector.drain()
.filter { it.event == RowAnalytics.EVENT_FOCUS }
.sumOf { it.dwellMs }
// The whole 45s is credited to the row, not just the first chunk.
assertEquals(45_000L, dwell)
}
@Test
fun `a checkpoint below the threshold does not discard the accrued fraction`() {
val clock = FakeClock()
val collector = analytics(clock)
collector.rowFocused("recommended", "MOVIES", "item-1")
clock.advance(300)
collector.checkpointFocus() // below MIN_DWELL_MS — nothing emitted, clock left running
clock.advance(300)
collector.endFocus()
val focus = collector.drain().single { it.event == RowAnalytics.EVENT_FOCUS }
assertEquals(600L, focus.dwellMs)
}
@Test
fun `restored events are retried on the next drain`() {
val collector = analytics(FakeClock())
collector.rowImpression("favorites", "FAVORITES")
val undelivered = collector.drain()
assertEquals(1, undelivered.size)
collector.restore(undelivered)
collector.rowImpression("recommended", "MOVIES")
assertEquals(
listOf("favorites", "recommended"),
collector.drain().map { it.rowId },
)
}
@Test
fun `restored events past capacity drop the oldest`() {
val collector = analytics(FakeClock(), maxBuffered = 2)
val stale = List(3) {
com.ponzischeme89.memby.data.model.GatewayRowEvent(
rowId = "stale-$it", rowKind = "MOVIES",
event = RowAnalytics.EVENT_IMPRESSION, occurredAt = "x",
)
}
collector.restore(stale)
assertEquals(listOf("stale-1", "stale-2"), collector.drain().map { it.rowId })
}
@Test @Test
fun `passing through a row is not counted as attention`() { fun `passing through a row is not counted as attention`() {
val clock = FakeClock() val clock = FakeClock()
@@ -0,0 +1,42 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Test
/** The precedence in [cardFormat]: an operator's per-row pin beats the viewer's global
* artwork style, which beats the automatic episode/poster rule. */
class MediaRowCardFormatTest {
private val movie = BaseItem(id = "m", name = "A Film", type = "Movie")
private val episode = BaseItem(id = "e", name = "Pilot", type = "Episode")
@Test
fun automaticRuleWhenNothingIsPinned() {
assertEquals(MediaCardFormat.PORTRAIT, cardFormat(MediaRowKind.MOVIES, movie))
assertEquals(MediaCardFormat.LANDSCAPE, cardFormat(MediaRowKind.MOVIES, episode))
assertEquals(MediaCardFormat.LANDSCAPE, cardFormat(MediaRowKind.CONTINUE, movie))
}
@Test
fun rowLayoutOverridesEverything() {
assertEquals(
MediaCardFormat.LANDSCAPE,
cardFormat(MediaRowKind.FAVORITES, movie, artworkStyle = "poster", rowLayout = "thumb"),
)
assertEquals(
MediaCardFormat.PORTRAIT,
cardFormat(MediaRowKind.CONTINUE, episode, artworkStyle = "backdrop", rowLayout = "poster"),
)
}
@Test
fun globalArtworkStyleStillAppliesWithoutARowPin() {
assertEquals(MediaCardFormat.LANDSCAPE, cardFormat(MediaRowKind.MOVIES, movie, artworkStyle = "backdrop"))
assertEquals(MediaCardFormat.PORTRAIT, cardFormat(MediaRowKind.MOVIES, episode, artworkStyle = "poster"))
}
@Test
fun unknownRowLayoutFallsThrough() {
assertEquals(MediaCardFormat.PORTRAIT, cardFormat(MediaRowKind.MOVIES, movie, rowLayout = "sideways"))
}
}
@@ -0,0 +1,73 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
import org.junit.Before
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
/**
* The REQUEST READY tag on a Continue Watching card. The claim it makes that the tag reads
* as a temporary marker rather than as part of the artwork, and sits clear of the watched /
* favourite icons in the opposite corner is not something a unit test can check.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class RequestReadyCardScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `a freshly arrived request wears the request ready tag`() {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
Box(Modifier.fillMaxSize().padding(48.dp)) {
ContinueWatchingCard(
item = BaseItem(
id = "requested-film",
name = "The Slow Return",
type = "Movie",
runTimeTicks = 108L * 60L * 10_000_000L,
userData = UserItemData(playbackPositionTicks = 0L),
membyRequestReady = true,
membyRequestReadyLabel = "REQUEST READY",
),
availableWidth = 864.dp,
portraitArtwork = false,
onFocused = {},
onClick = {},
onLongClick = {},
)
}
}
}
compose.onNodeWithText("REQUEST READY").assertExists()
compose.onRoot().captureRoboImage(
"build/screenshots/request-ready/continue-watching-tag.png",
)
}
}
@@ -105,6 +105,35 @@ class ResumableMediaCardTest {
assertNull(model.nextUpLabel) assertNull(model.nextUpLabel)
} }
@Test
fun `a request-ready title carries the gateway's tag wording`() {
val model = BaseItem(
id = "film",
name = "A Requested Film",
type = "Movie",
membyRequestReady = true,
membyRequestReadyLabel = "READY FOR YOU",
).toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
assertEquals("READY FOR YOU", model.requestReadyTag)
}
@Test
fun `a request-ready title with no wording falls back to the client constant`() {
val model = BaseItem(id = "film", name = "A Film", type = "Movie", membyRequestReady = true)
.toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
assertEquals(REQUEST_READY_TAG, model.requestReadyTag)
}
@Test
fun `an ordinary card has no request-ready tag`() {
val model = BaseItem(id = "film", name = "A Film", type = "Movie")
.toResumableMediaCardModel(thumbUrl = null, backdropUrl = null, primaryUrl = null)
assertNull(model.requestReadyTag)
}
@Test @Test
fun `landscape artwork prefers thumb over backdrop and primary`() { fun `landscape artwork prefers thumb over backdrop and primary`() {
val model = ResumableMediaCardModel( val model = ResumableMediaCardModel(
+1
View File
@@ -74,6 +74,7 @@ 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/requests", s.adminAuth(s.handleAdminRequests))
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports)) 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("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))
+2 -2
View File
@@ -105,7 +105,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
for _, account := range accounts { for _, account := range accounts {
identifiers = append(identifiers, watchTimeAccount{ID: account.ID, Username: account.Username}) identifiers = append(identifiers, watchTimeAccount{ID: account.ID, Username: account.Username})
} }
watchTime := s.watchTimeForAccounts(r.Context(), identifiers) watchTime, priorWeekWatchTime := s.watchTimeForAccounts(r.Context(), identifiers)
result := make([]adminMembyAccount, 0, len(accounts)) result := make([]adminMembyAccount, 0, len(accounts))
for _, account := range accounts { for _, account := range accounts {
@@ -123,7 +123,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
} }
watched, matchedWatchTime := watchTime[account.ID] watched, matchedWatchTime := watchTime[account.ID]
result = append(result, adminMembyAccount{ result = append(result, adminMembyAccount{
WatchTime: summariseWatchTime(watched, matchedWatchTime), WatchTime: summariseWatchTime(watched, matchedWatchTime, priorWeekWatchTime[account.ID]),
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt, ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
Initials: stringPreference(accountSettings.Preferences, "profileInitials"), Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
ShortName: stringPreference(accountSettings.Preferences, "shortName"), ShortName: stringPreference(accountSettings.Preferences, "shortName"),
@@ -44,6 +44,9 @@ func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse {
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second), EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()), SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()),
LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute), LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute),
HomeTTLSeconds: int(s.homeTTL() / time.Second),
RecommendTTLHours: int(s.recommendTTL() / time.Hour),
ForYouRebuildHour: s.forYouRebuildHour(),
}, },
LogLevels: store.GatewayLogLevels, LogLevels: store.GatewayLogLevels,
NotificationDisplays: store.GatewayNotificationDisplays, NotificationDisplays: store.GatewayNotificationDisplays,
@@ -146,6 +149,15 @@ func gatewaySettingsChanges(before, after store.GatewaySettings) string {
if before.LibrarySyncMinutes != after.LibrarySyncMinutes { if before.LibrarySyncMinutes != after.LibrarySyncMinutes {
changes = append(changes, "library sweep interval") changes = append(changes, "library sweep interval")
} }
if before.HomeTTLSeconds != after.HomeTTLSeconds {
changes = append(changes, "home cache lifetime")
}
if before.RecommendTTLHours != after.RecommendTTLHours {
changes = append(changes, "recommendation cache lifetime")
}
if !sameIntPointer(before.ForYouRebuildHour, after.ForYouRebuildHour) {
changes = append(changes, "For You rebuild hour")
}
switch len(changes) { switch len(changes) {
case 0: case 0:
return "" return ""
@@ -156,6 +168,13 @@ func gatewaySettingsChanges(before, after store.GatewaySettings) string {
} }
} }
func sameIntPointer(a, b *int) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func joinPhrase(values []string) string { func joinPhrase(values []string) string {
switch len(values) { switch len(values) {
case 0: case 0:
+16 -1
View File
@@ -167,7 +167,22 @@ func adminPreviewData() map[string]any {
map[string]any{"from": "details", "to": "playback", "count": 18}, map[string]any{"from": "details", "to": "playback", "count": 18},
}, },
}, },
"/admin/api/requests": map[string]any{"requests": []any{}}, "/admin/api/requests": map[string]any{"requests": []any{
map[string]any{"userId": "u-1", "username": "matt", "mediaType": "movie",
"foreignId": 693134, "title": "Dune: Part Two", "year": 2024,
"requestedAt": stamp(2 * time.Hour), "status": "downloading",
"statusLabel": "Downloading", "statusDetail": "About 12 minutes left",
"progress": 64},
map[string]any{"userId": "u-2", "username": "sam", "mediaType": "series",
"foreignId": 371980, "title": "Severance", "year": 2022,
"requestedAt": stamp(30 * time.Hour), "status": "available",
"statusLabel": "Ready to watch", "statusDetail": "In your library",
"embyItemId": "abc123"},
map[string]any{"userId": "u-1", "username": "matt", "mediaType": "movie",
"foreignId": 1032823, "title": "Memory", "year": 2023,
"requestedAt": stamp(9 * 24 * time.Hour), "status": "requested",
"statusLabel": "Requested", "statusDetail": "Searching for a copy"},
}},
// A prefix among the terms and an unattributed row in the log, because both are // A prefix among the terms and an unattributed row in the log, because both are
// ordinary here and a preview showing neither would not be a preview of this page. // ordinary here and a preview showing neither would not be a preview of this page.
"/admin/api/searches": map[string]any{ "/admin/api/searches": map[string]any{
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The console's window on what the household has actually asked for.
//
// The overview already reports RequestUsage — a count and a last-asked time per viewer —
// which answers "who uses the feature" and nothing about "what did they ask for". This is
// the table underneath that number: every stored ask, newest first, with the person
// attached and the same derived status the viewer's own page shows, so an operator can see
// that three people are waiting on the same film or that a request has been stuck
// searching for a week.
const adminRequestsLimit = store.MediaRequestSweepLimit
// adminMediaRequest is one row of that table: an ask, who made it, and what has become of
// it. The status half is derived per read by decorateRequests, exactly as it is for the
// viewer, so the console and the television never disagree about a title's state.
type adminMediaRequest struct {
UserID string `json:"userId"`
Username string `json:"username,omitempty"`
myRequest
}
type adminRequestsResponse struct {
Requests []adminMediaRequest `json:"requests"`
}
func (s *Server) handleAdminRequests(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
owned, err := s.store.AllMediaRequests(ctx, adminRequestsLimit)
if err != nil {
s.loggerFor(ctx).Error("admin media requests read failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read media requests")
return
}
// An optional filter to what one viewer asked for, applied here rather than in SQL: the
// list is already bounded and small, and AllMediaRequests is the one read the ready
// sweep also uses.
if userID := strings.TrimSpace(r.URL.Query().Get("userId")); userID != "" {
filtered := owned[:0]
for _, req := range owned {
if req.UserID == userID {
filtered = append(filtered, req)
}
}
owned = filtered
}
stored := make([]store.MediaRequest, len(owned))
for i := range owned {
stored[i] = owned[i].MediaRequest
}
// One decoration pass for the whole household. The *arr catalogues it consults are
// cached for the day and shared, so this is the same cost as one viewer opening their
// page. The console is the operator's tool, so it always sees the full progress
// vocabulary.
cards := s.decorateRequests(ctx, stored, true)
// A name an ask cannot be attributed to costs the column and nothing else — the id
// still tells one requester from another, and the titles are the point of the page.
names := map[string]string{}
if users, err := s.store.KnownUsers(ctx); err == nil {
for _, user := range users {
if user.Username != "" {
names[user.ID] = user.Username
}
}
} else {
s.loggerFor(ctx).Warn("media request owners unresolved", "error", err)
}
rows := make([]adminMediaRequest, len(owned))
for i := range owned {
rows[i] = adminMediaRequest{
UserID: owned[i].UserID,
Username: names[owned[i].UserID],
myRequest: cards[i],
}
}
writeJSON(w, http.StatusOK, adminRequestsResponse{Requests: rows})
}
+22
View File
@@ -947,4 +947,26 @@ func TestToRowEventValidatesAndClamps(t *testing.T) {
t.Fatalf("user should come from the session, got %q", event.UserID) t.Fatalf("user should come from the session, got %q", event.UserID)
} }
}) })
t.Run("rejects free text in the controlled fields", func(t *testing.T) {
cases := []rowEventPayload{
{RowID: "row with spaces", Event: "focus"},
{RowID: "r", RowKind: "MOVIES; drop table", Event: "focus"},
{RowID: "r", Event: "focus", ItemID: "not/an/id"},
{RowID: strings.Repeat("x", 101), Event: "focus"},
}
for _, payload := range cases {
if _, ok := toRowEvent(payload, "u", now); ok {
t.Fatalf("expected %+v to be dropped", payload)
}
}
})
t.Run("keeps a well-formed curated row id", func(t *testing.T) {
event, ok := toRowEvent(
rowEventPayload{RowID: "curated:movies:genre:science-fiction", RowKind: "movies", Event: "impression"}, "u", now)
if !ok || event.RowID != "curated:movies:genre:science-fiction" {
t.Fatalf("a normal composed row id should pass, got ok=%v event=%+v", ok, event)
}
})
} }
+9
View File
@@ -210,6 +210,15 @@ func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.Ro
if payload.RowID == "" { if payload.RowID == "" {
return store.RowEvent{}, false return store.RowEvent{}, false
} }
// Row id, kind and item id are controlled vocabulary the television composes, never
// free text — so an event carrying anything else in them was misassembled, and a
// pathological value would distort the aggregates the console reads. Drop it whole,
// as the journey path does.
if !safeAnalyticsValue(payload.RowID, 100) ||
!safeAnalyticsValue(payload.RowKind, 40) ||
!safeAnalyticsValue(payload.ItemID, 100) {
return store.RowEvent{}, false
}
switch payload.Event { switch payload.Event {
case store.RowEventImpression, store.RowEventFocus, store.RowEventSelect: case store.RowEventImpression, store.RowEventFocus, store.RowEventSelect:
default: default:
+5 -2
View File
@@ -132,7 +132,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Actor: req.Username, Target: req.DeviceName, Actor: req.Username, Target: req.DeviceName,
Link: "/admin/logins", Link: "/admin/logins",
Metadata: adminevents.Meta(map[string]any{ Metadata: adminevents.Meta(map[string]any{
"deviceId": req.DeviceID, "ip": requestClientIP(r), "deviceId": req.DeviceID, "ip": s.resolveClientIP(r).String(),
}), }),
}) })
writeError(w, http.StatusUnauthorized, "sign-in failed") writeError(w, http.StatusUnauthorized, "sign-in failed")
@@ -205,14 +205,17 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Now that the session exists, the request line this call ends with can name it too. // Now that the session exists, the request line this call ends with can name it too.
identify(r.Context(), sess) identify(r.Context(), sess)
origin := s.resolveClientIP(r)
s.loggerFor(r.Context()).Info("signed in", s.loggerFor(r.Context()).Info("signed in",
"emby_user", sess.EmbyUserID, "emby_user", sess.EmbyUserID,
"device_id", sess.DeviceID, "device_id", sess.DeviceID,
"protocol", clientLogValue(sess.ClientProtocol), "protocol", clientLogValue(sess.ClientProtocol),
"replaced_session", len(created.ReplacedHash) > 0, "replaced_session", len(created.ReplacedHash) > 0,
"client_ip", origin.String(),
"client_ip_via", origin.Via,
) )
address := requestClientIP(r) address := origin.String()
s.recordLogin(r, store.LoginEvent{ s.recordLogin(r, store.LoginEvent{
EmbyUserID: sess.EmbyUserID, Username: sess.Username, EmbyUserID: sess.EmbyUserID, Username: sess.Username,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName, DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"net"
"net/http"
"net/netip"
"strings"
"github.com/ponzischeme89/memby/server/internal/config"
)
// clientIP is where a request came from and how that was worked out. Via is one of
// "forwarded", "real-ip", "socket" or "none". It is for operational logging and the
// admin console's directory only — never authentication or access control.
type clientIP struct {
Addr string
Via string
}
// String is the address, or "unknown" when none could be determined, matching what the
// login history and the trailer log previously recorded.
func (c clientIP) String() string {
if c.Addr == "" {
return "unknown"
}
return c.Addr
}
// resolveClientIP works out the originating address of an incoming request. It believes
// X-Forwarded-For and X-Real-IP only when the immediate peer is a configured trusted
// proxy, so a client that reaches the gateway directly cannot spoof its address with a
// header.
func (s *Server) resolveClientIP(r *http.Request) clientIP {
trusted := s.cfg.TrustedProxies
if trusted == nil {
trusted = config.DefaultTrustedProxyRanges()
}
return clientIPFrom(r.RemoteAddr, r.Header, trusted)
}
func clientIPFrom(remoteAddr string, header http.Header, trusted []netip.Prefix) clientIP {
socket := parseHostAddr(remoteAddr)
if !socket.IsValid() {
return clientIP{Via: "none"}
}
fromSocket := clientIP{Addr: socket.String(), Via: "socket"}
if !prefixesContain(trusted, socket) {
// The peer is not a known proxy, so nothing it forwarded is believed.
return fromSocket
}
// X-Forwarded-For grows by one entry per hop, so the rightmost is the nearest
// proxy. Walking right to left, the first entry that is not itself a trusted proxy
// is the originating client.
forwarded := forwardedChain(header)
for i := len(forwarded) - 1; i >= 0; i-- {
if !prefixesContain(trusted, forwarded[i]) {
return clientIP{Addr: forwarded[i].String(), Via: "forwarded"}
}
}
// Every forwarded hop was itself trusted. If there were any, the leftmost is the
// genuine origin — a direct LAN client behind the household proxy. Otherwise fall
// back to a single X-Real-IP, then to the socket.
if len(forwarded) > 0 {
return clientIP{Addr: forwarded[0].String(), Via: "forwarded"}
}
if realIP := parseAddr(header.Get("X-Real-IP")); realIP.IsValid() {
return clientIP{Addr: realIP.String(), Via: "real-ip"}
}
return fromSocket
}
func parseHostAddr(remoteAddr string) netip.Addr {
remoteAddr = strings.TrimSpace(remoteAddr)
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
remoteAddr = host
}
return parseAddr(remoteAddr)
}
func parseAddr(value string) netip.Addr {
addr, err := netip.ParseAddr(strings.TrimSpace(value))
if err != nil {
return netip.Addr{}
}
return addr.Unmap()
}
func forwardedChain(header http.Header) []netip.Addr {
var out []netip.Addr
for _, value := range header.Values("X-Forwarded-For") {
for _, part := range strings.Split(value, ",") {
if addr := parseAddr(part); addr.IsValid() {
out = append(out, addr)
}
}
}
return out
}
func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"net/http"
"net/netip"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestClientIPFrom(t *testing.T) {
trusted := config.DefaultTrustedProxyRanges()
tests := []struct {
name string
remoteAddr string
headers map[string]string
wantAddr string
wantVia string
}{
{
name: "direct public client, no proxy headers believed",
remoteAddr: "203.0.113.9:52344",
headers: map[string]string{"X-Forwarded-For": "10.0.0.9"},
wantAddr: "203.0.113.9",
wantVia: "socket",
},
{
name: "through the household proxy, real client in X-Forwarded-For",
remoteAddr: "10.0.0.2:41000",
headers: map[string]string{"X-Forwarded-For": "203.0.113.42, 10.0.0.2"},
wantAddr: "203.0.113.42",
wantVia: "forwarded",
},
{
name: "direct LAN client behind the proxy still shows its LAN address",
remoteAddr: "10.0.0.2:41000",
headers: map[string]string{"X-Forwarded-For": "10.0.0.50"},
wantAddr: "10.0.0.50",
wantVia: "forwarded",
},
{
name: "proxy sets only X-Real-IP",
remoteAddr: "192.168.1.1:8443",
headers: map[string]string{"X-Real-IP": "198.51.100.7"},
wantAddr: "198.51.100.7",
wantVia: "real-ip",
},
{
name: "trusted proxy with no forwarding headers",
remoteAddr: "127.0.0.1:5000",
wantAddr: "127.0.0.1",
wantVia: "socket",
},
{
name: "spoofed X-Real-IP from an untrusted client is ignored",
remoteAddr: "203.0.113.9:1000",
headers: map[string]string{"X-Real-IP": "10.0.0.1"},
wantAddr: "203.0.113.9",
wantVia: "socket",
},
{
name: "unparseable remote address",
remoteAddr: "garbage",
wantAddr: "unknown",
wantVia: "none",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
header := http.Header{}
for key, value := range test.headers {
header.Set(key, value)
}
got := clientIPFrom(test.remoteAddr, header, trusted)
if got.String() != test.wantAddr || got.Via != test.wantVia {
t.Fatalf("clientIPFrom = %+v, want addr %q via %q", got, test.wantAddr, test.wantVia)
}
})
}
}
func TestClientIPFromTrustsNothingWhenListEmpty(t *testing.T) {
header := http.Header{"X-Forwarded-For": {"203.0.113.1"}}
got := clientIPFrom("10.0.0.2:5000", header, []netip.Prefix{})
if got.String() != "10.0.0.2" || got.Via != "socket" {
t.Fatalf("clientIPFrom with no trusted proxies = %+v, want 10.0.0.2 via socket", got)
}
}
+9 -5
View File
@@ -89,11 +89,11 @@ func intPtr(v int) *int { return &v }
// once, here, rather than the admin console guessing at it independently and drifting from // once, here, rather than the admin console guessing at it independently and drifting from
// what a television actually understands. // what a television actually understands.
type rowTypeDefinition struct { type rowTypeDefinition struct {
Type string `json:"type"` Type string `json:"type"`
Label string `json:"label"` Label string `json:"label"`
Description string `json:"description"` Description string `json:"description"`
Component string `json:"component"` Component string `json:"component"`
DataSource string `json:"dataSource"` DataSource string `json:"dataSource"`
// Pages lists which of Home/Movies/TV this type may be placed on. A row whose type // Pages lists which of Home/Movies/TV this type may be placed on. A row whose type
// is not valid for the page it is being saved to is exactly the "unsupported row/page // is not valid for the page it is being saved to is exactly the "unsupported row/page
// combination" the visual editor exists to make impossible. // combination" the visual editor exists to make impossible.
@@ -173,6 +173,10 @@ func validateSectionDefinitions(key string, raw json.RawMessage) error {
if definition.MaxItems < 0 || definition.MaxItems > 100 { if definition.MaxItems < 0 || definition.MaxItems > 100 {
return errors.New(key + ": row " + id + " has an invalid maximum item count") return errors.New(key + ": row " + id + " has an invalid maximum item count")
} }
if definition.Layout != "" &&
definition.Layout != config.SectionLayoutPoster && definition.Layout != config.SectionLayoutThumb {
return errors.New(key + ": row " + id + " has an invalid card layout")
}
if strings.TrimSpace(definition.Component) == "" { if strings.TrimSpace(definition.Component) == "" {
return errors.New(key + ": row " + id + " needs a component") return errors.New(key + ": row " + id + " needs a component")
} }
+31
View File
@@ -163,6 +163,31 @@ func (s *Server) LibrarySyncInterval() time.Duration {
s.cfg.SyncInterval) s.cfg.SyncInterval)
} }
// homeTTL and recommendTTL are "the override, or what was deployed" for the two cache
// lifetimes an operator can reach. They cannot be switched off, so a non-positive stored
// value falls through to the deployed duration.
func (s *Server) homeTTL() time.Duration {
if seconds := s.gatewaySettings.get().HomeTTLSeconds; seconds > 0 {
return time.Duration(seconds) * time.Second
}
return s.cfg.HomeTTL
}
func (s *Server) recommendTTL() time.Duration {
if hours := s.gatewaySettings.get().RecommendTTLHours; hours > 0 {
return time.Duration(hours) * time.Hour
}
return s.cfg.RecommendTTL
}
// forYouRebuildHour is the household-local hour the daily For You rebuild is due at.
func (s *Server) forYouRebuildHour() int {
if hour := s.gatewaySettings.get().ForYouRebuildHour; hour != nil {
return *hour
}
return s.cfg.ForYouRebuildHour
}
// overrideWindow reads one of the three settings that can be switched off: a negative // overrideWindow reads one of the three settings that can be switched off: a negative
// value is off, zero is "whatever was deployed", anything else is the override in the // value is off, zero is "whatever was deployed", anything else is the override in the
// given unit. // given unit.
@@ -191,6 +216,9 @@ type deployedGatewaySettings struct {
EmbyHealthSeconds int `json:"embyHealthSeconds"` EmbyHealthSeconds int `json:"embyHealthSeconds"`
SlowRequestMillis int `json:"slowRequestMillis"` SlowRequestMillis int `json:"slowRequestMillis"`
LibrarySyncMinutes int `json:"librarySyncMinutes"` LibrarySyncMinutes int `json:"librarySyncMinutes"`
HomeTTLSeconds int `json:"homeTtlSeconds"`
RecommendTTLHours int `json:"recommendTtlHours"`
ForYouRebuildHour int `json:"forYouRebuildHour"`
} }
func (s *Server) deployedSettings() deployedGatewaySettings { func (s *Server) deployedSettings() deployedGatewaySettings {
@@ -208,6 +236,9 @@ func (s *Server) deployedSettings() deployedGatewaySettings {
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second), EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond), SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond),
LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute), LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute),
HomeTTLSeconds: int(s.cfg.HomeTTL / time.Second),
RecommendTTLHours: int(s.cfg.RecommendTTL / time.Hour),
ForYouRebuildHour: s.cfg.ForYouRebuildHour,
} }
} }
@@ -65,6 +65,36 @@ func TestNotificationDisplayDefaultsToHomeOnly(t *testing.T) {
} }
} }
func TestCacheOverridesFallBackToDeployed(t *testing.T) {
s := &Server{}
s.cfg.HomeTTL = 60 * time.Second
s.cfg.RecommendTTL = 24 * time.Hour
s.cfg.ForYouRebuildHour = 4
if got := s.homeTTL(); got != 60*time.Second {
t.Fatalf("unset home TTL should be the deployed value, got %s", got)
}
if got := s.recommendTTL(); got != 24*time.Hour {
t.Fatalf("unset recommend TTL should be the deployed value, got %s", got)
}
if got := s.forYouRebuildHour(); got != 4 {
t.Fatalf("unset rebuild hour should be the deployed value, got %d", got)
}
midnight := 0
s.gatewaySettings.set(store.GatewaySettings{
HomeTTLSeconds: 30, RecommendTTLHours: 6, ForYouRebuildHour: &midnight,
})
if got := s.homeTTL(); got != 30*time.Second {
t.Fatalf("home TTL override not applied, got %s", got)
}
if got := s.recommendTTL(); got != 6*time.Hour {
t.Fatalf("recommend TTL override not applied, got %s", got)
}
if got := s.forYouRebuildHour(); got != 0 {
t.Fatalf("a rebuild-hour override of midnight must win over the deployed 4, got %d", got)
}
}
func TestLevelNameCoversTheVocabulary(t *testing.T) { func TestLevelNameCoversTheVocabulary(t *testing.T) {
for _, level := range store.GatewayLogLevels { for _, level := range store.GatewayLogLevels {
if got := levelName(parseTestLevel(t, level)); got != level { if got := levelName(parseTestLevel(t, level)); got != level {
+27 -1
View File
@@ -135,6 +135,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
forYouRows []recommend.Row forYouRows []recommend.Row
forYouRowStale bool forYouRowStale bool
seriesPlayed map[string]time.Time seriesPlayed map[string]time.Time
surfacedReady []store.SurfacedReadyRequest
ranking rankingInputs ranking rankingInputs
rowStats []store.RowStat rowStats []store.RowStat
rowStatsOK bool rowStatsOK bool
@@ -286,6 +287,24 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
defer wg.Done() defer wg.Done()
ranking = s.rankingInputs(ctx, sess.EmbyUserID) ranking = s.rankingInputs(ctx, sess.EmbyUserID)
}() }()
// The requests this viewer has asked for that have just become watchable, to be pinned
// to the front of Continue Watching. Read beside the Emby fan-out, not after it: it is a
// function of the viewer's id alone, and the overwhelmingly common answer is none.
if continueWatching && s.store != nil {
wg.Add(1)
go func() {
defer wg.Done()
ready, err := s.store.SurfacedReadyRequests(ctx, sess.EmbyUserID)
if err != nil {
s.loggerFor(ctx).Warn("surfaced request arrivals unavailable",
"user", sess.EmbyUserID, "error", err)
return
}
mu.Lock()
surfacedReady = ready
mu.Unlock()
}()
}
if s.store != nil { if s.store != nil {
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -346,6 +365,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
if continueWatching { if continueWatching {
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow) out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
} }
// A title this viewer requested that has just arrived is pinned to the front of the row
// (or tagged in place if it is already there), so the request journey ends where they
// look for something to watch rather than on the My Requests page.
if continueWatching && len(surfacedReady) > 0 {
out.ContinueWatching = s.injectRequestReady(ctx, cred, out.ContinueWatching, surfacedReady)
}
// Recommendations are read from their own long-lived cache. A miss means this // Recommendations are read from their own long-lived cache. A miss means this
// response ships without them and a rebuild starts in the background — the home // response ships without them and a rebuild starts in the background — the home
@@ -391,6 +416,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
out.Rows = s.personalizeTitlesWith(out.Rows, ranking) out.Rows = s.personalizeTitlesWith(out.Rows, ranking)
out.Rows = personalizeRowsByTitleScores(selectPersonalizedRows(out.Rows)) out.Rows = personalizeRowsByTitleScores(selectPersonalizedRows(out.Rows))
out.Rows = deduplicateRows(out.Rows) out.Rows = deduplicateRows(out.Rows)
out.Rows = s.applyRowLayouts(ctx, out.Rows)
rank() rank()
// Ratings ride on the cards themselves. Only what is already stored is attached, so // Ratings ride on the cards themselves. Only what is already stored is attached, so
// the launcher pays one indexed read rather than a request per poster, and a card // the launcher pays one indexed read rather than a request per poster, and a card
@@ -422,7 +448,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
} }
// A partial payload is served but never cached: the next request should retry. // A partial payload is served but never cached: the next request should retry.
if !out.Partial { if !out.Partial {
if err := s.cache.Set(ctx, key, body, s.cfg.HomeTTL); err != nil { if err := s.cache.Set(ctx, key, body, s.homeTTL()); err != nil {
s.loggerFor(ctx).Warn("home cache write failed", "error", err) s.loggerFor(ctx).Warn("home cache write failed", "error", err)
} }
} }
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"context"
"encoding/json"
"strings"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
// sectionDefinitionKeys are the three configuration values that hold a page's row
// composition. A layout override can be set on any of them, and the home payload is the
// single row source for all three browse destinations on the television, so all three are
// merged into one lookup.
var sectionDefinitionKeys = []string{
"home.sectionDefinitions",
"movies.sectionDefinitions",
"tv.sectionDefinitions",
}
// rowLayoutOverrides collects every operator-pinned card shape from the section
// definitions, keyed by the definition id. An invalid or empty layout is not an override.
func rowLayoutOverrides(policy store.FeaturePolicy) map[string]string {
overrides := map[string]string{}
for _, key := range sectionDefinitionKeys {
raw, ok := policy.Values[key]
if !ok {
continue
}
var definitions []config.RemoteSectionDefinition
if json.Unmarshal(raw, &definitions) != nil {
continue
}
for _, definition := range definitions {
switch definition.Layout {
case config.SectionLayoutPoster, config.SectionLayoutThumb:
id := strings.TrimSpace(definition.ID)
if id != "" {
overrides[id] = definition.Layout
}
}
}
}
return overrides
}
// applyRowLayoutOverrides stamps the pinned card shape onto each finished row. A row is
// matched by its exact id first, then by a section id it is a child of ("for-you" pins
// "for-you:home:evening" too) — the same rule applyRemoteHomeSections uses on the
// television to rank a family of rows together. A row that already carries a layout (none
// do today) is left alone. Pure, so it can be pinned by a test.
func applyRowLayoutOverrides(rows []recommend.Row, overrides map[string]string) []recommend.Row {
if len(overrides) == 0 {
return rows
}
for index := range rows {
if rows[index].Layout != "" {
continue
}
if layout, ok := overrides[rows[index].ID]; ok {
rows[index].Layout = layout
continue
}
for section, layout := range overrides {
if strings.HasPrefix(rows[index].ID, section+":") {
rows[index].Layout = layout
break
}
}
}
return rows
}
func (s *Server) applyRowLayouts(ctx context.Context, rows []recommend.Row) []recommend.Row {
return applyRowLayoutOverrides(rows, rowLayoutOverrides(s.currentFeaturePolicy(ctx)))
}
@@ -0,0 +1,61 @@
package api
import (
"encoding/json"
"testing"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestRowLayoutOverridesReadsEveryPage(t *testing.T) {
policy := store.FeaturePolicy{Values: map[string]json.RawMessage{
"home.sectionDefinitions": json.RawMessage(`[
{"id":"favorites","type":"favorites","title":"Favourites","component":"mediaRow","layout":"thumb"},
{"id":"continue","type":"continueWatching","title":"Continue","component":"mediaRow"},
{"id":"broken","type":"custom","title":"x","component":"mediaRow","layout":"sideways"}
]`),
"movies.sectionDefinitions": json.RawMessage(`[
{"id":"library","type":"library","title":"Library","component":"mediaGrid","layout":"poster"}
]`),
}}
got := rowLayoutOverrides(policy)
if got["favorites"] != "thumb" {
t.Fatalf("favorites layout = %q, want thumb", got["favorites"])
}
if got["library"] != "poster" {
t.Fatalf("library layout = %q, want poster", got["library"])
}
if _, ok := got["continue"]; ok {
t.Fatalf("a row with no layout must not appear as an override")
}
if _, ok := got["broken"]; ok {
t.Fatalf("an invalid layout must not appear as an override")
}
}
func TestApplyRowLayoutOverridesMatchesExactAndChildRows(t *testing.T) {
rows := []recommend.Row{
{ID: "favorites"},
{ID: "for-you"},
{ID: "for-you:home:evening"},
{ID: "latest-movies", Layout: "poster"},
}
out := applyRowLayoutOverrides(rows, map[string]string{"favorites": "thumb", "for-you": "poster"})
if out[0].Layout != "thumb" {
t.Fatalf("exact match not applied: %q", out[0].Layout)
}
if out[1].Layout != "poster" || out[2].Layout != "poster" {
t.Fatalf("a section id must pin its child rows too: %q / %q", out[1].Layout, out[2].Layout)
}
if out[3].Layout != "poster" {
t.Fatalf("a row that already carries a layout must be left alone: %q", out[3].Layout)
}
}
func TestApplyRowLayoutOverridesNoOpWithoutOverrides(t *testing.T) {
rows := []recommend.Row{{ID: "favorites"}}
if applyRowLayoutOverrides(rows, nil)[0].Layout != "" {
t.Fatalf("no overrides must leave rows untouched")
}
}
+1 -1
View File
@@ -283,7 +283,7 @@ func (s *Server) runForYouRebuild(ctx context.Context) (scheduler.Outcome, error
if err != nil { if err != nil {
return scheduler.Outcome{}, fmt.Errorf("read For You rebuild state: %w", err) return scheduler.Outcome{}, fmt.Errorf("read For You rebuild state: %w", err)
} }
if !forYouRebuildDue(state.LastRebuildAt, now, s.cfg.ForYouRebuildHour) { if !forYouRebuildDue(state.LastRebuildAt, now, s.forYouRebuildHour()) {
return scheduler.Outcome{}, nil return scheduler.Outcome{}, nil
} }
result, err := s.forYou.RebuildAll(ctx, true) result, err := s.forYou.RebuildAll(ctx, true)
-26
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"strings" "strings"
@@ -237,28 +236,3 @@ func isPlaybackItemPath(path string) bool {
} }
return false return false
} }
// requestClientIP is the viewer-facing address recorded for trailer playback. The first
// Forwarded address is the original client when the gateway is behind its normal reverse
// proxy; direct deployments fall back to RemoteAddr. This value is for operational logs,
// never authentication or access control.
func requestClientIP(r *http.Request) string {
for _, value := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") {
if ip := net.ParseIP(strings.TrimSpace(value)); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil {
return ip.String()
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err == nil {
if ip := net.ParseIP(host); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.RemoteAddr)); ip != nil {
return ip.String()
}
return "unknown"
}
-9
View File
@@ -73,15 +73,6 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
} }
} }
func TestRequestClientIPPrefersOriginalForwardedAddress(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items/42/trailers/report", nil)
request.RemoteAddr = "10.0.0.2:41234"
request.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.2")
if got := requestClientIP(request); got != "203.0.113.9" {
t.Fatalf("client ip = %q", got)
}
}
func TestIdentifyNamesTheViewerAndTelevision(t *testing.T) { func TestIdentifyNamesTheViewerAndTelevision(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/v1/home", nil) request := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
request.Header.Set("X-Memby-Version", "0.1.60") request.Header.Set("X-Memby-Version", "0.1.60")
+7 -1
View File
@@ -31,7 +31,13 @@ func (s *Server) recordLogin(r *http.Request, event store.LoginEvent) {
return return
} }
if event.IPAddress == "" { if event.IPAddress == "" {
event.IPAddress = requestClientIP(r) origin := s.resolveClientIP(r)
event.IPAddress = origin.String()
// One line per sign-in attempt, so an operator can see which header the address
// came from when a reverse proxy is in front of the gateway. Debug keeps it out
// of the ordinary log while still being there when the question is asked.
s.log.Debug("resolved login client ip",
"ip", origin.String(), "via", origin.Via, "device_id", event.DeviceID)
} }
if event.ClientVersion == "" { if event.ClientVersion == "" {
event.ClientVersion = clientVersion(r) event.ClientVersion = clientVersion(r)
+19
View File
@@ -248,6 +248,25 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond), "negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
) )
// Starting to watch it retires the REQUEST READY pin — the request journey is complete,
// and from here the card is ordinary Continue Watching. Best-effort and detached: the
// viewer is about to watch something regardless of whether this write lands, and the
// age-out sweep is the backstop. itemID is the id the card opened (the series id for a
// series request); target.ID is the resolved movie or episode.
if s.store != nil {
detached := context.WithoutCancel(ctx)
go func() {
cleared, err := s.store.ClearRequestReadySurfaced(detached, sess.EmbyUserID, itemID, target.ID)
if err != nil {
s.loggerFor(detached).Warn("request ready pin not cleared", "error", err)
return
}
if cleared > 0 {
s.invalidateHomeFor(detached, sess.EmbyUserID)
}
}()
}
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode := nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
s.nextAiringFieldsFor(ctx, target) s.nextAiringFieldsFor(ctx, target)
writeJSON(w, http.StatusOK, playbackResponse{ writeJSON(w, http.StatusOK, playbackResponse{
+1 -1
View File
@@ -63,7 +63,7 @@ func (s *Server) buildRecommendations(ctx context.Context, sess store.Session) (
// An empty result is cached too: a user with no history should not trigger a full // An empty result is cached too: a user with no history should not trigger a full
// rebuild on every single home load. // rebuild on every single home load.
if raw, err := json.Marshal(rows); err == nil { if raw, err := json.Marshal(rows); err == nil {
if err := s.cache.Set(ctx, cache.RecommendationsKey(sess.EmbyUserID), raw, s.cfg.RecommendTTL); err != nil { if err := s.cache.Set(ctx, cache.RecommendationsKey(sess.EmbyUserID), raw, s.recommendTTL()); err != nil {
s.loggerFor(ctx).Warn("recommendation cache write failed", "error", err) s.loggerFor(ctx).Warn("recommendation cache write failed", "error", err)
} }
} }
+25
View File
@@ -70,6 +70,15 @@ func (s *Server) runRequestReadyScan(ctx context.Context) (string, error) {
return "", nil return "", nil
} }
// Retire pins nobody has acted on in a fortnight before looking at anything else, so a
// stale REQUEST READY tag is gone from Home within a sweep of its deadline rather than
// leaning on SurfacedReadyRequests' own window clause to keep hiding it.
if expired, err := s.store.ExpireStaleReadyRequests(ctx); err != nil {
s.loggerFor(ctx).Warn("stale request pins not expired", "error", err)
} else {
s.invalidateHomeFor(ctx, expired...)
}
owned, err := s.store.AllMediaRequests(ctx, store.MediaRequestSweepLimit) owned, err := s.store.AllMediaRequests(ctx, store.MediaRequestSweepLimit)
if err != nil { if err != nil {
return "", fmt.Errorf("request arrivals: read requests: %w", err) return "", fmt.Errorf("request arrivals: read requests: %w", err)
@@ -112,6 +121,22 @@ func (s *Server) runRequestReadyScan(ctx context.Context) (string, error) {
if s.announceRequestReady(ctx, req, card) { if s.announceRequestReady(ctx, req, card) {
announced++ announced++
} }
// Pin it to the front of the requester's Continue Watching row until they play
// it. Only possible once the library has an item id — a title the *arr reports a
// file for but Emby has not imported yet is still worth the notification above,
// but there is nothing for a card to open. MarkRequestReadySurfaced's own guard
// makes the repeat a no-op, so this is safe to call on every observed transition.
if card.EmbyItemID != "" {
if err := s.store.MarkRequestReadySurfaced(
ctx, req.UserID, req.MediaType, req.ForeignID, card.EmbyItemID,
); err != nil {
s.loggerFor(ctx).Warn("request ready pin not recorded",
"user_id", req.UserID, "type", req.MediaType,
"foreign_id", req.ForeignID, "error", err)
} else {
s.invalidateHomeFor(ctx, req.UserID)
}
}
} }
if err := s.store.SetMediaRequestStatus( if err := s.store.SetMediaRequestStatus(
ctx, req.UserID, req.MediaType, req.ForeignID, card.Status, ctx, req.UserID, req.MediaType, req.ForeignID, card.Status,
+179
View File
@@ -0,0 +1,179 @@
package api
import (
"context"
"encoding/json"
"net/url"
"strings"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Pinning a freshly arrived request to the front of Continue Watching.
//
// The notification (requests_ready.go) tells the person who asked that their title has
// landed; this is the other half — putting it where they actually look for something to
// watch. An unwatched film never appears in Continue Watching on its own and a new series
// only shows up if they go hunting, so without this the request journey ends a step short of
// its payoff.
//
// It is deliberately cheap. The only new work on the Home path is one indexed Postgres read
// (SurfacedReadyRequests), and — only when that read is non-empty, which is rare — one
// batched Emby item lookup for the titles not already in the row. No Radarr or Sonarr call
// is ever made here.
const (
// requestReadyField and requestReadyLabelField are what the television reads. The label
// is the server's wording (the MembyAirLabel precedent), so renaming it needs no app
// release; a client that predates the field falls back to its own constant.
requestReadyField = "MembyRequestReady"
requestReadyLabelField = "MembyRequestReadyLabel"
// requestReadyLabel is the tag drawn on the card.
requestReadyLabel = "REQUEST READY"
)
// injectRequestReady tags or prepends the viewer's still-pinned request arrivals in the
// merged Continue Watching list.
//
// - A surfaced title already in the row (the film itself, or the series an episode belongs
// to) is tagged in place — no second card.
// - One not in the row is fetched from Emby and prepended, newest arrival first.
//
// Any failure degrades to the list untouched: the person was already told by notification,
// and a missing pin is a smaller disappointment than a broken launcher.
func (s *Server) injectRequestReady(
ctx context.Context,
cred emby.Credentials,
items []json.RawMessage,
surfaced []store.SurfacedReadyRequest,
) []json.RawMessage {
if len(surfaced) == 0 {
return items
}
missing := missingRequestReady(items, surfaced)
if len(missing) == 0 {
return mergeRequestReady(items, surfaced, nil)
}
ids := make([]string, 0, len(missing))
for _, req := range missing {
ids = append(ids, req.ItemID)
}
fetched, err := s.emby.Items(ctx, cred, rowParams(url.Values{
"Ids": {strings.Join(ids, ",")},
"Recursive": {"true"},
}, fieldsContinue))
if err != nil {
s.loggerFor(ctx).Warn("request ready items unavailable", "error", err)
// The titles already in the row are still worth tagging even when the fetch for the
// rest fails.
return mergeRequestReady(items, surfaced, nil)
}
byID := make(map[string]json.RawMessage, len(fetched.Items))
for _, raw := range fetched.Items {
if id, _, _, _ := continueItemFields(raw, nil); id != "" {
byID[id] = raw
}
}
return mergeRequestReady(items, surfaced, byID)
}
// requestReadyIndex maps every item and series id already in the row to its position.
func requestReadyIndex(items []json.RawMessage) map[string]int {
present := make(map[string]int, len(items))
for index, raw := range items {
id, seriesID, _, _ := continueItemFields(raw, nil)
if id != "" {
present[id] = index
}
if seriesID != "" {
if _, ok := present[seriesID]; !ok {
present[seriesID] = index
}
}
}
return present
}
// missingRequestReady is the surfaced arrivals whose title is not already in the row.
func missingRequestReady(
items []json.RawMessage, surfaced []store.SurfacedReadyRequest,
) []store.SurfacedReadyRequest {
present := requestReadyIndex(items)
missing := make([]store.SurfacedReadyRequest, 0, len(surfaced))
for _, req := range surfaced {
if _, ok := present[req.ItemID]; !ok {
missing = append(missing, req)
}
}
return missing
}
// mergeRequestReady is the pure half: stamp the arrivals already in the row in place, and
// prepend the rest from `fetched` (newest first), stamped. An arrival with no fetched item
// is simply left out — the notification already carried the news.
func mergeRequestReady(
items []json.RawMessage,
surfaced []store.SurfacedReadyRequest,
fetched map[string]json.RawMessage,
) []json.RawMessage {
out := make([]json.RawMessage, len(items))
copy(out, items)
present := requestReadyIndex(out)
var missing []store.SurfacedReadyRequest
for _, req := range surfaced {
if index, ok := present[req.ItemID]; ok {
out[index] = stampRequestReady(out[index])
continue
}
missing = append(missing, req)
}
// surfaced is ordered newest first, so this block keeps that order and the whole block
// goes in front of the existing row.
prepend := make([]json.RawMessage, 0, len(missing))
for _, req := range missing {
if raw, ok := fetched[req.ItemID]; ok {
prepend = append(prepend, stampRequestReady(raw))
}
}
if len(prepend) == 0 {
return out
}
return append(prepend, out...)
}
// stampRequestReady sets the two tag fields on one item's JSON, leaving everything else
// alone. It mirrors decorateItemRatings' approach — decode to a map, set, re-encode — so a
// field the row does not model is preserved.
func stampRequestReady(raw json.RawMessage) json.RawMessage {
var item map[string]any
if err := json.Unmarshal(raw, &item); err != nil || item == nil {
return raw
}
item[requestReadyField] = true
item[requestReadyLabelField] = requestReadyLabel
if stamped, err := json.Marshal(item); err == nil {
return stamped
}
return raw
}
// invalidateHomeFor drops the cached Home payload for each named viewer, best-effort. Used
// wherever a request pin is created or retired outside a Home request itself — the ready
// sweep and the playback handler.
func (s *Server) invalidateHomeFor(ctx context.Context, userIDs ...string) {
if s.cache == nil {
return
}
for _, userID := range userIDs {
if userID == "" {
continue
}
if err := s.cache.InvalidateUser(ctx, userID); err != nil {
s.loggerFor(ctx).Warn("home cache not invalidated", "user_id", userID, "error", err)
}
}
}
@@ -0,0 +1,119 @@
package api
import (
"encoding/json"
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
)
func readyReq(itemID, mediaType string) store.SurfacedReadyRequest {
return store.SurfacedReadyRequest{MediaType: mediaType, ItemID: itemID, Title: itemID}
}
func isStamped(t *testing.T, raw json.RawMessage) bool {
t.Helper()
var item struct {
Ready bool `json:"MembyRequestReady"`
Label string `json:"MembyRequestReadyLabel"`
}
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return item.Ready && item.Label == requestReadyLabel
}
// The overwhelmingly common case: nobody is waiting on anything, so the row is untouched.
func TestMergeRequestReadyNoArrivalsIsANoOp(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
got := mergeRequestReady(items, nil, nil)
if !equalIDs(mergedIDs(t, got), []string{"film"}) {
t.Fatalf("ids = %v", mergedIDs(t, got))
}
if isStamped(t, got[0]) {
t.Fatal("item stamped with no arrivals")
}
}
// An arrival not in the row is prepended and tagged.
func TestMergeRequestReadyPrependsMissingTitle(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
surfaced := []store.SurfacedReadyRequest{readyReq("new-movie", "movie")}
fetched := map[string]json.RawMessage{
"new-movie": continueRaw(t, map[string]any{"Id": "new-movie", "Type": "Movie"}),
}
got := mergeRequestReady(items, surfaced, fetched)
if want := []string{"new-movie", "film"}; !equalIDs(mergedIDs(t, got), want) {
t.Fatalf("ids = %v, want %v", mergedIDs(t, got), want)
}
if !isStamped(t, got[0]) {
t.Fatal("prepended card not stamped")
}
if isStamped(t, got[1]) {
t.Fatal("existing card wrongly stamped")
}
}
// Newest arrival first when several are prepended (surfaced is ordered newest first).
func TestMergeRequestReadyKeepsNewestArrivalFirst(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
surfaced := []store.SurfacedReadyRequest{readyReq("newer", "movie"), readyReq("older", "movie")}
fetched := map[string]json.RawMessage{
"newer": continueRaw(t, map[string]any{"Id": "newer", "Type": "Movie"}),
"older": continueRaw(t, map[string]any{"Id": "older", "Type": "Movie"}),
}
got := mergedIDs(t, mergeRequestReady(items, surfaced, fetched))
if want := []string{"newer", "older", "film"}; !equalIDs(got, want) {
t.Fatalf("ids = %v, want %v", got, want)
}
}
// A surfaced title already in Continue Watching — by its own id, or as the series an
// episode belongs to — is tagged in place, never duplicated.
func TestMergeRequestReadyTagsExistingCardInPlace(t *testing.T) {
items := []json.RawMessage{
resumeItem(t, "s1e3", "severance", "2026-08-06T19:00:00Z"),
resumeItem(t, "the-film", "", "2026-08-01T20:00:00Z"),
}
surfaced := []store.SurfacedReadyRequest{
readyReq("severance", "series"),
readyReq("the-film", "movie"),
}
got := mergeRequestReady(items, surfaced, nil)
if want := []string{"s1e3", "the-film"}; !equalIDs(mergedIDs(t, got), want) {
t.Fatalf("ids = %v, want %v", mergedIDs(t, got), want)
}
if !isStamped(t, got[0]) || !isStamped(t, got[1]) {
t.Fatal("existing cards not tagged in place")
}
}
// A fetch that returned nothing for an arrival degrades to leaving it out rather than
// inserting a blank card.
func TestMergeRequestReadyDropsUnfetchableArrival(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
surfaced := []store.SurfacedReadyRequest{readyReq("gone", "movie")}
got := mergeRequestReady(items, surfaced, map[string]json.RawMessage{})
if !equalIDs(mergedIDs(t, got), []string{"film"}) {
t.Fatalf("ids = %v", mergedIDs(t, got))
}
}
func TestStampRequestReadyPreservesOtherFields(t *testing.T) {
raw := continueRaw(t, map[string]any{"Id": "x", "Name": "Keep me", "Type": "Movie"})
stamped := stampRequestReady(raw)
var item struct {
Name string `json:"Name"`
Ready bool `json:"MembyRequestReady"`
}
if err := json.Unmarshal(stamped, &item); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if item.Name != "Keep me" || !item.Ready {
t.Fatalf("stamp lost fields: %+v", item)
}
}
+3 -1
View File
@@ -155,11 +155,13 @@ func (s *Server) handleTrailerReport(w http.ResponseWriter, r *http.Request, ses
writeError(w, http.StatusBadRequest, "invalid trailer report") writeError(w, http.StatusBadRequest, "invalid trailer report")
return return
} }
origin := s.resolveClientIP(r)
fields := []any{ fields := []any{
"item_id", itemID, "item_id", itemID,
"provider", report.Provider, "provider", report.Provider,
"candidate", report.CandidateID, "candidate", report.CandidateID,
"source_ip", requestClientIP(r), "source_ip", origin.String(),
"source_ip_via", origin.Via,
} }
if reason := strings.TrimSpace(report.Reason); reason != "" { if reason := strings.TrimSpace(report.Reason); reason != "" {
fields = append(fields, "reason", reason) fields = append(fields, "reason", reason)
+4
View File
@@ -66,6 +66,9 @@ func TestTrailerStartedLogUsesOriginalClientIP(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/report", request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/report",
bytes.NewBufferString(`{"candidateId":"youtube-1","provider":"youtube","phase":"started"}`)) bytes.NewBufferString(`{"candidateId":"youtube-1","provider":"youtube","phase":"started"}`))
request.SetPathValue("id", "film-1") request.SetPathValue("id", "film-1")
// The request reaches the gateway through the household reverse proxy, whose LAN
// address is trusted by default, so its X-Forwarded-For is believed.
request.RemoteAddr = "10.0.0.2:44321"
request.Header.Set("X-Forwarded-For", "203.0.113.42, 10.0.0.2") request.Header.Set("X-Forwarded-For", "203.0.113.42, 10.0.0.2")
request, _ = withRequestIdentity(request) request, _ = withRequestIdentity(request)
identify(request.Context(), store.Session{Username: "viewer", DeviceName: "Lounge TV"}) identify(request.Context(), store.Session{Username: "viewer", DeviceName: "Lounge TV"})
@@ -76,6 +79,7 @@ func TestTrailerStartedLogUsesOriginalClientIP(t *testing.T) {
} }
page := events.Events(0, 10) page := events.Events(0, 10)
if len(page.Events) != 1 || page.Events[0].Attributes["source_ip"] != "203.0.113.42" || if len(page.Events) != 1 || page.Events[0].Attributes["source_ip"] != "203.0.113.42" ||
page.Events[0].Attributes["source_ip_via"] != "forwarded" ||
page.Events[0].Message != "trailer playback started" { page.Events[0].Message != "trailer playback started" {
t.Fatalf("unexpected event: %+v", page.Events) t.Fatalf("unexpected event: %+v", page.Events)
} }
+86 -16
View File
@@ -59,6 +59,53 @@ func previousMonth(now time.Time, location *time.Location) (from, to time.Time,
return from, to, from.Format("2006-01") return from, to, from.Format("2006-01")
} }
// priorWeekToDate is last week measured to the same point this week has reached: the local
// Monday a week ago, up to the same weekday and wall-clock time as now. AddDate keeps the
// wall clock across a daylight-saving change, so "the same time last week" stays the same
// time rather than drifting an hour — the reason weekStartIn works in dates too.
func priorWeekToDate(now time.Time, location *time.Location) (from, to time.Time) {
from = weekStartIn(now, location).AddDate(0, 0, -7)
to = now.In(location).AddDate(0, 0, -7)
return from, to
}
// watchWeekTrendFloor is how far this week's figure can sit from last week's before the
// difference is worth showing. The band is the larger of this and a tenth of last week —
// an absolute floor so a light household is not told "down" over a few minutes, and a
// proportion so a heavy one is not told "steady" over an hour.
const watchWeekTrendFloor = 5 * time.Minute
// watchWeekTrend is this week-to-date against the same point last week: a direction the
// console prints beside the figure, plus the numbers behind it. Direction is "up", "down",
// "steady" or "none" — the last when there is nothing to compare, the runtimestats
// DirectionUnknown stance.
type watchWeekTrend struct {
Direction string `json:"direction"`
PriorMs int64 `json:"priorMs"`
DeltaMs int64 `json:"deltaMs"`
}
// weekOverWeek classifies this week's watch time against last week's to-date figure. Pure,
// so the console and its tests agree on where "steady" ends.
func weekOverWeek(currentMs, priorMs int64) watchWeekTrend {
if currentMs <= 0 && priorMs <= 0 {
return watchWeekTrend{Direction: "none"}
}
delta := currentMs - priorMs
band := priorMs / 10
if floor := watchWeekTrendFloor.Milliseconds(); band < floor {
band = floor
}
direction := "steady"
switch {
case delta > band:
direction = "up"
case delta < -band:
direction = "down"
}
return watchWeekTrend{Direction: direction, PriorMs: priorMs, DeltaMs: delta}
}
// weekKey names a week the way the source key needs it — a stable string that changes exactly // weekKey names a week the way the source key needs it — a stable string that changes exactly
// once per week. ISO year-and-week, so the last days of December cannot collide with the // once per week. ISO year-and-week, so the last days of December cannot collide with the
// first days of January. // first days of January.
@@ -143,14 +190,18 @@ func monthlyDigestMessage(month time.Duration, monthName, topTitle string) strin
// different facts, and a console that showed both as a row of zeroes would leave an operator // different facts, and a console that showed both as a row of zeroes would leave an operator
// investigating a viewer rather than an integration. // investigating a viewer rather than an integration.
type watchTimeSummary struct { type watchTimeSummary struct {
Matched bool `json:"matched"` Matched bool `json:"matched"`
Username string `json:"tracearrUsername,omitempty"` Username string `json:"tracearrUsername,omitempty"`
WeekMs int64 `json:"weekMs"` WeekMs int64 `json:"weekMs"`
MonthMs int64 `json:"monthMs"` // WeekTrend compares WeekMs with the same point last week, so the console can show
TotalMs int64 `json:"totalMs"` // whether a person's viewing is up or down without the operator holding last week's
WeekSessions int `json:"weekSessions"` // figure in their head.
MonthSessions int `json:"monthSessions"` WeekTrend watchWeekTrend `json:"weekTrend"`
LastWatchedAt *time.Time `json:"lastWatchedAt,omitempty"` MonthMs int64 `json:"monthMs"`
TotalMs int64 `json:"totalMs"`
WeekSessions int `json:"weekSessions"`
MonthSessions int `json:"monthSessions"`
LastWatchedAt *time.Time `json:"lastWatchedAt,omitempty"`
} }
// tracearrEnabled is whether there is anything to read at all. Every watch-time reader checks // tracearrEnabled is whether there is anything to read at all. Every watch-time reader checks
@@ -220,16 +271,16 @@ type watchTimeAccount struct {
func (s *Server) watchTimeForAccounts( func (s *Server) watchTimeForAccounts(
ctx context.Context, ctx context.Context,
accounts []watchTimeAccount, accounts []watchTimeAccount,
) map[string]store.WatchTimeTotals { ) (totals map[string]store.WatchTimeTotals, priorWeekMs map[string]int64) {
if !s.tracearrEnabled() || len(accounts) == 0 { if !s.tracearrEnabled() || len(accounts) == 0 {
return nil return nil, nil
} }
location := s.householdLocation() location := s.householdLocation()
now := time.Now() now := time.Now()
totals, err := s.store.TracearrWatchTime(ctx, weekStartIn(now, location), monthStartIn(now, location)) rows, err := s.store.TracearrWatchTime(ctx, weekStartIn(now, location), monthStartIn(now, location))
if err != nil { if err != nil {
s.loggerFor(ctx).Warn("watch time read failed", "error", err) s.loggerFor(ctx).Warn("watch time read failed", "error", err)
return nil return nil, nil
} }
identities, err := s.store.TracearrIdentities(ctx) identities, err := s.store.TracearrIdentities(ctx)
if err != nil { if err != nil {
@@ -238,18 +289,37 @@ func (s *Server) watchTimeForAccounts(
s.loggerFor(ctx).Warn("Tracearr identity map unavailable", "error", err) s.loggerFor(ctx).Warn("Tracearr identity map unavailable", "error", err)
identities = map[string]store.RecommendationIdentity{} identities = map[string]store.RecommendationIdentity{}
} }
return attributeWatchTime(accounts, totals, identities) attributed := attributeWatchTime(accounts, rows, identities)
// Last week to the same point, attributed the same two ways, so the console can say
// whether viewing is up or down. A failure here costs the arrow, never the figures.
priorWeek := map[string]int64{}
priorFrom, priorTo := priorWeekToDate(now, location)
windows, err := s.store.TracearrWatchTimeRange(ctx, priorFrom, priorTo)
if err != nil {
s.loggerFor(ctx).Warn("prior-week watch time read failed", "error", err)
} else {
byID, byName := indexWatchTimeRanges(windows)
for _, account := range accounts {
window := lookupWatchTimeRange(byID, byName, identities[account.ID], account.Username)
priorWeek[account.ID] = window.Ms
}
}
return attributed, priorWeek
} }
// summariseWatchTime turns the store's row into what the console reads, including the case // summariseWatchTime turns the store's row into what the console reads, including the case
// where there is no row. // where there is no row. priorWeekMs is last week's viewing to the same point, for the
func summariseWatchTime(totals store.WatchTimeTotals, matched bool) watchTimeSummary { // week-over-week arrow — zero when there is nothing recorded, which weekOverWeek reads as
// "no change to show" rather than as a fall to nothing.
func summariseWatchTime(totals store.WatchTimeTotals, matched bool, priorWeekMs int64) watchTimeSummary {
if !matched { if !matched {
return watchTimeSummary{} return watchTimeSummary{}
} }
return watchTimeSummary{ return watchTimeSummary{
Matched: true, Username: totals.Username, Matched: true, Username: totals.Username,
WeekMs: totals.WeekMs, MonthMs: totals.MonthMs, TotalMs: totals.TotalMs, WeekMs: totals.WeekMs, WeekTrend: weekOverWeek(totals.WeekMs, priorWeekMs),
MonthMs: totals.MonthMs, TotalMs: totals.TotalMs,
WeekSessions: totals.WeekSessions, MonthSessions: totals.MonthSessions, WeekSessions: totals.WeekSessions, MonthSessions: totals.MonthSessions,
LastWatchedAt: totals.LastWatchedAt, LastWatchedAt: totals.LastWatchedAt,
} }
+42 -1
View File
@@ -185,6 +185,47 @@ func TestWatchTimeIsAttributedByRecordedIdentityBeforeName(t *testing.T) {
} }
} }
func TestPriorWeekToDateMatchesThePointThisWeekHasReached(t *testing.T) {
location := auckland(t)
// Wednesday 19 August 2026, mid-evening.
now := time.Date(2026, 8, 19, 21, 15, 0, 0, location)
from, to := priorWeekToDate(now, location)
if got, want := from.Format("2006-01-02 15:04"), "2026-08-10 00:00"; got != want {
t.Fatalf("prior week from = %s, want %s", got, want)
}
if got, want := to.Format("2006-01-02 15:04"), "2026-08-12 21:15"; got != want {
t.Fatalf("prior week to = %s, want %s (same weekday and time, a week back)", got, want)
}
}
func TestWeekOverWeek(t *testing.T) {
minute := int64(60 * 1000)
tests := []struct {
name string
current, prior int64
wantDir string
}{
{"nothing either week", 0, 0, "none"},
{"first watching this week", 90 * minute, 0, "up"},
{"stopped watching", 0, 90 * minute, "down"},
{"a few minutes more is still steady", 63 * minute, 60 * minute, "steady"},
{"well up on last week", 180 * minute, 60 * minute, "up"},
{"well down on last week", 20 * minute, 120 * minute, "down"},
{"identical", 45 * minute, 45 * minute, "steady"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := weekOverWeek(test.current, test.prior)
if got.Direction != test.wantDir {
t.Fatalf("weekOverWeek(%d, %d) = %q, want %q", test.current, test.prior, got.Direction, test.wantDir)
}
if got.Direction != "none" && got.DeltaMs != test.current-test.prior {
t.Fatalf("delta = %d, want %d", got.DeltaMs, test.current-test.prior)
}
})
}
}
func TestWatchTimeFallsBackToACaseInsensitiveName(t *testing.T) { func TestWatchTimeFallsBackToACaseInsensitiveName(t *testing.T) {
accounts := []watchTimeAccount{{ID: "emby-1", Username: "Matt"}} accounts := []watchTimeAccount{{ID: "emby-1", Username: "Matt"}}
totals := []store.WatchTimeTotals{{Username: "matt", WeekMs: 42}} totals := []store.WatchTimeTotals{{Username: "matt", WeekMs: 42}}
@@ -202,7 +243,7 @@ func TestAnUnmatchedAccountIsAbsentRatherThanZero(t *testing.T) {
if _, ok := got["emby-9"]; ok { if _, ok := got["emby-9"]; ok {
t.Fatal("an unmatched account was attributed watch time") t.Fatal("an unmatched account was attributed watch time")
} }
if summary := summariseWatchTime(store.WatchTimeTotals{}, false); summary.Matched { if summary := summariseWatchTime(store.WatchTimeTotals{}, false, 0); summary.Matched {
t.Fatal("an unmatched summary claimed a match") t.Fatal("an unmatched summary claimed a match")
} }
} }
+72
View File
@@ -4,6 +4,7 @@ package config
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/netip"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -85,6 +86,17 @@ type Config struct {
UpstreamTimeout time.Duration UpstreamTimeout time.Duration
// TrustedProxies are the reverse proxies whose X-Forwarded-For and X-Real-IP
// headers the gateway will believe when working out where a request originated.
// A forwarded header is only honoured when the immediate peer is one of these, so
// a client reaching the gateway directly cannot spoof its address by setting one.
//
// Empty (MEMBY_TRUSTED_PROXIES unset) trusts loopback and the private/unique-local
// ranges — every reverse proxy a home deployment puts in front of Memby sits in one
// of those. Set it to a comma-separated list of addresses or CIDR ranges to narrow
// or widen that, or to "none" to trust no proxy at all.
TrustedProxies []netip.Prefix
// SlowRequestThreshold is how long a request has to take before its log line // SlowRequestThreshold is how long a request has to take before its log line
// carries a stage breakdown. Fast requests deliberately carry none: it is the one // carries a stage breakdown. Fast requests deliberately carry none: it is the one
// field on the line that varies in width, and a column of them on every /v1/status // field on the line that varies in width, and a column of them on every /v1/status
@@ -225,6 +237,10 @@ func Load() (Config, error) {
if err != nil { if err != nil {
return Config{}, err return Config{}, err
} }
trusted, err := trustedProxies(os.Getenv("MEMBY_TRUSTED_PROXIES"))
if err != nil {
return Config{}, fmt.Errorf("MEMBY_TRUSTED_PROXIES: %w", err)
}
c := Config{ c := Config{
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"), ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"), EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
@@ -261,6 +277,7 @@ func Load() (Config, error) {
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")), SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour), AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second), UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
TrustedProxies: trusted,
SlowRequestThreshold: duration("MEMBY_SLOW_REQUEST_THRESHOLD", 500*time.Millisecond), SlowRequestThreshold: duration("MEMBY_SLOW_REQUEST_THRESHOLD", 500*time.Millisecond),
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second), EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"), SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
@@ -408,6 +425,61 @@ func duration(key string, fallback time.Duration) time.Duration {
return fallback return fallback
} }
// DefaultTrustedProxyRanges is what MEMBY_TRUSTED_PROXIES falls back to: loopback and
// the private and unique-local ranges. It is exported so the request layer can use the
// same set when it is handed a nil list.
func DefaultTrustedProxyRanges() []netip.Prefix {
return parsePrefixes(
"127.0.0.0/8", "::1/128",
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16", "fe80::/10", "fc00::/7",
)
}
func parsePrefixes(values ...string) []netip.Prefix {
out := make([]netip.Prefix, 0, len(values))
for _, value := range values {
if prefix, err := netip.ParsePrefix(value); err == nil {
out = append(out, prefix.Masked())
}
}
return out
}
// trustedProxies reads the MEMBY_TRUSTED_PROXIES list. Blank means the defaults, the
// literal "none" means an empty (but non-nil) set, and every other token is an address
// or a CIDR range — with "private" as a shorthand for the default ranges.
func trustedProxies(raw string) ([]netip.Prefix, error) {
raw = strings.TrimSpace(raw)
switch {
case raw == "":
return DefaultTrustedProxyRanges(), nil
case strings.EqualFold(raw, "none"):
return []netip.Prefix{}, nil
}
out := []netip.Prefix{}
for _, token := range strings.Split(raw, ",") {
token = strings.TrimSpace(token)
if token == "" {
continue
}
if strings.EqualFold(token, "private") {
out = append(out, DefaultTrustedProxyRanges()...)
continue
}
if prefix, err := netip.ParsePrefix(token); err == nil {
out = append(out, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(token)
if err != nil {
return nil, fmt.Errorf("%q is not an IP address or CIDR range", token)
}
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
return out, nil
}
func integer(key string, fallback int) int { func integer(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key)) raw := strings.TrimSpace(os.Getenv(key))
if raw == "" { if raw == "" {
+52
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"net/netip"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -89,6 +90,57 @@ func TestForYouRebuildHourIsValidated(t *testing.T) {
} }
} }
func TestTrustedProxiesDefaultToLoopbackAndPrivateRanges(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_TRUSTED_PROXIES", "")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
trusts := func(ip string) bool {
addr := netip.MustParseAddr(ip)
for _, prefix := range cfg.TrustedProxies {
if prefix.Contains(addr) {
return true
}
}
return false
}
for _, want := range []string{"127.0.0.1", "10.0.0.2", "192.168.1.5"} {
if !trusts(want) {
t.Fatalf("%s should be trusted by default", want)
}
}
if trusts("203.0.113.9") {
t.Fatal("a public address must not be trusted by default")
}
}
func TestTrustedProxiesNoneClearsTheList(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_TRUSTED_PROXIES", "none")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.TrustedProxies == nil || len(cfg.TrustedProxies) != 0 {
t.Fatalf("trusted proxies = %v, want an empty non-nil list", cfg.TrustedProxies)
}
}
func TestTrustedProxiesRejectGarbage(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_TRUSTED_PROXIES", "10.0.0.0/8, not-an-address")
if _, err := Load(); err == nil {
t.Fatal("expected an unparseable trusted proxy entry to fail")
}
}
func TestRecommendationWeightsMustBeJSON(t *testing.T) { func TestRecommendationWeightsMustBeJSON(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby") t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby") t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
+24 -10
View File
@@ -82,18 +82,29 @@ type RemotePageConfig struct {
// RemoteSectionDefinition is the stable composition contract. Clients render only // RemoteSectionDefinition is the stable composition contract. Clients render only
// known component types and ignore definitions introduced by newer gateways. // known component types and ignore definitions introduced by newer gateways.
type RemoteSectionDefinition struct { type RemoteSectionDefinition struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Title string `json:"title"` Title string `json:"title"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Position int `json:"position"` Position int `json:"position"`
DataSource string `json:"dataSource"` DataSource string `json:"dataSource"`
Component string `json:"component"` Component string `json:"component"`
MaxItems int `json:"maxItems,omitempty"` MaxItems int `json:"maxItems,omitempty"`
Destination string `json:"destination,omitempty"` Destination string `json:"destination,omitempty"`
Settings map[string]any `json:"settings,omitempty"` // Layout overrides the card shape a mediaRow draws: "poster" forces upright poster
// cards, "thumb" forces the wide landscape cards Continue Watching uses. Empty leaves
// the client's automatic choice (episodes landscape, everything else poster) alone.
Layout string `json:"layout,omitempty"`
Settings map[string]any `json:"settings,omitempty"`
} }
// SectionLayoutPoster and SectionLayoutThumb are the two explicit card shapes an operator
// can pin a row to; anything else means "let the client decide".
const (
SectionLayoutPoster = "poster"
SectionLayoutThumb = "thumb"
)
type RemoteContinueWatching struct { type RemoteContinueWatching struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
IncludeNextUp bool `json:"includeNextUp"` IncludeNextUp bool `json:"includeNextUp"`
@@ -321,6 +332,9 @@ func validateRemoteConfigSections(document RemoteConfig) error {
if section.Position < 0 || section.MaxItems < 0 || section.MaxItems > 500 { if section.Position < 0 || section.MaxItems < 0 || section.MaxItems > 500 {
return fmt.Errorf("remote configuration section has an invalid position or item limit") return fmt.Errorf("remote configuration section has an invalid position or item limit")
} }
if section.Layout != "" && section.Layout != SectionLayoutPoster && section.Layout != SectionLayoutThumb {
return fmt.Errorf("remote configuration section %q has an invalid layout", section.ID)
}
} }
} }
return nil return nil
+4
View File
@@ -23,6 +23,10 @@ type Row struct {
Title string `json:"title"` Title string `json:"title"`
Kind string `json:"kind"` Kind string `json:"kind"`
Items []json.RawMessage `json:"items"` Items []json.RawMessage `json:"items"`
// Layout is an operator-pinned card shape ("poster" or "thumb") from the section
// definitions, or empty to leave the client's automatic choice alone. It is stamped
// onto the finished rows by applyRowLayoutOverrides.
Layout string `json:"layout,omitempty"`
} }
// Source is the slice of the Emby client this package needs, narrowed so tests can // Source is the slice of the Emby client this package needs, narrowed so tests can
+22
View File
@@ -66,6 +66,18 @@ type GatewaySettings struct {
// it is still the only way anything is discovered and must stay frequent. // it is still the only way anything is discovered and must stay frequent.
LibrarySyncMinutes int `json:"librarySyncMinutes"` LibrarySyncMinutes int `json:"librarySyncMinutes"`
// HomeTTLSeconds and RecommendTTLHours are how long the launcher payload and the
// personalised recommendation pool stay cached. They cannot be switched off — a TTL of
// nothing means every home load rebuilds — so zero keeps meaning "deployed" and any
// positive value is the override.
HomeTTLSeconds int `json:"homeTtlSeconds"`
RecommendTTLHours int `json:"recommendTtlHours"`
// ForYouRebuildHour is the household-local hour (023) the daily For You rebuild is due
// at. It is a pointer because 0 is a legitimate hour, so nil — not zero — is what means
// "whatever was deployed".
ForYouRebuildHour *int `json:"forYouRebuildHour,omitempty"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"` UpdatedBy string `json:"updatedBy,omitempty"`
} }
@@ -125,6 +137,16 @@ func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
// A day is the ceiling rather than a week: however well the webhooks are working, the // A day is the ceiling rather than a week: however well the webhooks are working, the
// sweep is the only thing that ever notices a file somebody moved by hand. // sweep is the only thing that ever notices a file somebody moved by hand.
settings.LibrarySyncMinutes = clampOverride(settings.LibrarySyncMinutes, 5, 24*60, true) settings.LibrarySyncMinutes = clampOverride(settings.LibrarySyncMinutes, 5, 24*60, true)
settings.HomeTTLSeconds = clampOverride(settings.HomeTTLSeconds, 5, 3600, false)
settings.RecommendTTLHours = clampOverride(settings.RecommendTTLHours, 1, 168, false)
if settings.ForYouRebuildHour != nil {
hour := *settings.ForYouRebuildHour
if hour < 0 || hour > 23 {
// An hour outside the clock is dropped rather than clamped: 25 is a typo, and
// silently reading it as 23 would run the rebuild at a time nobody asked for.
settings.ForYouRebuildHour = nil
}
}
return settings return settings
} }
@@ -2,6 +2,31 @@ package store
import "testing" import "testing"
func TestNormalizeGatewaySettingsCacheAndRebuildOverrides(t *testing.T) {
hour := 25
settings := normalizeGatewaySettings(GatewaySettings{
HomeTTLSeconds: 2, RecommendTTLHours: 500, ForYouRebuildHour: &hour,
})
// TTLs cannot be switched off, so a sub-floor value clamps up and an over-ceiling one
// clamps down rather than either reading as "deployed".
if settings.HomeTTLSeconds != 5 {
t.Fatalf("home TTL should clamp to the floor, got %d", settings.HomeTTLSeconds)
}
if settings.RecommendTTLHours != 168 {
t.Fatalf("recommend TTL should clamp to the ceiling, got %d", settings.RecommendTTLHours)
}
// An hour outside the clock is a typo, and is dropped rather than clamped.
if settings.ForYouRebuildHour != nil {
t.Fatalf("an out-of-range rebuild hour should be dropped, got %d", *settings.ForYouRebuildHour)
}
valid := 0
kept := normalizeGatewaySettings(GatewaySettings{ForYouRebuildHour: &valid})
if kept.ForYouRebuildHour == nil || *kept.ForYouRebuildHour != 0 {
t.Fatalf("midnight is a legitimate rebuild hour and must survive, got %v", kept.ForYouRebuildHour)
}
}
// Normalisation is what stands between a hand-edited row (or a console built against an // Normalisation is what stands between a hand-edited row (or a console built against an
// older vocabulary) and a gateway that cannot decide what day it is. // older vocabulary) and a gateway that cannot decide what day it is.
func TestNormalizeGatewaySettingsRefusesWhatItCannotUse(t *testing.T) { func TestNormalizeGatewaySettingsRefusesWhatItCannotUse(t *testing.T) {
+141
View File
@@ -180,6 +180,147 @@ func (s *Store) AllMediaRequests(ctx context.Context, limit int) ([]OwnedMediaRe
// MediaRequestSweepLimit caps what one pass of the ready sweep will look at. // MediaRequestSweepLimit caps what one pass of the ready sweep will look at.
const MediaRequestSweepLimit = 500 const MediaRequestSweepLimit = 500
// RequestReadySurfaceWindow is how long a freshly arrived request stays pinned to the front
// of Continue Watching with a REQUEST READY tag if the viewer never plays it. Past this the
// age-out clears it: an arrival nobody has acted on in a fortnight is no longer news, and a
// pin that outstays its welcome is worse than one that lapses.
const RequestReadySurfaceWindow = 14 * 24 * time.Hour
// SurfacedReadyRequest is one arrival still worth pinning: which title, and the Emby item id
// the card opens and dedupes against.
type SurfacedReadyRequest struct {
MediaType string
ForeignID int
Title string
Year int
PosterURL string
ItemID string
SurfacedAt time.Time
}
// MarkRequestReadySurfaced records that a request has become watchable and should be pinned.
//
// The guard is the whole of the idempotency: the ready sweep calls this on every observed
// transition to "available", and only the first one — before anything has cleared it — takes
// effect. A request cleared by playback or the age-out is never re-pinned here; a genuinely
// new ask is a fresh row with both timestamps null.
func (s *Store) MarkRequestReadySurfaced(
ctx context.Context, userID, mediaType string, foreignID int, itemID string,
) error {
itemID = strings.TrimSpace(itemID)
if itemID == "" {
return fmt.Errorf("store: request ready needs an item id")
}
_, err := s.pool.Exec(ctx, `
UPDATE media_requests
SET ready_surfaced_at = now(), ready_item_id = $4
WHERE emby_user_id = $1 AND media_type = $2 AND foreign_id = $3
AND ready_surfaced_at IS NULL AND ready_cleared_at IS NULL`,
strings.TrimSpace(userID), mediaType, foreignID, itemID)
if err != nil {
return fmt.Errorf("store: mark request ready surfaced: %w", err)
}
return nil
}
// SurfacedReadyRequests reads one viewer's still-pinned arrivals, newest first.
//
// The window and the cleared check are both in the query so the caller never has to think
// about either: a row past RequestReadySurfaceWindow, or one playback has cleared, simply
// does not come back. This is the read Home makes beside its Emby fan-out.
func (s *Store) SurfacedReadyRequests(
ctx context.Context, userID string,
) ([]SurfacedReadyRequest, error) {
cutoff := time.Now().Add(-RequestReadySurfaceWindow)
rows, err := s.pool.Query(ctx, `
SELECT media_type, foreign_id, title, year, poster_url, ready_item_id, ready_surfaced_at
FROM media_requests
WHERE emby_user_id = $1
AND ready_surfaced_at IS NOT NULL
AND ready_cleared_at IS NULL
AND ready_surfaced_at > $2
AND ready_item_id <> ''
ORDER BY ready_surfaced_at DESC`,
strings.TrimSpace(userID), cutoff)
if err != nil {
return nil, fmt.Errorf("store: read surfaced ready requests: %w", err)
}
defer rows.Close()
out := []SurfacedReadyRequest{}
for rows.Next() {
var req SurfacedReadyRequest
if err := rows.Scan(
&req.MediaType, &req.ForeignID, &req.Title, &req.Year,
&req.PosterURL, &req.ItemID, &req.SurfacedAt,
); err != nil {
return nil, fmt.Errorf("store: scan surfaced ready request: %w", err)
}
out = append(out, req)
}
return out, rows.Err()
}
// ClearRequestReadySurfaced retires the pin for whichever of a viewer's requests point at
// the given Emby item id — the film itself, or the series an episode belongs to. It returns
// how many rows it touched so the caller can skip a cache invalidation that would change
// nothing.
func (s *Store) ClearRequestReadySurfaced(
ctx context.Context, userID string, itemIDs ...string,
) (int64, error) {
ids := make([]string, 0, len(itemIDs))
for _, id := range itemIDs {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return 0, nil
}
tag, err := s.pool.Exec(ctx, `
UPDATE media_requests
SET ready_cleared_at = now()
WHERE emby_user_id = $1 AND ready_item_id = ANY($2)
AND ready_surfaced_at IS NOT NULL AND ready_cleared_at IS NULL`,
strings.TrimSpace(userID), ids)
if err != nil {
return 0, fmt.Errorf("store: clear request ready surfaced: %w", err)
}
return tag.RowsAffected(), nil
}
// ExpireStaleReadyRequests clears every pin past RequestReadySurfaceWindow across the whole
// household in one statement, and returns the users whose Home cache is now stale. The ready
// sweep runs this so SurfacedReadyRequests never has to lean on its own window clause to
// hide a row that should have been retired days ago.
func (s *Store) ExpireStaleReadyRequests(ctx context.Context) ([]string, error) {
cutoff := time.Now().Add(-RequestReadySurfaceWindow)
rows, err := s.pool.Query(ctx, `
UPDATE media_requests
SET ready_cleared_at = now()
WHERE ready_surfaced_at IS NOT NULL AND ready_cleared_at IS NULL
AND ready_surfaced_at <= $1
RETURNING emby_user_id`, cutoff)
if err != nil {
return nil, fmt.Errorf("store: expire stale ready requests: %w", err)
}
defer rows.Close()
seen := map[string]bool{}
users := []string{}
for rows.Next() {
var userID string
if err := rows.Scan(&userID); err != nil {
return nil, err
}
if !seen[userID] {
seen[userID] = true
users = append(users, userID)
}
}
return users, rows.Err()
}
// SetMediaRequestStatus records what a request was last seen doing. // SetMediaRequestStatus records what a request was last seen doing.
// //
// Written only when the state actually moved, so a sweep over a household where nothing has // Written only when the state actually moved, so a sweep over a household where nothing has
+16
View File
@@ -615,6 +615,22 @@ ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS last_status TEXT NOT NULL DE
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
ON media_requests (emby_user_id, requested_at DESC); ON media_requests (emby_user_id, requested_at DESC);
-- The arrival is announced once as a notification, and then pinned to the front of that
-- viewer's Continue Watching row with a REQUEST READY tag until they start watching it.
-- These three columns are the memory of that pin: ready_surfaced_at is when the sweep first
-- saw the title become watchable (and only then, when the library also had an item id to
-- open); ready_item_id is what the row pins and dedupes against; ready_cleared_at is set the
-- moment playback starts or the 14-day age-out fires, and a non-null value stops the request
-- ever being surfaced again — asking afresh after a delete is a new row, which is the case
-- where the pin is genuinely new.
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS ready_surfaced_at TIMESTAMPTZ;
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS ready_item_id TEXT NOT NULL DEFAULT '';
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS ready_cleared_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS media_requests_user_surfaced_idx
ON media_requests (emby_user_id)
WHERE ready_surfaced_at IS NOT NULL AND ready_cleared_at IS NULL;
-- Every sign-in attempt, successful or not. -- Every sign-in attempt, successful or not.
-- --
-- The sessions table above holds one row per television and is overwritten by the next -- The sessions table above holds one row per television and is overwritten by the next