This commit is contained in:
ponzischeme89
2026-08-28 23:00:02 +12:00
parent 3e89036f7b
commit d5632e844a
66 changed files with 2870 additions and 689 deletions
+12
View File
@@ -150,6 +150,18 @@ MEMBY_EMBY_HEALTH_INTERVAL=60s
# settings page overrides it without a redeployment.
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/
+18 -3
View File
@@ -967,9 +967,24 @@ it hides itself on devices that actually support it.
**Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events
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:
+9
View File
@@ -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 023 with no "off": an empty field is "deployed"
* (null on the wire) and every other value is the hour it runs at. */
function parseHourField(raw: string): number | null {
const text = raw.trim();
if (text === '') return null;
const parsed = Number.parseInt(text, 10);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 23) return null;
return parsed;
}
function describe(value: number, unit: string): string {
if (value <= 0) return 'off';
return `${value} ${unit}${value === 1 ? '' : 's'}`;
}
function notificationDisplayLabel(value: string): string {
switch (value) {
case 'everywhere': return 'Everywhere';
case 'off': return 'Off';
default: return 'Home only';
}
}
interface Draft {
timezone: string;
logLevel: string;
sessionIdleDays: string;
sonarrAlertMinutes: string;
radarrAlertMinutes: string;
notificationDisplay: string;
embyHealthSeconds: string;
slowRequestMillis: string;
librarySyncMinutes: string;
homeTtlSeconds: string;
recommendTtlHours: string;
forYouRebuildHour: string;
}
function draftFrom(settings: GatewaySettings): Draft {
return {
timezone: settings.timezone ?? '',
logLevel: settings.logLevel ?? '',
sessionIdleDays: numberFieldValue(settings.sessionIdleDays),
sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes),
radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes),
notificationDisplay: settings.notificationDisplay || 'home_only',
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
slowRequestMillis: numberFieldValue(settings.slowRequestMillis),
librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes),
homeTtlSeconds: settings.homeTtlSeconds ? String(settings.homeTtlSeconds) : '',
recommendTtlHours: settings.recommendTtlHours ? String(settings.recommendTtlHours) : '',
forYouRebuildHour: settings.forYouRebuildHour == null ? '' : String(settings.forYouRebuildHour),
};
}
function bodyFrom(draft: Draft): GatewaySettings {
return {
timezone: draft.timezone.trim(),
logLevel: draft.logLevel.trim(),
sessionIdleDays: parseNumberField(draft.sessionIdleDays, false),
sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true),
radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true),
notificationDisplay: draft.notificationDisplay,
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true),
slowRequestMillis: parseNumberField(draft.slowRequestMillis, true),
librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true),
homeTtlSeconds: parseNumberField(draft.homeTtlSeconds, false),
recommendTtlHours: parseNumberField(draft.recommendTtlHours, false),
forYouRebuildHour: parseHourField(draft.forYouRebuildHour),
};
}
export function GatewaySettingsSection() {
const { data, error, loading, reload } = useQuery<GatewaySettingsResponse>('/admin/api/gateway-settings');
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<Draft | null>(null);
// A refresh must never take a half-typed field away: the draft is adopted once, and
// after that the operator owns it until they save or discard.
useEffect(() => {
if (!draft && data) setDraft(draftFrom(data.settings));
}, [data, draft]);
const set = <K extends keyof Draft>(key: K, value: string) =>
setDraft((current) => (current ? { ...current, [key]: value } : current));
const save = () =>
run('save', async () => {
if (!draft) return;
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', bodyFrom(draft)),
'Gateway settings saved.',
);
// Adopt what the server stored rather than what was typed — normalisation clamps and
// refuses. A failed save leaves the draft alone.
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const clearAll = () =>
run('clear', async () => {
const saved = await wrap(
() => api.post<GatewaySettingsResponse>('/admin/api/gateway-settings', {
timezone: '', logLevel: '', sessionIdleDays: 0,
sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0, slowRequestMillis: 0,
librarySyncMinutes: 0, notificationDisplay: 'home_only',
homeTtlSeconds: 0, recommendTtlHours: 0, forYouRebuildHour: null,
}),
'Every setting is back to its deployed or server default.',
);
if (saved) setDraft(draftFrom(saved.settings));
await reload();
});
const deployed = data?.deployed;
const effective = data?.effective;
const levels = data?.logLevels ?? [];
const notificationDisplays = data?.notificationDisplays ?? ['everywhere', 'home_only', 'off'];
return (
<>
<Banner message={error} />
{loading || !draft || !deployed || !effective ? (
<Loading rows={2} />
) : (
<>
<Card
title="This gateway"
intro="The server process this console is served by, and what it currently believes."
icon="chip"
tone="info"
actions={<Tag tone="info">{data?.version ?? 'unknown'}</Tag>}
>
<KeyValue
rows={[
{ label: 'Server timezone', value: effective.timezone || 'not set' },
{ label: 'Log level', value: effective.logLevel },
{ label: 'Sign-in expiry', value: describe(effective.sessionIdleDays, 'day') },
{ label: 'Emby health probe', value: describe(effective.embyHealthSeconds, 'second') },
{ label: 'Slow-request breakdown', value: describe(effective.slowRequestMillis, 'millisecond') },
{ label: 'Catalogue sweep', value: describe(effective.librarySyncMinutes, 'minute') },
{ label: 'Home cache lifetime', value: describe(effective.homeTtlSeconds, 'second') },
{ label: 'Recommendation cache lifetime', value: describe(effective.recommendTtlHours, 'hour') },
{ label: 'For You rebuild hour', value: `${effective.forYouRebuildHour}:00` },
{ label: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
{ label: 'Notification display', value: notificationDisplayLabel(effective.notificationDisplay) },
]}
/>
</Card>
<Card
title="Server settings"
intro="Environment-backed fields can be left empty to use the deployed value shown beneath them. Changes take effect immediately — nothing here needs a restart."
icon="sliders"
tone="note"
footer={
<>
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save settings
</Button>
<Button busy={busy === 'clear'} onClick={() => void clearAll()}>
Use defaults
</Button>
</>
}
>
<div className="fields">
<Field
label="Server timezone"
hint={`Deployed: ${deployed.timezone || 'not set'}. An IANA name, for example Pacific/Auckland. Decides what "today" means for the schedule rows, the home hero and the sign-in history.`}
>
<input
type="text"
value={draft.timezone}
placeholder={deployed.timezone}
onChange={(event) => set('timezone', event.target.value)}
/>
</Field>
<Field
label="Log level"
hint={`Deployed: ${deployed.logLevel}. Applies to the running process at once, so debug can be turned on to watch something happen.`}
>
<select value={draft.logLevel} onChange={(event) => set('logLevel', event.target.value)}>
<option value="">Deployed ({deployed.logLevel})</option>
{levels.map((level) => (
<option key={level} value={level}>{level}</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Sign a television out after (days)"
hint={`Deployed: ${deployed.sessionIdleDays} days. A session row holds a live Emby token, so this is how long a set nobody uses keeps working credentials.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sessionIdleDays}
placeholder={String(deployed.sessionIdleDays)}
onChange={(event) => set('sessionIdleDays', event.target.value)}
/>
</Field>
<Field
label="Slow-request breakdown above (ms)"
hint={`Deployed: ${deployed.slowRequestMillis || 'off'}. A request slower than this logs where its time went — Emby, Postgres, Redis, row assembly. Lower it while chasing one slow screen; type off to stop annotating altogether.`}
>
<input
type="text"
inputMode="numeric"
value={draft.slowRequestMillis}
placeholder={String(deployed.slowRequestMillis)}
onChange={(event) => set('slowRequestMillis', event.target.value)}
/>
</Field>
<Field
label="Emby health probe (seconds)"
hint={`Deployed: ${deployed.embyHealthSeconds || 'off'}. How often the gateway asks Emby whether it is answering. Type off to stop probing, which also removes the outage bar from every television.`}
>
<input
type="text"
inputMode="numeric"
value={draft.embyHealthSeconds}
placeholder={String(deployed.embyHealthSeconds)}
onChange={(event) => set('embyHealthSeconds', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Catalogue sweep (minutes)"
hint={`Deployed: ${deployed.librarySyncMinutes || 'off'}. How often the gateway asks Emby what has changed. With the Sonarr and Radarr webhooks wired up a new file is in the catalogue within a minute of landing, and this is only reconciliation for media they do not manage — 360 is a sensible choice then. Without them it is the only way anything is found.`}
>
<input
type="text"
inputMode="numeric"
value={draft.librarySyncMinutes}
placeholder={String(deployed.librarySyncMinutes)}
onChange={(event) => set('librarySyncMinutes', event.target.value)}
/>
</Field>
<Field
label="Home cache lifetime (seconds)"
hint={`Deployed: ${deployed.homeTtlSeconds}. How long a built launcher payload is served before it is rebuilt. Lower for fresher rows at the cost of more Emby traffic; it cannot be switched off.`}
>
<input
type="text"
inputMode="numeric"
value={draft.homeTtlSeconds}
placeholder={String(deployed.homeTtlSeconds)}
onChange={(event) => set('homeTtlSeconds', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Recommendation cache lifetime (hours)"
hint={`Deployed: ${deployed.recommendTtlHours}. How long a personalised recommendation pool stays warm. Long by design — the rotation within it is daily.`}
>
<input
type="text"
inputMode="numeric"
value={draft.recommendTtlHours}
placeholder={String(deployed.recommendTtlHours)}
onChange={(event) => set('recommendTtlHours', event.target.value)}
/>
</Field>
<Field
label="For You rebuild hour (023)"
hint={`Deployed: ${deployed.forYouRebuildHour}:00. The household-local hour the daily For You rebuild is due at. Leave empty for the deployed hour.`}
>
<input
type="text"
inputMode="numeric"
value={draft.forYouRebuildHour}
placeholder={String(deployed.forYouRebuildHour)}
onChange={(event) => set('forYouRebuildHour', event.target.value)}
/>
</Field>
</div>
<div className="fields">
<Field
label="Notification display"
hint="Controls informational banners for every viewer and television. Everywhere also permits playback overlays; Home only keeps them off active movies, episodes and trailers; Off hides them throughout Memby."
>
<select
value={draft.notificationDisplay}
onChange={(event) => set('notificationDisplay', event.target.value)}
>
{notificationDisplays.map((value) => (
<option key={value} value={value}>{notificationDisplayLabel(value)}</option>
))}
</select>
</Field>
</div>
<div className="fields">
<Field
label="Episode alert window (minutes)"
hint={`Deployed: ${deployed.sonarrAlertMinutes || 'off'}. How long a "just aired" notice stays on offer to a set that was switched off at the time. Type off to stop announcing them.`}
>
<input
type="text"
inputMode="numeric"
value={draft.sonarrAlertMinutes}
placeholder={String(deployed.sonarrAlertMinutes)}
onChange={(event) => set('sonarrAlertMinutes', event.target.value)}
/>
</Field>
<Field
label="Film alert window (minutes)"
hint={`Deployed: ${deployed.radarrAlertMinutes || 'off'}. The same, for a film Radarr has just imported.`}
>
<input
type="text"
inputMode="numeric"
value={draft.radarrAlertMinutes}
placeholder={String(deployed.radarrAlertMinutes)}
onChange={(event) => set('radarrAlertMinutes', event.target.value)}
/>
</Field>
</div>
<Note tone="note">
These settings live in the database and survive a restart. A deployment rewrites
<code>.env</code>, not this document, so environment-backed values can then disagree;
permanent environment changes belong in <code>.env.example</code> as well.
</Note>
{data?.settings.updatedBy ? (
<Note>
Last changed by {data.settings.updatedBy}
{data.settings.updatedAt ? ` on ${new Date(data.settings.updatedAt).toLocaleString('en-NZ')}` : ''}.
</Note>
) : null}
</Card>
</>
)}
</>
);
}
+2 -3
View File
@@ -232,9 +232,8 @@ export function Layout() {
<span className="account-avatar account-avatar-large" aria-hidden="true">{initial}</span>
<span><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
+31 -1
View File
@@ -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}
+32
View File
@@ -91,6 +91,38 @@ export function Grid({ cols, children }: { cols?: '2' | 'wide'; children: ReactN
);
}
/** Tabs splits one page into a handful of named views, anchored on a hairline under the
* page head. It is for a page that is genuinely several things about one subject — the
* client control plane, its rows, its server settings — where a single scroll buries the
* section somebody came for. Segments is the control for an exclusive *choice*; this is
* navigation within a page. The active tab rides `?tab=` so a view is linkable. */
export function Tabs<T extends string>({
tabs,
active,
onChange,
}: {
tabs: readonly { id: T; label: string; icon?: IconName }[];
active: T;
onChange: (id: T) => void;
}) {
return (
<nav className="tabs" role="tablist">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={tab.id === active}
onClick={() => onChange(tab.id)}
>
{tab.icon ? <Icon name={tab.icon} /> : null}
{tab.label}
</button>
))}
</nav>
);
}
/* ---------- tiles ---------- */
export interface TileSpec {
+8 -2
View File
@@ -44,6 +44,13 @@ export const nav: NavGroup[] = [
item('journey-viewer', '/admin/journeys/:userId', 'Journey', 'User journey', 'One viewers journey through Memby.', 'journey', { hidden: true }),
],
},
{
id: 'config', label: 'Configuration', defaultCollapsed: false, collapsible: false,
items: [
item('features', '/admin/features', 'Client configuration', 'Client configuration',
'Everything the thin TV client renders, and the gateways own server-level settings.', 'sliders'),
],
},
{
id: 'history', label: 'History', defaultCollapsed: false,
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 }),
],
},
];
+15 -1
View File
@@ -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',
+15 -2
View File
@@ -7,7 +7,8 @@ import { ago, initials, num, presence, recent, watchTime, when } from '../lib/fo
import { Banner, Button, Card, Confirm, EmptyRow, Loading, PageHead, TableWrap, Tag, Tiles, Toggle } from '../components/ui';
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
View File
@@ -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
+193 -9
View File
@@ -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>
</>
+7 -326
View File
@@ -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 />
</>
);
}
+41
View File
@@ -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;
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.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(
+1
View File
@@ -74,6 +74,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
mux.Handle("POST /admin/api/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))
+2 -2
View File
@@ -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:
+16 -1
View File
@@ -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{
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The console's window on what the household has actually asked for.
//
// The overview already reports RequestUsage — a count and a last-asked time per viewer —
// which answers "who uses the feature" and nothing about "what did they ask for". This is
// the table underneath that number: every stored ask, newest first, with the person
// attached and the same derived status the viewer's own page shows, so an operator can see
// that three people are waiting on the same film or that a request has been stuck
// searching for a week.
const adminRequestsLimit = store.MediaRequestSweepLimit
// adminMediaRequest is one row of that table: an ask, who made it, and what has become of
// it. The status half is derived per read by decorateRequests, exactly as it is for the
// viewer, so the console and the television never disagree about a title's state.
type adminMediaRequest struct {
UserID string `json:"userId"`
Username string `json:"username,omitempty"`
myRequest
}
type adminRequestsResponse struct {
Requests []adminMediaRequest `json:"requests"`
}
func (s *Server) handleAdminRequests(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
owned, err := s.store.AllMediaRequests(ctx, adminRequestsLimit)
if err != nil {
s.loggerFor(ctx).Error("admin media requests read failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read media requests")
return
}
// An optional filter to what one viewer asked for, applied here rather than in SQL: the
// list is already bounded and small, and AllMediaRequests is the one read the ready
// sweep also uses.
if userID := strings.TrimSpace(r.URL.Query().Get("userId")); userID != "" {
filtered := owned[:0]
for _, req := range owned {
if req.UserID == userID {
filtered = append(filtered, req)
}
}
owned = filtered
}
stored := make([]store.MediaRequest, len(owned))
for i := range owned {
stored[i] = owned[i].MediaRequest
}
// One decoration pass for the whole household. The *arr catalogues it consults are
// cached for the day and shared, so this is the same cost as one viewer opening their
// page. The console is the operator's tool, so it always sees the full progress
// vocabulary.
cards := s.decorateRequests(ctx, stored, true)
// A name an ask cannot be attributed to costs the column and nothing else — the id
// still tells one requester from another, and the titles are the point of the page.
names := map[string]string{}
if users, err := s.store.KnownUsers(ctx); err == nil {
for _, user := range users {
if user.Username != "" {
names[user.ID] = user.Username
}
}
} else {
s.loggerFor(ctx).Warn("media request owners unresolved", "error", err)
}
rows := make([]adminMediaRequest, len(owned))
for i := range owned {
rows[i] = adminMediaRequest{
UserID: owned[i].UserID,
Username: names[owned[i].UserID],
myRequest: cards[i],
}
}
writeJSON(w, http.StatusOK, adminRequestsResponse{Requests: rows})
}
+22
View File
@@ -947,4 +947,26 @@ func TestToRowEventValidatesAndClamps(t *testing.T) {
t.Fatalf("user should come from the session, got %q", event.UserID)
}
})
t.Run("rejects free text in the controlled fields", func(t *testing.T) {
cases := []rowEventPayload{
{RowID: "row with spaces", Event: "focus"},
{RowID: "r", RowKind: "MOVIES; drop table", Event: "focus"},
{RowID: "r", Event: "focus", ItemID: "not/an/id"},
{RowID: strings.Repeat("x", 101), Event: "focus"},
}
for _, payload := range cases {
if _, ok := toRowEvent(payload, "u", now); ok {
t.Fatalf("expected %+v to be dropped", payload)
}
}
})
t.Run("keeps a well-formed curated row id", func(t *testing.T) {
event, ok := toRowEvent(
rowEventPayload{RowID: "curated:movies:genre:science-fiction", RowKind: "movies", Event: "impression"}, "u", now)
if !ok || event.RowID != "curated:movies:genre:science-fiction" {
t.Fatalf("a normal composed row id should pass, got ok=%v event=%+v", ok, event)
}
})
}
+9
View File
@@ -210,6 +210,15 @@ func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.Ro
if payload.RowID == "" {
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:
+5 -2
View File
@@ -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,
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"net"
"net/http"
"net/netip"
"strings"
"github.com/ponzischeme89/memby/server/internal/config"
)
// clientIP is where a request came from and how that was worked out. Via is one of
// "forwarded", "real-ip", "socket" or "none". It is for operational logging and the
// admin console's directory only — never authentication or access control.
type clientIP struct {
Addr string
Via string
}
// String is the address, or "unknown" when none could be determined, matching what the
// login history and the trailer log previously recorded.
func (c clientIP) String() string {
if c.Addr == "" {
return "unknown"
}
return c.Addr
}
// resolveClientIP works out the originating address of an incoming request. It believes
// X-Forwarded-For and X-Real-IP only when the immediate peer is a configured trusted
// proxy, so a client that reaches the gateway directly cannot spoof its address with a
// header.
func (s *Server) resolveClientIP(r *http.Request) clientIP {
trusted := s.cfg.TrustedProxies
if trusted == nil {
trusted = config.DefaultTrustedProxyRanges()
}
return clientIPFrom(r.RemoteAddr, r.Header, trusted)
}
func clientIPFrom(remoteAddr string, header http.Header, trusted []netip.Prefix) clientIP {
socket := parseHostAddr(remoteAddr)
if !socket.IsValid() {
return clientIP{Via: "none"}
}
fromSocket := clientIP{Addr: socket.String(), Via: "socket"}
if !prefixesContain(trusted, socket) {
// The peer is not a known proxy, so nothing it forwarded is believed.
return fromSocket
}
// X-Forwarded-For grows by one entry per hop, so the rightmost is the nearest
// proxy. Walking right to left, the first entry that is not itself a trusted proxy
// is the originating client.
forwarded := forwardedChain(header)
for i := len(forwarded) - 1; i >= 0; i-- {
if !prefixesContain(trusted, forwarded[i]) {
return clientIP{Addr: forwarded[i].String(), Via: "forwarded"}
}
}
// Every forwarded hop was itself trusted. If there were any, the leftmost is the
// genuine origin — a direct LAN client behind the household proxy. Otherwise fall
// back to a single X-Real-IP, then to the socket.
if len(forwarded) > 0 {
return clientIP{Addr: forwarded[0].String(), Via: "forwarded"}
}
if realIP := parseAddr(header.Get("X-Real-IP")); realIP.IsValid() {
return clientIP{Addr: realIP.String(), Via: "real-ip"}
}
return fromSocket
}
func parseHostAddr(remoteAddr string) netip.Addr {
remoteAddr = strings.TrimSpace(remoteAddr)
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
remoteAddr = host
}
return parseAddr(remoteAddr)
}
func parseAddr(value string) netip.Addr {
addr, err := netip.ParseAddr(strings.TrimSpace(value))
if err != nil {
return netip.Addr{}
}
return addr.Unmap()
}
func forwardedChain(header http.Header) []netip.Addr {
var out []netip.Addr
for _, value := range header.Values("X-Forwarded-For") {
for _, part := range strings.Split(value, ",") {
if addr := parseAddr(part); addr.IsValid() {
out = append(out, addr)
}
}
}
return out
}
func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"net/http"
"net/netip"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestClientIPFrom(t *testing.T) {
trusted := config.DefaultTrustedProxyRanges()
tests := []struct {
name string
remoteAddr string
headers map[string]string
wantAddr string
wantVia string
}{
{
name: "direct public client, no proxy headers believed",
remoteAddr: "203.0.113.9:52344",
headers: map[string]string{"X-Forwarded-For": "10.0.0.9"},
wantAddr: "203.0.113.9",
wantVia: "socket",
},
{
name: "through the household proxy, real client in X-Forwarded-For",
remoteAddr: "10.0.0.2:41000",
headers: map[string]string{"X-Forwarded-For": "203.0.113.42, 10.0.0.2"},
wantAddr: "203.0.113.42",
wantVia: "forwarded",
},
{
name: "direct LAN client behind the proxy still shows its LAN address",
remoteAddr: "10.0.0.2:41000",
headers: map[string]string{"X-Forwarded-For": "10.0.0.50"},
wantAddr: "10.0.0.50",
wantVia: "forwarded",
},
{
name: "proxy sets only X-Real-IP",
remoteAddr: "192.168.1.1:8443",
headers: map[string]string{"X-Real-IP": "198.51.100.7"},
wantAddr: "198.51.100.7",
wantVia: "real-ip",
},
{
name: "trusted proxy with no forwarding headers",
remoteAddr: "127.0.0.1:5000",
wantAddr: "127.0.0.1",
wantVia: "socket",
},
{
name: "spoofed X-Real-IP from an untrusted client is ignored",
remoteAddr: "203.0.113.9:1000",
headers: map[string]string{"X-Real-IP": "10.0.0.1"},
wantAddr: "203.0.113.9",
wantVia: "socket",
},
{
name: "unparseable remote address",
remoteAddr: "garbage",
wantAddr: "unknown",
wantVia: "none",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
header := http.Header{}
for key, value := range test.headers {
header.Set(key, value)
}
got := clientIPFrom(test.remoteAddr, header, trusted)
if got.String() != test.wantAddr || got.Via != test.wantVia {
t.Fatalf("clientIPFrom = %+v, want addr %q via %q", got, test.wantAddr, test.wantVia)
}
})
}
}
func TestClientIPFromTrustsNothingWhenListEmpty(t *testing.T) {
header := http.Header{"X-Forwarded-For": {"203.0.113.1"}}
got := clientIPFrom("10.0.0.2:5000", header, []netip.Prefix{})
if got.String() != "10.0.0.2" || got.Via != "socket" {
t.Fatalf("clientIPFrom with no trusted proxies = %+v, want 10.0.0.2 via socket", got)
}
}
+9 -5
View File
@@ -89,11 +89,11 @@ func intPtr(v int) *int { return &v }
// once, here, rather than the admin console guessing at it independently and drifting from
// 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")
}
+31
View File
@@ -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 {
+27 -1
View File
@@ -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)
}
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"context"
"encoding/json"
"strings"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
// sectionDefinitionKeys are the three configuration values that hold a page's row
// composition. A layout override can be set on any of them, and the home payload is the
// single row source for all three browse destinations on the television, so all three are
// merged into one lookup.
var sectionDefinitionKeys = []string{
"home.sectionDefinitions",
"movies.sectionDefinitions",
"tv.sectionDefinitions",
}
// rowLayoutOverrides collects every operator-pinned card shape from the section
// definitions, keyed by the definition id. An invalid or empty layout is not an override.
func rowLayoutOverrides(policy store.FeaturePolicy) map[string]string {
overrides := map[string]string{}
for _, key := range sectionDefinitionKeys {
raw, ok := policy.Values[key]
if !ok {
continue
}
var definitions []config.RemoteSectionDefinition
if json.Unmarshal(raw, &definitions) != nil {
continue
}
for _, definition := range definitions {
switch definition.Layout {
case config.SectionLayoutPoster, config.SectionLayoutThumb:
id := strings.TrimSpace(definition.ID)
if id != "" {
overrides[id] = definition.Layout
}
}
}
}
return overrides
}
// applyRowLayoutOverrides stamps the pinned card shape onto each finished row. A row is
// matched by its exact id first, then by a section id it is a child of ("for-you" pins
// "for-you:home:evening" too) — the same rule applyRemoteHomeSections uses on the
// television to rank a family of rows together. A row that already carries a layout (none
// do today) is left alone. Pure, so it can be pinned by a test.
func applyRowLayoutOverrides(rows []recommend.Row, overrides map[string]string) []recommend.Row {
if len(overrides) == 0 {
return rows
}
for index := range rows {
if rows[index].Layout != "" {
continue
}
if layout, ok := overrides[rows[index].ID]; ok {
rows[index].Layout = layout
continue
}
for section, layout := range overrides {
if strings.HasPrefix(rows[index].ID, section+":") {
rows[index].Layout = layout
break
}
}
}
return rows
}
func (s *Server) applyRowLayouts(ctx context.Context, rows []recommend.Row) []recommend.Row {
return applyRowLayoutOverrides(rows, rowLayoutOverrides(s.currentFeaturePolicy(ctx)))
}
@@ -0,0 +1,61 @@
package api
import (
"encoding/json"
"testing"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestRowLayoutOverridesReadsEveryPage(t *testing.T) {
policy := store.FeaturePolicy{Values: map[string]json.RawMessage{
"home.sectionDefinitions": json.RawMessage(`[
{"id":"favorites","type":"favorites","title":"Favourites","component":"mediaRow","layout":"thumb"},
{"id":"continue","type":"continueWatching","title":"Continue","component":"mediaRow"},
{"id":"broken","type":"custom","title":"x","component":"mediaRow","layout":"sideways"}
]`),
"movies.sectionDefinitions": json.RawMessage(`[
{"id":"library","type":"library","title":"Library","component":"mediaGrid","layout":"poster"}
]`),
}}
got := rowLayoutOverrides(policy)
if got["favorites"] != "thumb" {
t.Fatalf("favorites layout = %q, want thumb", got["favorites"])
}
if got["library"] != "poster" {
t.Fatalf("library layout = %q, want poster", got["library"])
}
if _, ok := got["continue"]; ok {
t.Fatalf("a row with no layout must not appear as an override")
}
if _, ok := got["broken"]; ok {
t.Fatalf("an invalid layout must not appear as an override")
}
}
func TestApplyRowLayoutOverridesMatchesExactAndChildRows(t *testing.T) {
rows := []recommend.Row{
{ID: "favorites"},
{ID: "for-you"},
{ID: "for-you:home:evening"},
{ID: "latest-movies", Layout: "poster"},
}
out := applyRowLayoutOverrides(rows, map[string]string{"favorites": "thumb", "for-you": "poster"})
if out[0].Layout != "thumb" {
t.Fatalf("exact match not applied: %q", out[0].Layout)
}
if out[1].Layout != "poster" || out[2].Layout != "poster" {
t.Fatalf("a section id must pin its child rows too: %q / %q", out[1].Layout, out[2].Layout)
}
if out[3].Layout != "poster" {
t.Fatalf("a row that already carries a layout must be left alone: %q", out[3].Layout)
}
}
func TestApplyRowLayoutOverridesNoOpWithoutOverrides(t *testing.T) {
rows := []recommend.Row{{ID: "favorites"}}
if applyRowLayoutOverrides(rows, nil)[0].Layout != "" {
t.Fatalf("no overrides must leave rows untouched")
}
}
+1 -1
View File
@@ -283,7 +283,7 @@ func (s *Server) runForYouRebuild(ctx context.Context) (scheduler.Outcome, error
if err != nil {
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)
-26
View File
@@ -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"
}
-9
View File
@@ -73,15 +73,6 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
}
}
func TestRequestClientIPPrefersOriginalForwardedAddress(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items/42/trailers/report", nil)
request.RemoteAddr = "10.0.0.2:41234"
request.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.2")
if got := requestClientIP(request); got != "203.0.113.9" {
t.Fatalf("client ip = %q", got)
}
}
func TestIdentifyNamesTheViewerAndTelevision(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
request.Header.Set("X-Memby-Version", "0.1.60")
+7 -1
View File
@@ -31,7 +31,13 @@ func (s *Server) recordLogin(r *http.Request, event store.LoginEvent) {
return
}
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)
+19
View File
@@ -248,6 +248,25 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
)
// 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{
+1 -1
View File
@@ -63,7 +63,7 @@ func (s *Server) buildRecommendations(ctx context.Context, sess store.Session) (
// An empty result is cached too: a user with no history should not trigger a full
// 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)
}
}
+25
View File
@@ -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,
+179
View File
@@ -0,0 +1,179 @@
package api
import (
"context"
"encoding/json"
"net/url"
"strings"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Pinning a freshly arrived request to the front of Continue Watching.
//
// The notification (requests_ready.go) tells the person who asked that their title has
// landed; this is the other half — putting it where they actually look for something to
// watch. An unwatched film never appears in Continue Watching on its own and a new series
// only shows up if they go hunting, so without this the request journey ends a step short of
// its payoff.
//
// It is deliberately cheap. The only new work on the Home path is one indexed Postgres read
// (SurfacedReadyRequests), and — only when that read is non-empty, which is rare — one
// batched Emby item lookup for the titles not already in the row. No Radarr or Sonarr call
// is ever made here.
const (
// requestReadyField and requestReadyLabelField are what the television reads. The label
// is the server's wording (the MembyAirLabel precedent), so renaming it needs no app
// release; a client that predates the field falls back to its own constant.
requestReadyField = "MembyRequestReady"
requestReadyLabelField = "MembyRequestReadyLabel"
// requestReadyLabel is the tag drawn on the card.
requestReadyLabel = "REQUEST READY"
)
// injectRequestReady tags or prepends the viewer's still-pinned request arrivals in the
// merged Continue Watching list.
//
// - A surfaced title already in the row (the film itself, or the series an episode belongs
// to) is tagged in place — no second card.
// - One not in the row is fetched from Emby and prepended, newest arrival first.
//
// Any failure degrades to the list untouched: the person was already told by notification,
// and a missing pin is a smaller disappointment than a broken launcher.
func (s *Server) injectRequestReady(
ctx context.Context,
cred emby.Credentials,
items []json.RawMessage,
surfaced []store.SurfacedReadyRequest,
) []json.RawMessage {
if len(surfaced) == 0 {
return items
}
missing := missingRequestReady(items, surfaced)
if len(missing) == 0 {
return mergeRequestReady(items, surfaced, nil)
}
ids := make([]string, 0, len(missing))
for _, req := range missing {
ids = append(ids, req.ItemID)
}
fetched, err := s.emby.Items(ctx, cred, rowParams(url.Values{
"Ids": {strings.Join(ids, ",")},
"Recursive": {"true"},
}, fieldsContinue))
if err != nil {
s.loggerFor(ctx).Warn("request ready items unavailable", "error", err)
// The titles already in the row are still worth tagging even when the fetch for the
// rest fails.
return mergeRequestReady(items, surfaced, nil)
}
byID := make(map[string]json.RawMessage, len(fetched.Items))
for _, raw := range fetched.Items {
if id, _, _, _ := continueItemFields(raw, nil); id != "" {
byID[id] = raw
}
}
return mergeRequestReady(items, surfaced, byID)
}
// requestReadyIndex maps every item and series id already in the row to its position.
func requestReadyIndex(items []json.RawMessage) map[string]int {
present := make(map[string]int, len(items))
for index, raw := range items {
id, seriesID, _, _ := continueItemFields(raw, nil)
if id != "" {
present[id] = index
}
if seriesID != "" {
if _, ok := present[seriesID]; !ok {
present[seriesID] = index
}
}
}
return present
}
// missingRequestReady is the surfaced arrivals whose title is not already in the row.
func missingRequestReady(
items []json.RawMessage, surfaced []store.SurfacedReadyRequest,
) []store.SurfacedReadyRequest {
present := requestReadyIndex(items)
missing := make([]store.SurfacedReadyRequest, 0, len(surfaced))
for _, req := range surfaced {
if _, ok := present[req.ItemID]; !ok {
missing = append(missing, req)
}
}
return missing
}
// mergeRequestReady is the pure half: stamp the arrivals already in the row in place, and
// prepend the rest from `fetched` (newest first), stamped. An arrival with no fetched item
// is simply left out — the notification already carried the news.
func mergeRequestReady(
items []json.RawMessage,
surfaced []store.SurfacedReadyRequest,
fetched map[string]json.RawMessage,
) []json.RawMessage {
out := make([]json.RawMessage, len(items))
copy(out, items)
present := requestReadyIndex(out)
var missing []store.SurfacedReadyRequest
for _, req := range surfaced {
if index, ok := present[req.ItemID]; ok {
out[index] = stampRequestReady(out[index])
continue
}
missing = append(missing, req)
}
// surfaced is ordered newest first, so this block keeps that order and the whole block
// goes in front of the existing row.
prepend := make([]json.RawMessage, 0, len(missing))
for _, req := range missing {
if raw, ok := fetched[req.ItemID]; ok {
prepend = append(prepend, stampRequestReady(raw))
}
}
if len(prepend) == 0 {
return out
}
return append(prepend, out...)
}
// stampRequestReady sets the two tag fields on one item's JSON, leaving everything else
// alone. It mirrors decorateItemRatings' approach — decode to a map, set, re-encode — so a
// field the row does not model is preserved.
func stampRequestReady(raw json.RawMessage) json.RawMessage {
var item map[string]any
if err := json.Unmarshal(raw, &item); err != nil || item == nil {
return raw
}
item[requestReadyField] = true
item[requestReadyLabelField] = requestReadyLabel
if stamped, err := json.Marshal(item); err == nil {
return stamped
}
return raw
}
// invalidateHomeFor drops the cached Home payload for each named viewer, best-effort. Used
// wherever a request pin is created or retired outside a Home request itself — the ready
// sweep and the playback handler.
func (s *Server) invalidateHomeFor(ctx context.Context, userIDs ...string) {
if s.cache == nil {
return
}
for _, userID := range userIDs {
if userID == "" {
continue
}
if err := s.cache.InvalidateUser(ctx, userID); err != nil {
s.loggerFor(ctx).Warn("home cache not invalidated", "user_id", userID, "error", err)
}
}
}
@@ -0,0 +1,119 @@
package api
import (
"encoding/json"
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
)
func readyReq(itemID, mediaType string) store.SurfacedReadyRequest {
return store.SurfacedReadyRequest{MediaType: mediaType, ItemID: itemID, Title: itemID}
}
func isStamped(t *testing.T, raw json.RawMessage) bool {
t.Helper()
var item struct {
Ready bool `json:"MembyRequestReady"`
Label string `json:"MembyRequestReadyLabel"`
}
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return item.Ready && item.Label == requestReadyLabel
}
// The overwhelmingly common case: nobody is waiting on anything, so the row is untouched.
func TestMergeRequestReadyNoArrivalsIsANoOp(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
got := mergeRequestReady(items, nil, nil)
if !equalIDs(mergedIDs(t, got), []string{"film"}) {
t.Fatalf("ids = %v", mergedIDs(t, got))
}
if isStamped(t, got[0]) {
t.Fatal("item stamped with no arrivals")
}
}
// An arrival not in the row is prepended and tagged.
func TestMergeRequestReadyPrependsMissingTitle(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
surfaced := []store.SurfacedReadyRequest{readyReq("new-movie", "movie")}
fetched := map[string]json.RawMessage{
"new-movie": continueRaw(t, map[string]any{"Id": "new-movie", "Type": "Movie"}),
}
got := mergeRequestReady(items, surfaced, fetched)
if want := []string{"new-movie", "film"}; !equalIDs(mergedIDs(t, got), want) {
t.Fatalf("ids = %v, want %v", mergedIDs(t, got), want)
}
if !isStamped(t, got[0]) {
t.Fatal("prepended card not stamped")
}
if isStamped(t, got[1]) {
t.Fatal("existing card wrongly stamped")
}
}
// Newest arrival first when several are prepended (surfaced is ordered newest first).
func TestMergeRequestReadyKeepsNewestArrivalFirst(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
surfaced := []store.SurfacedReadyRequest{readyReq("newer", "movie"), readyReq("older", "movie")}
fetched := map[string]json.RawMessage{
"newer": continueRaw(t, map[string]any{"Id": "newer", "Type": "Movie"}),
"older": continueRaw(t, map[string]any{"Id": "older", "Type": "Movie"}),
}
got := mergedIDs(t, mergeRequestReady(items, surfaced, fetched))
if want := []string{"newer", "older", "film"}; !equalIDs(got, want) {
t.Fatalf("ids = %v, want %v", got, want)
}
}
// A surfaced title already in Continue Watching — by its own id, or as the series an
// episode belongs to — is tagged in place, never duplicated.
func TestMergeRequestReadyTagsExistingCardInPlace(t *testing.T) {
items := []json.RawMessage{
resumeItem(t, "s1e3", "severance", "2026-08-06T19:00:00Z"),
resumeItem(t, "the-film", "", "2026-08-01T20:00:00Z"),
}
surfaced := []store.SurfacedReadyRequest{
readyReq("severance", "series"),
readyReq("the-film", "movie"),
}
got := mergeRequestReady(items, surfaced, nil)
if want := []string{"s1e3", "the-film"}; !equalIDs(mergedIDs(t, got), want) {
t.Fatalf("ids = %v, want %v", mergedIDs(t, got), want)
}
if !isStamped(t, got[0]) || !isStamped(t, got[1]) {
t.Fatal("existing cards not tagged in place")
}
}
// A fetch that returned nothing for an arrival degrades to leaving it out rather than
// inserting a blank card.
func TestMergeRequestReadyDropsUnfetchableArrival(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
surfaced := []store.SurfacedReadyRequest{readyReq("gone", "movie")}
got := mergeRequestReady(items, surfaced, map[string]json.RawMessage{})
if !equalIDs(mergedIDs(t, got), []string{"film"}) {
t.Fatalf("ids = %v", mergedIDs(t, got))
}
}
func TestStampRequestReadyPreservesOtherFields(t *testing.T) {
raw := continueRaw(t, map[string]any{"Id": "x", "Name": "Keep me", "Type": "Movie"})
stamped := stampRequestReady(raw)
var item struct {
Name string `json:"Name"`
Ready bool `json:"MembyRequestReady"`
}
if err := json.Unmarshal(stamped, &item); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if item.Name != "Keep me" || !item.Ready {
t.Fatalf("stamp lost fields: %+v", item)
}
}
+3 -1
View File
@@ -155,11 +155,13 @@ func (s *Server) handleTrailerReport(w http.ResponseWriter, r *http.Request, ses
writeError(w, http.StatusBadRequest, "invalid trailer report")
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)
+4
View File
@@ -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)
}
+86 -16
View File
@@ -59,6 +59,53 @@ func previousMonth(now time.Time, location *time.Location) (from, to time.Time,
return from, to, from.Format("2006-01")
}
// 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,
}
+42 -1
View File
@@ -185,6 +185,47 @@ func TestWatchTimeIsAttributedByRecordedIdentityBeforeName(t *testing.T) {
}
}
func TestPriorWeekToDateMatchesThePointThisWeekHasReached(t *testing.T) {
location := auckland(t)
// Wednesday 19 August 2026, mid-evening.
now := time.Date(2026, 8, 19, 21, 15, 0, 0, location)
from, to := priorWeekToDate(now, location)
if got, want := from.Format("2006-01-02 15:04"), "2026-08-10 00:00"; got != want {
t.Fatalf("prior week from = %s, want %s", got, want)
}
if got, want := to.Format("2006-01-02 15:04"), "2026-08-12 21:15"; got != want {
t.Fatalf("prior week to = %s, want %s (same weekday and time, a week back)", got, want)
}
}
func TestWeekOverWeek(t *testing.T) {
minute := int64(60 * 1000)
tests := []struct {
name string
current, prior int64
wantDir string
}{
{"nothing either week", 0, 0, "none"},
{"first watching this week", 90 * minute, 0, "up"},
{"stopped watching", 0, 90 * minute, "down"},
{"a few minutes more is still steady", 63 * minute, 60 * minute, "steady"},
{"well up on last week", 180 * minute, 60 * minute, "up"},
{"well down on last week", 20 * minute, 120 * minute, "down"},
{"identical", 45 * minute, 45 * minute, "steady"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := weekOverWeek(test.current, test.prior)
if got.Direction != test.wantDir {
t.Fatalf("weekOverWeek(%d, %d) = %q, want %q", test.current, test.prior, got.Direction, test.wantDir)
}
if got.Direction != "none" && got.DeltaMs != test.current-test.prior {
t.Fatalf("delta = %d, want %d", got.DeltaMs, test.current-test.prior)
}
})
}
}
func TestWatchTimeFallsBackToACaseInsensitiveName(t *testing.T) {
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")
}
}
+72
View File
@@ -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 == "" {
+52
View File
@@ -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")
+24 -10
View File
@@ -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
+4
View File
@@ -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
+22
View File
@@ -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 (023) the daily For You rebuild is due
// at. It is a pointer because 0 is a legitimate hour, so nil — not zero — is what means
// "whatever was deployed".
ForYouRebuildHour *int `json:"forYouRebuildHour,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
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) {
+141
View File
@@ -180,6 +180,147 @@ func (s *Store) AllMediaRequests(ctx context.Context, limit int) ([]OwnedMediaRe
// MediaRequestSweepLimit caps what one pass of the ready sweep will look at.
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
+16
View File
@@ -615,6 +615,22 @@ ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS last_status TEXT NOT NULL DE
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
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