0.3.49
This commit is contained in:
@@ -150,6 +150,18 @@ MEMBY_EMBY_HEALTH_INTERVAL=60s
|
||||
# settings page overrides it without a redeployment.
|
||||
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
|
||||
# Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers.
|
||||
MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/
|
||||
|
||||
@@ -967,9 +967,24 @@ it hides itself on devices that actually support it.
|
||||
|
||||
**Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events
|
||||
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
|
||||
failures, because telemetry must never surface on a TV. Aggregates are read at query time
|
||||
in `store.RowStats`; raw events are pruned after 90 days.
|
||||
on `ON_STOP`, and on dispose. Aggregates are read at query time in `store.RowStats`; raw
|
||||
events are pruned after 90 days. Three things keep the focus/dwell figures honest:
|
||||
|
||||
- **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
|
||||
pieces make that true and each is easy to undo:
|
||||
|
||||
@@ -257,6 +257,8 @@ export interface RemoteSectionDefinition {
|
||||
component: string;
|
||||
maxItems?: number;
|
||||
destination?: string;
|
||||
/** Card shape override for a mediaRow: "poster", "thumb", or absent for automatic. */
|
||||
layout?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -802,6 +804,10 @@ export interface GatewaySettings {
|
||||
embyHealthSeconds: number;
|
||||
slowRequestMillis: number;
|
||||
librarySyncMinutes: number;
|
||||
homeTtlSeconds: number;
|
||||
recommendTtlHours: number;
|
||||
/** null means "whatever was deployed"; 0 is a legitimate override (midnight). */
|
||||
forYouRebuildHour: number | null;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
@@ -818,6 +824,9 @@ export interface GatewaySettingValues {
|
||||
embyHealthSeconds: number;
|
||||
slowRequestMillis: number;
|
||||
librarySyncMinutes: number;
|
||||
homeTtlSeconds: number;
|
||||
recommendTtlHours: number;
|
||||
forYouRebuildHour: number;
|
||||
}
|
||||
|
||||
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 0–23 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 (0–23)"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -232,9 +232,8 @@ export function Layout() {
|
||||
<span className="account-avatar account-avatar-large" aria-hidden="true">{initial}</span>
|
||||
<span><small>Signed in as</small><b>{currentUser}</b></span>
|
||||
</div>
|
||||
{/* The gateway's own settings live here rather than on the rail: the rail
|
||||
is the household — its users, its content, its televisions — and this
|
||||
is the server process those pages are served by. */}
|
||||
{/* Gateway settings now live on Client configuration in the rail; this is a
|
||||
shortcut to the standalone view of the same editor. */}
|
||||
<NavLink to="/admin/settings" role="menuitem" onClick={() => setAccountOpen(false)}>
|
||||
<Icon name="sliders" />
|
||||
Gateway settings
|
||||
|
||||
@@ -53,6 +53,11 @@ function describeRow(row: RemoteSectionDefinition, rowTypes: RowTypeDefinition[]
|
||||
return row.type ? `Custom · ${row.type}` : 'Custom';
|
||||
}
|
||||
|
||||
const LAYOUT_LABEL: Record<string, string> = {
|
||||
poster: 'Poster cards',
|
||||
thumb: 'Thumbnail cards',
|
||||
};
|
||||
|
||||
export function RowsEditor({
|
||||
page,
|
||||
rows,
|
||||
@@ -142,7 +147,7 @@ export function RowsEditor({
|
||||
isNew: true,
|
||||
row: {
|
||||
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">
|
||||
<Chip>{describeRow(row, rowTypes)}</Chip>
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,6 +296,7 @@ function RowEditorDialog({
|
||||
const [customDataSource, setCustomDataSource] = useState(row.dataSource);
|
||||
const [customComponent, setCustomComponent] = useState(row.component || 'mediaRow');
|
||||
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
|
||||
// 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 &&
|
||||
(!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 = () => {
|
||||
if (!valid) return;
|
||||
const resolvedType = rowType?.type ?? 'custom';
|
||||
@@ -324,6 +337,7 @@ function RowEditorDialog({
|
||||
component: isCustom ? customComponent.trim() : rowType!.component,
|
||||
maxItems,
|
||||
destination,
|
||||
...(layoutApplies && (layout === 'poster' || layout === 'thumb') ? { layout } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -359,6 +373,22 @@ function RowEditorDialog({
|
||||
</div>
|
||||
{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 ? (
|
||||
<Note>Browses the {PAGE_LABEL[page]} catalogue — this row can only ever point at the page it lives on.</Note>
|
||||
) : null}
|
||||
|
||||
@@ -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 ---------- */
|
||||
|
||||
export interface TileSpec {
|
||||
|
||||
+8
-2
@@ -44,6 +44,13 @@ export const nav: NavGroup[] = [
|
||||
item('journey-viewer', '/admin/journeys/:userId', 'Journey', 'User journey', 'One viewer’s 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 gateway’s own server-level settings.', 'sliders'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'history', label: 'History', defaultCollapsed: false,
|
||||
items: [
|
||||
@@ -98,7 +105,6 @@ export const nav: NavGroup[] = [
|
||||
{
|
||||
id: 'rules', label: 'Rules', defaultCollapsed: false,
|
||||
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('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('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('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 }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -103,6 +103,9 @@ interface WatchTime {
|
||||
matched: boolean;
|
||||
tracearrUsername?: string;
|
||||
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;
|
||||
totalMs: number;
|
||||
weekSessions: number;
|
||||
@@ -145,6 +148,17 @@ function cssColour(value: string | undefined): string {
|
||||
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 =
|
||||
| { kind: 'remove-device'; deviceId: string; name: string }
|
||||
| { kind: 'remove-account' }
|
||||
@@ -494,7 +508,7 @@ export function AccountPage() {
|
||||
<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),
|
||||
icon: 'pulse',
|
||||
tone: 'data',
|
||||
|
||||
@@ -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';
|
||||
|
||||
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 {
|
||||
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;
|
||||
@@ -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 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() {
|
||||
const { data, error, loading, reload } = useQuery<AccountsResponse>('/admin/api/accounts', { 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><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="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><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>
|
||||
|
||||
+269
-219
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
@@ -15,22 +16,39 @@ import {
|
||||
Loading,
|
||||
PageHead,
|
||||
PlainTiles,
|
||||
Tabs,
|
||||
Tag,
|
||||
Toggle,
|
||||
} from '../components/ui';
|
||||
import { RowsEditor } from '../components/RowsEditor';
|
||||
import { GatewaySettingsSection } from '../components/GatewaySettingsSection';
|
||||
import type { RemoteSectionDefinition } from '../api/types';
|
||||
|
||||
/* The three configuration keys that hold a page's row composition. They are edited
|
||||
* visually through RowsEditor rather than through the generic configuration list below,
|
||||
* 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. */
|
||||
/* One page for everything the household is served, split into four views so the section an
|
||||
* operator came for is not at the bottom of a single scroll:
|
||||
*
|
||||
* 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> = {
|
||||
home: 'home.sectionDefinitions',
|
||||
movies: 'movies.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 {
|
||||
action: 'safe-mode' | 'rollback' | 'reset' | 'feature';
|
||||
title: string;
|
||||
@@ -45,13 +63,21 @@ export function FeaturesPage() {
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
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 features = policy?.features ?? [];
|
||||
const rowTypes = policy?.rowTypes ?? [];
|
||||
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 configuration = rowConfiguration.filter((item) => !rowKeys.has(item.key));
|
||||
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 (
|
||||
<>
|
||||
<PageHead
|
||||
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} />
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
|
||||
{loading || !policy ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<Card
|
||||
title="Thin client control plane"
|
||||
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>
|
||||
<a className="chip" href="/admin/recommendations">For You and recommendation pools</a>
|
||||
<a className="chip" href="/admin/engagement">Contextual row engagement signals</a>
|
||||
<a className="chip" href="/admin/clients">Client versions and capabilities</a>
|
||||
</div>
|
||||
</Card>
|
||||
{tab === 'overview' ? (
|
||||
loading || !policy ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<Card
|
||||
title="Thin client control plane"
|
||||
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>
|
||||
<a className="chip" href="/admin/recommendations">For You and recommendation pools</a>
|
||||
<a className="chip" href="/admin/engagement">Contextual row engagement signals</a>
|
||||
<a className="chip" href="/admin/clients">Client versions and capabilities</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<RowsEditor
|
||||
page="home"
|
||||
rows={rowsFor(ROW_CONFIGURATION_KEYS.home)}
|
||||
rowTypes={rowTypes}
|
||||
busy={busy === ROW_CONFIGURATION_KEYS.home}
|
||||
onSave={saveRows(ROW_CONFIGURATION_KEYS.home)}
|
||||
/>
|
||||
<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)}
|
||||
/>
|
||||
<Card
|
||||
title="Control plane"
|
||||
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."
|
||||
icon="sliders"
|
||||
tone="ok"
|
||||
actions={
|
||||
<Button
|
||||
variant={safeMode ? undefined : 'danger'}
|
||||
busy={busy === 'safe'}
|
||||
onClick={() =>
|
||||
safeMode
|
||||
? void act('leave-safe-mode', 'safe', 'Safe mode ended.')
|
||||
: setPending({
|
||||
action: 'safe-mode',
|
||||
title: 'Enable safe mode?',
|
||||
body:
|
||||
'Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.',
|
||||
label: 'Enable safe mode',
|
||||
})
|
||||
}
|
||||
>
|
||||
{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
|
||||
title="Control plane"
|
||||
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."
|
||||
icon="sliders"
|
||||
tone="ok"
|
||||
actions={
|
||||
<Button
|
||||
variant={safeMode ? undefined : 'danger'}
|
||||
busy={busy === 'safe'}
|
||||
onClick={() =>
|
||||
safeMode
|
||||
? void act('leave-safe-mode', 'safe', 'Safe mode ended.')
|
||||
: setPending({
|
||||
action: 'safe-mode',
|
||||
title: 'Enable safe mode?',
|
||||
body:
|
||||
'Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.',
|
||||
label: 'Enable safe mode',
|
||||
})
|
||||
}
|
||||
>
|
||||
{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>
|
||||
<div className="row">
|
||||
<Button
|
||||
disabled={!policy.canRollback}
|
||||
onClick={() =>
|
||||
setPending({
|
||||
action: 'rollback',
|
||||
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>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{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
|
||||
title="Central configuration"
|
||||
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."
|
||||
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">
|
||||
{tab === 'features' ? (
|
||||
loading || !policy ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
{features.length === 0 ? (
|
||||
<Card title="Nothing registered" icon="sliders">
|
||||
<Card title="Feature flags" icon="sliders">
|
||||
<Empty>No server features are registered.</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
features.map((feature) => (
|
||||
<Card
|
||||
key={feature.key}
|
||||
title={feature.name}
|
||||
intro={feature.description}
|
||||
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'}
|
||||
hint="Changing this applies the feature policy to every compatible television."
|
||||
checked={feature.enabled}
|
||||
disabled={busy === feature.key}
|
||||
onChange={(enabled) => setPending({
|
||||
action: 'feature', key: feature.key, enabled,
|
||||
title: `${enabled ? 'Turn on' : 'Turn off'} ${feature.name}?`,
|
||||
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>
|
||||
<Chip>protocol {num(feature.minimumProtocol)}+</Chip>
|
||||
<Chip tone={feature.compatible ? 'ok' : 'warn'}>
|
||||
{feature.compatible ? 'server compatible' : 'compatibility blocked'}
|
||||
</Chip>
|
||||
<Chip tone="note">{feature.area}</Chip>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
<Grid cols="2">
|
||||
{features.map((feature) => (
|
||||
<Card
|
||||
key={feature.key}
|
||||
title={feature.name}
|
||||
intro={feature.description}
|
||||
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'}
|
||||
hint="Changing this applies the feature policy to every compatible television."
|
||||
checked={feature.enabled}
|
||||
disabled={busy === feature.key}
|
||||
onChange={(enabled) => setPending({
|
||||
action: 'feature', key: feature.key, enabled,
|
||||
title: `${enabled ? 'Turn on' : 'Turn off'} ${feature.name}?`,
|
||||
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>
|
||||
<Chip>protocol {num(feature.minimumProtocol)}+</Chip>
|
||||
<Chip tone={feature.compatible ? 'ok' : 'warn'}>
|
||||
{feature.compatible ? 'server compatible' : 'compatibility blocked'}
|
||||
</Chip>
|
||||
<Chip tone="note">{feature.area}</Chip>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Card>
|
||||
<div className="row">
|
||||
<Button
|
||||
disabled={!policy.canRollback}
|
||||
onClick={() =>
|
||||
setPending({
|
||||
action: 'rollback',
|
||||
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>
|
||||
<Card
|
||||
title="Configuration values"
|
||||
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."
|
||||
icon="sliders"
|
||||
>
|
||||
{configuration.length === 0 ? (
|
||||
<Empty>No configuration values are registered.</Empty>
|
||||
) : (
|
||||
<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 ? (
|
||||
<Confirm
|
||||
|
||||
@@ -1,10 +1,67 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
import { useToast } from '../lib/toast';
|
||||
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() {
|
||||
const { status, error, loading, reload } = useGateway();
|
||||
@@ -12,8 +69,21 @@ export function RequestsPage() {
|
||||
const { busy, run } = useAction();
|
||||
const users = status?.requestUsers ?? [];
|
||||
const allowed = status?.requestPolicy?.allowedUserIds ?? [];
|
||||
const usage = useMemo(() => new Map((status?.requestUsage ?? []).map((entry) => [entry.userId, entry])), [status?.requestUsage]);
|
||||
const toggle = (id: string) => run(`access-${id}`, async () => {
|
||||
const usage = useMemo(
|
||||
() => 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];
|
||||
await wrap(
|
||||
() => api.post('/admin/api/request-policy', { allowedUserIds: next }),
|
||||
@@ -24,8 +94,12 @@ export function RequestsPage() {
|
||||
|
||||
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={requests.error} />
|
||||
|
||||
{loading ? (
|
||||
<Loading rows={2} />
|
||||
@@ -64,9 +138,119 @@ export function RequestsPage() {
|
||||
{users.length === 0 ? (
|
||||
<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>
|
||||
{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>
|
||||
<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>
|
||||
{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>
|
||||
</>
|
||||
|
||||
@@ -1,337 +1,18 @@
|
||||
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, PageHead, Tag } from '../components/ui';
|
||||
import { GatewaySettingsSection } from '../components/GatewaySettingsSection';
|
||||
import { PageHead } from '../components/ui';
|
||||
|
||||
/* The gateway's own settings, as distinct from every other page in this console.
|
||||
*
|
||||
* Everything else here decides what the *televisions* do — which rows they draw, which
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
/* 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
|
||||
* embedded in Client configuration, which is where it is discovered from the rail. */
|
||||
|
||||
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 (
|
||||
<>
|
||||
<PageHead
|
||||
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} />
|
||||
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
<GatewaySettingsSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -786,6 +786,47 @@ a {
|
||||
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) {
|
||||
:root {
|
||||
--rail: 0px;
|
||||
|
||||
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
val defaultVersionName = "0.3.39"
|
||||
val defaultVersionName = "0.3.40"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -2145,10 +2145,24 @@ class EmbyRepository internal constructor(
|
||||
* 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.
|
||||
*/
|
||||
fun reportRowEvents(events: List<GatewayRowEvent>) {
|
||||
if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return
|
||||
fun reportRowEvents(
|
||||
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 {
|
||||
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) {
|
||||
synchronized(lock) {
|
||||
if (focusedRowId == rowId) return
|
||||
closeOpenFocus()
|
||||
if (focusedRowId == rowId) {
|
||||
// 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
|
||||
focusedRowKind = rowKind
|
||||
focusStartedAt = now()
|
||||
@@ -92,11 +97,34 @@ class RowAnalytics(
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the open focus measurement — call when leaving the home screen, or before a
|
||||
* flush, so dwell is not lost while the viewer sits on one row.
|
||||
* Closes the open focus measurement outright — call when focus genuinely leaves the
|
||||
* rows (the rail, the hero, an overlay, a selection) or the home screen is left.
|
||||
*/
|
||||
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. */
|
||||
@@ -116,28 +144,37 @@ class RowAnalytics(
|
||||
impressed.clear()
|
||||
impressedItems.clear()
|
||||
focusedRowId = null
|
||||
focusStartedAt = 0
|
||||
lastFocusedItemId = ""
|
||||
}
|
||||
}
|
||||
|
||||
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 dwell = (now() - focusStartedAt).coerceAtLeast(0)
|
||||
focusedRowId = null
|
||||
// Sub-second glances are D-pad travel, not attention. Dropping them keeps the
|
||||
// numbers meaningful and the batches small.
|
||||
if (dwell < MIN_DWELL_MS) return
|
||||
add(
|
||||
GatewayRowEvent(
|
||||
rowId = rowId,
|
||||
rowKind = focusedRowKind,
|
||||
event = EVENT_FOCUS,
|
||||
itemId = lastFocusedItemId,
|
||||
dwellMs = dwell,
|
||||
occurredAt = timestamp(),
|
||||
),
|
||||
)
|
||||
// Sub-threshold glances are D-pad travel, not attention. Drop them — and, when
|
||||
// re-arming, leave the clock running so the unrecorded fraction is not discarded
|
||||
// on every flush.
|
||||
if (dwell >= MIN_DWELL_MS) {
|
||||
add(
|
||||
GatewayRowEvent(
|
||||
rowId = rowId,
|
||||
rowKind = focusedRowKind,
|
||||
event = EVENT_FOCUS,
|
||||
itemId = lastFocusedItemId,
|
||||
dwellMs = dwell,
|
||||
occurredAt = timestamp(),
|
||||
),
|
||||
)
|
||||
focusStartedAt = now()
|
||||
}
|
||||
if (!reArm) focusedRowId = null
|
||||
}
|
||||
|
||||
/** Caller already holds the lock. */
|
||||
|
||||
@@ -498,6 +498,13 @@ data class BaseItem(
|
||||
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
|
||||
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
|
||||
@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("MembyRequestable") val membyRequestable: Boolean = false,
|
||||
@SerialName("MembyPosterURL") val membyPosterUrl: String? = null,
|
||||
|
||||
@@ -176,6 +176,12 @@ data class HomeRow(
|
||||
val title: String = "",
|
||||
val kind: String = "",
|
||||
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. */
|
||||
|
||||
@@ -271,6 +271,12 @@ internal fun HomeContentPane(
|
||||
// back. Keyed on the destination so arriving at Home never inherits where
|
||||
// focus happened to be on another one.
|
||||
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(
|
||||
hasMovies = hasContextualHero,
|
||||
listAtTop = homeListAtTop,
|
||||
|
||||
@@ -616,6 +616,7 @@ internal fun serverHomeRows(
|
||||
"continue" -> CONTINUE_ROW_SECONDARY_METADATA
|
||||
else -> BROWSE_ROW_SECONDARY_METADATA
|
||||
},
|
||||
cardLayout = row.layout,
|
||||
)
|
||||
}
|
||||
// A server response is already context-ranked. Do not reapply the static bundled
|
||||
|
||||
@@ -654,7 +654,7 @@ internal fun HomeScreen(
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner, homeViewModel) {
|
||||
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)
|
||||
onDispose {
|
||||
@@ -683,6 +683,9 @@ internal fun HomeScreen(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
onRailFocusChanged = {
|
||||
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 ->
|
||||
homeViewModel.trackJourney(
|
||||
|
||||
@@ -262,17 +262,30 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
|
||||
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)
|
||||
// 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
|
||||
* screen stops, so time spent sitting on one row is not lost.
|
||||
* Focus has moved off the shelves — to the rail, the hero, My Shows or an overlay.
|
||||
* 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
|
||||
analytics.endFocus()
|
||||
repository.reportRowEvents(analytics.drain())
|
||||
if (endMeasurement) analytics.endFocus() else analytics.checkpointFocus()
|
||||
repository.reportRowEvents(analytics.drain()) { unsent -> analytics.restore(unsent) }
|
||||
repository.reportJourneyEvents(journey.drain())
|
||||
}
|
||||
|
||||
@@ -310,7 +323,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
*/
|
||||
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) {
|
||||
analytics.endFocus()
|
||||
@@ -991,7 +1004,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
analyticsPausedForPlayback = false
|
||||
flushAnalytics()
|
||||
flushAnalytics(endMeasurement = true)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
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.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
@@ -69,6 +70,12 @@ data class ResumableMediaCardModel(
|
||||
val played: Boolean = false,
|
||||
val favourite: 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 progress: Float get() = resumableProgress(playbackPositionTicks, runtimeTicks)
|
||||
@@ -76,6 +83,9 @@ data class ResumableMediaCardModel(
|
||||
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(
|
||||
thumbUrl: String?,
|
||||
backdropUrl: String?,
|
||||
@@ -102,6 +112,11 @@ internal fun BaseItem.toResumableMediaCardModel(
|
||||
primaryUrl = primaryUrl,
|
||||
played = played,
|
||||
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
|
||||
// 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.
|
||||
@@ -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))
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -63,6 +63,11 @@ data class HomeBrowseRow(
|
||||
val emptyMessage: String,
|
||||
val showSecondaryMetadata: Boolean = true,
|
||||
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 {
|
||||
@@ -175,8 +180,12 @@ internal fun MediaRow(
|
||||
|
||||
when {
|
||||
row.items.isEmpty() && row.loading -> {
|
||||
val isPortrait = remember(row.kind) {
|
||||
row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
|
||||
val isPortrait = remember(row.kind, row.cardLayout) {
|
||||
when (row.cardLayout) {
|
||||
"poster" -> true
|
||||
"thumb", "landscape" -> false
|
||||
else -> row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
|
||||
}
|
||||
}
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(
|
||||
@@ -226,7 +235,7 @@ internal fun MediaRow(
|
||||
items = row.items,
|
||||
key = { _, item -> item.id },
|
||||
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 ->
|
||||
val targetFocusRequester = when {
|
||||
@@ -266,8 +275,8 @@ internal fun MediaRow(
|
||||
{ currentOnItemLongPressed(currentItem) }
|
||||
}
|
||||
|
||||
val format = remember(row.kind, item.id, item.isEpisode, artworkStyle) {
|
||||
cardFormat(row.kind, item, artworkStyle)
|
||||
val format = remember(row.kind, item.id, item.isEpisode, artworkStyle, row.cardLayout) {
|
||||
cardFormat(row.kind, item, artworkStyle, row.cardLayout)
|
||||
}
|
||||
|
||||
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,
|
||||
item: BaseItem,
|
||||
artworkStyle: String = "automatic",
|
||||
): MediaCardFormat = when (artworkStyle) {
|
||||
rowLayout: String = "",
|
||||
): MediaCardFormat = when (rowLayout) {
|
||||
"poster" -> MediaCardFormat.PORTRAIT
|
||||
"backdrop" -> MediaCardFormat.LANDSCAPE
|
||||
else -> when {
|
||||
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
|
||||
item.isEpisode -> MediaCardFormat.LANDSCAPE
|
||||
else -> MediaCardFormat.PORTRAIT
|
||||
"thumb", "landscape" -> MediaCardFormat.LANDSCAPE
|
||||
else -> when (artworkStyle) {
|
||||
"poster" -> MediaCardFormat.PORTRAIT
|
||||
"backdrop" -> MediaCardFormat.LANDSCAPE
|
||||
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)
|
||||
}
|
||||
|
||||
@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
|
||||
fun `passing through a row is not counted as attention`() {
|
||||
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)
|
||||
}
|
||||
|
||||
@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
|
||||
fun `landscape artwork prefers thumb over backdrop and primary`() {
|
||||
val model = ResumableMediaCardModel(
|
||||
|
||||
@@ -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/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||
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("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
|
||||
mux.Handle("GET /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
||||
|
||||
@@ -105,7 +105,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
for _, account := range accounts {
|
||||
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))
|
||||
for _, account := range accounts {
|
||||
@@ -123,7 +123,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
watched, matchedWatchTime := watchTime[account.ID]
|
||||
result = append(result, adminMembyAccount{
|
||||
WatchTime: summariseWatchTime(watched, matchedWatchTime),
|
||||
WatchTime: summariseWatchTime(watched, matchedWatchTime, priorWeekWatchTime[account.ID]),
|
||||
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
||||
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
|
||||
ShortName: stringPreference(accountSettings.Preferences, "shortName"),
|
||||
|
||||
@@ -44,6 +44,9 @@ func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse {
|
||||
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
|
||||
SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()),
|
||||
LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute),
|
||||
HomeTTLSeconds: int(s.homeTTL() / time.Second),
|
||||
RecommendTTLHours: int(s.recommendTTL() / time.Hour),
|
||||
ForYouRebuildHour: s.forYouRebuildHour(),
|
||||
},
|
||||
LogLevels: store.GatewayLogLevels,
|
||||
NotificationDisplays: store.GatewayNotificationDisplays,
|
||||
@@ -146,6 +149,15 @@ func gatewaySettingsChanges(before, after store.GatewaySettings) string {
|
||||
if before.LibrarySyncMinutes != after.LibrarySyncMinutes {
|
||||
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) {
|
||||
case 0:
|
||||
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 {
|
||||
switch len(values) {
|
||||
case 0:
|
||||
|
||||
@@ -167,7 +167,22 @@ func adminPreviewData() map[string]any {
|
||||
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
|
||||
// ordinary here and a preview showing neither would not be a preview of this page.
|
||||
"/admin/api/searches": map[string]any{
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -947,4 +947,26 @@ func TestToRowEventValidatesAndClamps(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -210,6 +210,15 @@ func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.Ro
|
||||
if payload.RowID == "" {
|
||||
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 {
|
||||
case store.RowEventImpression, store.RowEventFocus, store.RowEventSelect:
|
||||
default:
|
||||
|
||||
@@ -132,7 +132,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Actor: req.Username, Target: req.DeviceName,
|
||||
Link: "/admin/logins",
|
||||
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")
|
||||
@@ -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.
|
||||
identify(r.Context(), sess)
|
||||
origin := s.resolveClientIP(r)
|
||||
s.loggerFor(r.Context()).Info("signed in",
|
||||
"emby_user", sess.EmbyUserID,
|
||||
"device_id", sess.DeviceID,
|
||||
"protocol", clientLogValue(sess.ClientProtocol),
|
||||
"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{
|
||||
EmbyUserID: sess.EmbyUserID, Username: sess.Username,
|
||||
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// what a television actually understands.
|
||||
type rowTypeDefinition struct {
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Component string `json:"component"`
|
||||
DataSource string `json:"dataSource"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Component string `json:"component"`
|
||||
DataSource string `json:"dataSource"`
|
||||
// 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
|
||||
// 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 {
|
||||
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) == "" {
|
||||
return errors.New(key + ": row " + id + " needs a component")
|
||||
}
|
||||
|
||||
@@ -163,6 +163,31 @@ func (s *Server) LibrarySyncInterval() time.Duration {
|
||||
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
|
||||
// value is off, zero is "whatever was deployed", anything else is the override in the
|
||||
// given unit.
|
||||
@@ -191,6 +216,9 @@ type deployedGatewaySettings struct {
|
||||
EmbyHealthSeconds int `json:"embyHealthSeconds"`
|
||||
SlowRequestMillis int `json:"slowRequestMillis"`
|
||||
LibrarySyncMinutes int `json:"librarySyncMinutes"`
|
||||
HomeTTLSeconds int `json:"homeTtlSeconds"`
|
||||
RecommendTTLHours int `json:"recommendTtlHours"`
|
||||
ForYouRebuildHour int `json:"forYouRebuildHour"`
|
||||
}
|
||||
|
||||
func (s *Server) deployedSettings() deployedGatewaySettings {
|
||||
@@ -208,6 +236,9 @@ func (s *Server) deployedSettings() deployedGatewaySettings {
|
||||
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
|
||||
SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond),
|
||||
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) {
|
||||
for _, level := range store.GatewayLogLevels {
|
||||
if got := levelName(parseTestLevel(t, level)); got != level {
|
||||
|
||||
@@ -135,6 +135,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
forYouRows []recommend.Row
|
||||
forYouRowStale bool
|
||||
seriesPlayed map[string]time.Time
|
||||
surfacedReady []store.SurfacedReadyRequest
|
||||
ranking rankingInputs
|
||||
rowStats []store.RowStat
|
||||
rowStatsOK bool
|
||||
@@ -286,6 +287,24 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
defer wg.Done()
|
||||
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 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -346,6 +365,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
||||
if continueWatching {
|
||||
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
|
||||
// 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 = personalizeRowsByTitleScores(selectPersonalizedRows(out.Rows))
|
||||
out.Rows = deduplicateRows(out.Rows)
|
||||
out.Rows = s.applyRowLayouts(ctx, out.Rows)
|
||||
rank()
|
||||
// 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
|
||||
@@ -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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func (s *Server) runForYouRebuild(ctx context.Context) (scheduler.Outcome, error
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
result, err := s.forYou.RebuildAll(ctx, true)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -237,28 +236,3 @@ func isPlaybackItemPath(path string) bool {
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
|
||||
request.Header.Set("X-Memby-Version", "0.1.60")
|
||||
|
||||
@@ -31,7 +31,13 @@ func (s *Server) recordLogin(r *http.Request, event store.LoginEvent) {
|
||||
return
|
||||
}
|
||||
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 == "" {
|
||||
event.ClientVersion = clientVersion(r)
|
||||
|
||||
@@ -248,6 +248,25 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
"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 :=
|
||||
s.nextAiringFieldsFor(ctx, target)
|
||||
writeJSON(w, http.StatusOK, playbackResponse{
|
||||
|
||||
@@ -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
|
||||
// rebuild on every single home load.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,15 @@ func (s *Server) runRequestReadyScan(ctx context.Context) (string, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
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) {
|
||||
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(
|
||||
ctx, req.UserID, req.MediaType, req.ForeignID, card.Status,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -155,11 +155,13 @@ func (s *Server) handleTrailerReport(w http.ResponseWriter, r *http.Request, ses
|
||||
writeError(w, http.StatusBadRequest, "invalid trailer report")
|
||||
return
|
||||
}
|
||||
origin := s.resolveClientIP(r)
|
||||
fields := []any{
|
||||
"item_id", itemID,
|
||||
"provider", report.Provider,
|
||||
"candidate", report.CandidateID,
|
||||
"source_ip", requestClientIP(r),
|
||||
"source_ip", origin.String(),
|
||||
"source_ip_via", origin.Via,
|
||||
}
|
||||
if reason := strings.TrimSpace(report.Reason); reason != "" {
|
||||
fields = append(fields, "reason", reason)
|
||||
|
||||
@@ -66,6 +66,9 @@ func TestTrailerStartedLogUsesOriginalClientIP(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/report",
|
||||
bytes.NewBufferString(`{"candidateId":"youtube-1","provider":"youtube","phase":"started"}`))
|
||||
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, _ = withRequestIdentity(request)
|
||||
identify(request.Context(), store.Session{Username: "viewer", DeviceName: "Lounge TV"})
|
||||
@@ -76,6 +79,7 @@ func TestTrailerStartedLogUsesOriginalClientIP(t *testing.T) {
|
||||
}
|
||||
page := events.Events(0, 10)
|
||||
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" {
|
||||
t.Fatalf("unexpected event: %+v", page.Events)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,53 @@ func previousMonth(now time.Time, location *time.Location) (from, to time.Time,
|
||||
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
|
||||
// once per week. ISO year-and-week, so the last days of December cannot collide with the
|
||||
// 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
|
||||
// investigating a viewer rather than an integration.
|
||||
type watchTimeSummary struct {
|
||||
Matched bool `json:"matched"`
|
||||
Username string `json:"tracearrUsername,omitempty"`
|
||||
WeekMs int64 `json:"weekMs"`
|
||||
MonthMs int64 `json:"monthMs"`
|
||||
TotalMs int64 `json:"totalMs"`
|
||||
WeekSessions int `json:"weekSessions"`
|
||||
MonthSessions int `json:"monthSessions"`
|
||||
LastWatchedAt *time.Time `json:"lastWatchedAt,omitempty"`
|
||||
Matched bool `json:"matched"`
|
||||
Username string `json:"tracearrUsername,omitempty"`
|
||||
WeekMs int64 `json:"weekMs"`
|
||||
// WeekTrend compares WeekMs with the same point last week, so the console can show
|
||||
// whether a person's viewing is up or down without the operator holding last week's
|
||||
// figure in their head.
|
||||
WeekTrend watchWeekTrend `json:"weekTrend"`
|
||||
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
|
||||
@@ -220,16 +271,16 @@ type watchTimeAccount struct {
|
||||
func (s *Server) watchTimeForAccounts(
|
||||
ctx context.Context,
|
||||
accounts []watchTimeAccount,
|
||||
) map[string]store.WatchTimeTotals {
|
||||
) (totals map[string]store.WatchTimeTotals, priorWeekMs map[string]int64) {
|
||||
if !s.tracearrEnabled() || len(accounts) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
location := s.householdLocation()
|
||||
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 {
|
||||
s.loggerFor(ctx).Warn("watch time read failed", "error", err)
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
identities, err := s.store.TracearrIdentities(ctx)
|
||||
if err != nil {
|
||||
@@ -238,18 +289,37 @@ func (s *Server) watchTimeForAccounts(
|
||||
s.loggerFor(ctx).Warn("Tracearr identity map unavailable", "error", err)
|
||||
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
|
||||
// where there is no row.
|
||||
func summariseWatchTime(totals store.WatchTimeTotals, matched bool) watchTimeSummary {
|
||||
// where there is no row. priorWeekMs is last week's viewing to the same point, for the
|
||||
// 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 {
|
||||
return watchTimeSummary{}
|
||||
}
|
||||
return watchTimeSummary{
|
||||
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,
|
||||
LastWatchedAt: totals.LastWatchedAt,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
accounts := []watchTimeAccount{{ID: "emby-1", Username: "Matt"}}
|
||||
totals := []store.WatchTimeTotals{{Username: "matt", WeekMs: 42}}
|
||||
@@ -202,7 +243,7 @@ func TestAnUnmatchedAccountIsAbsentRatherThanZero(t *testing.T) {
|
||||
if _, ok := got["emby-9"]; ok {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -85,6 +86,17 @@ type Config struct {
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -225,6 +237,10 @@ func Load() (Config, error) {
|
||||
if err != nil {
|
||||
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{
|
||||
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
|
||||
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")),
|
||||
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
||||
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
||||
TrustedProxies: trusted,
|
||||
SlowRequestThreshold: duration("MEMBY_SLOW_REQUEST_THRESHOLD", 500*time.Millisecond),
|
||||
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
|
||||
@@ -82,18 +82,29 @@ type RemotePageConfig struct {
|
||||
// RemoteSectionDefinition is the stable composition contract. Clients render only
|
||||
// known component types and ignore definitions introduced by newer gateways.
|
||||
type RemoteSectionDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Position int `json:"position"`
|
||||
DataSource string `json:"dataSource"`
|
||||
Component string `json:"component"`
|
||||
MaxItems int `json:"maxItems,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
Settings map[string]any `json:"settings,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Position int `json:"position"`
|
||||
DataSource string `json:"dataSource"`
|
||||
Component string `json:"component"`
|
||||
MaxItems int `json:"maxItems,omitempty"`
|
||||
Destination string `json:"destination,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 {
|
||||
Enabled bool `json:"enabled"`
|
||||
IncludeNextUp bool `json:"includeNextUp"`
|
||||
@@ -321,6 +332,9 @@ func validateRemoteConfigSections(document RemoteConfig) error {
|
||||
if section.Position < 0 || section.MaxItems < 0 || section.MaxItems > 500 {
|
||||
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
|
||||
|
||||
@@ -23,6 +23,10 @@ type Row struct {
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
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
|
||||
|
||||
@@ -66,6 +66,18 @@ type GatewaySettings struct {
|
||||
// it is still the only way anything is discovered and must stay frequent.
|
||||
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 (0–23) 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"`
|
||||
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
|
||||
// sweep is the only thing that ever notices a file somebody moved by hand.
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,31 @@ package store
|
||||
|
||||
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
|
||||
// older vocabulary) and a gateway that cannot decide what day it is.
|
||||
func TestNormalizeGatewaySettingsRefusesWhatItCannotUse(t *testing.T) {
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
//
|
||||
// Written only when the state actually moved, so a sweep over a household where nothing has
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
--
|
||||
-- The sessions table above holds one row per television and is overwritten by the next
|
||||
|
||||
Reference in New Issue
Block a user