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>
+184 -134
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,15 +130,32 @@ 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 ? (
{tab === 'overview' ? (
loading || !policy ? (
<Loading />
) : (
<>
@@ -129,28 +172,6 @@ export function FeaturesPage() {
</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."
@@ -195,110 +216,6 @@ export function FeaturesPage() {
/>
</Card>
<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">
{features.length === 0 ? (
<Card title="Nothing registered" 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>
<Card>
<div className="row">
<Button
@@ -335,8 +252,141 @@ export function FeaturesPage() {
</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)}
/>
<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}
{tab === 'features' ? (
loading || !policy ? (
<Loading />
) : (
<>
{features.length === 0 ? (
<Card title="Feature flags" icon="sliders">
<Empty>No server features are registered.</Empty>
</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>
)}
<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>
) : (
<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>
)}
</Card>
</>
)
) : null}
{tab === 'server' ? <GatewaySettingsSection /> : null}
{pending ? (
<Confirm
title={pending.title}
+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,18 +144,24 @@ 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
// 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,
@@ -138,6 +172,9 @@ class RowAnalytics(
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,13 +323,23 @@ 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
"thumb", "landscape" -> MediaCardFormat.LANDSCAPE
else -> when (artworkStyle) {
"poster" -> MediaCardFormat.PORTRAIT
"backdrop" -> MediaCardFormat.LANDSCAPE
else -> when {
@@ -329,3 +348,4 @@ private fun cardFormat(
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)
}
}
+4
View File
@@ -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)
}
+78 -8
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.
@@ -146,6 +193,10 @@ type watchTimeSummary struct {
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"`
@@ -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")
+14
View File
@@ -91,9 +91,20 @@ type RemoteSectionDefinition struct {
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