0.1.52 Gateway
This commit is contained in:
+3
-2
@@ -85,8 +85,9 @@ MEMBY_SONARR_TTL=5m
|
|||||||
# 0 turns the banners off and leaves the airing-today row alone.
|
# 0 turns the banners off and leaves the airing-today row alone.
|
||||||
MEMBY_SONARR_ALERT_WINDOW=3h
|
MEMBY_SONARR_ALERT_WINDOW=3h
|
||||||
|
|
||||||
# Optional Radarr calendar integration. Upcoming movies are selected strictly from
|
# Optional Radarr calendar integration. Upcoming movies cover the coming month, ordered by
|
||||||
# Radarr's digital release date; theatrical and physical dates are ignored.
|
# Radarr's digital release date. A film with no digital date yet is estimated at its cinema
|
||||||
|
# date plus 30 days and says so on the card; physical dates are ignored.
|
||||||
MEMBY_RADARR_URL=http://10.0.0.2:7878
|
MEMBY_RADARR_URL=http://10.0.0.2:7878
|
||||||
MEMBY_RADARR_API_KEY=d393acb157a44dc2b0e2aede96278ad5
|
MEMBY_RADARR_API_KEY=d393acb157a44dc2b0e2aede96278ad5
|
||||||
MEMBY_RADARR_TTL=5m
|
MEMBY_RADARR_TTL=5m
|
||||||
|
|||||||
@@ -743,6 +743,53 @@ each page read as a pile of unrelated controls. Things to preserve:
|
|||||||
the page that can do something about it. A screen that both summarises and changes state
|
the page that can do something about it. A screen that both summarises and changes state
|
||||||
is where an accidental click lives.
|
is where an accidental click lives.
|
||||||
|
|
||||||
|
**The gateway's own settings are `/admin/settings`**, reached from the account menu in the
|
||||||
|
top bar rather than from the rail — every other page decides what the *televisions* do, and
|
||||||
|
this one is about the server process. It is `store.GatewaySettings` (one `app_settings` row)
|
||||||
|
over `internal/api/gateway_settings.go`, and it holds the household timezone, the log level,
|
||||||
|
the idle sign-out, the two alert windows and the Emby health probe. Things to preserve:
|
||||||
|
|
||||||
|
- **Every field is an override, and `.env` is still the configuration.** Blank means
|
||||||
|
"whatever this container was started with", which the page prints beside each field, so
|
||||||
|
clearing a setting is a real undo rather than a value the operator has to remember. A
|
||||||
|
setting that can legitimately be *off* therefore needs a third value: **-1 is off, 0 is
|
||||||
|
deployed** (`store.GatewaySettingsOff`), because a plain zero would make "turn this off"
|
||||||
|
indistinguishable from "leave it alone".
|
||||||
|
- **Nothing reads the document directly.** The effective-value helpers in
|
||||||
|
`gateway_settings.go` — `householdLocation`, `sessionIdleExpiry`, `sonarrAlertWindow`,
|
||||||
|
`radarrAlertWindow`, `embyHealthInterval` — are the only readers, so adding a setting is
|
||||||
|
one helper beside its config field rather than teaching every call site that an override
|
||||||
|
exists. `s.cfg.SonarrLocation` in particular should not be read directly any more:
|
||||||
|
`householdLocation()` is what makes a timezone change reach the schedule rows, the hero
|
||||||
|
rotation and the sign-in history.
|
||||||
|
- **A setting that is read once at start-up is not a setting.** `WatchEmbyReachability`
|
||||||
|
re-reads its cadence every tick and keeps ticking (slowly) while the probe is off, so
|
||||||
|
switching it back on does not need a restart; the idle sweep reads the expiry inside
|
||||||
|
`Run` rather than closing over it; and the log level is a `*slog.LevelVar` threaded from
|
||||||
|
`main` through `Deps.LogLevel`, applied on save rather than waited for — an operator who
|
||||||
|
has just turned debug on and gone to look at the log must not spend thirty seconds
|
||||||
|
believing it did not work. `deployedLogLevel` is remembered because clearing the
|
||||||
|
override has to restore *something*, and the variable itself has by then been moved.
|
||||||
|
|
||||||
|
**Two things a television does are notifications now**, both in `internal/api/device_activity.go`.
|
||||||
|
`TypeDeviceFirstUse` announces the first time a set opened Memby on a household-local day
|
||||||
|
and `TypeDeviceUpdated` announces one that finished updating itself. Things to preserve:
|
||||||
|
|
||||||
|
- **First use is anchored on `/v1/home`**, not on the auth middleware every call passes
|
||||||
|
through: a set left on overnight polls `/v1/status` every ten seconds, so any-request
|
||||||
|
would announce it at midnight — an event nobody did, at the hour nobody is reading. It is
|
||||||
|
marked *before* the cached response is served, because a household whose rows are still
|
||||||
|
warm from another set has still just been opened by this one.
|
||||||
|
- **The answer comes from the insert.** `store.MarkDeviceDay` is `ON CONFLICT DO NOTHING`
|
||||||
|
on `device_activity_days`, so two televisions racing on the same row cannot both be told
|
||||||
|
they were first. The marks are retired with the device and pruned by their own
|
||||||
|
housekeeping task.
|
||||||
|
- **An update needs a previous version to be an update.** `announceDeviceUpdate` fires from
|
||||||
|
`captureClientIdentity` — the only place a set that updated in place is ever seen, since
|
||||||
|
it never signs in again — and an empty previous version is a build this gateway had never
|
||||||
|
been told the version of, not a version that moved. A *downgrade* is still announced: a
|
||||||
|
sideloaded step backwards is news too.
|
||||||
|
|
||||||
**Explaining a recommendation** is `recommend/explain.go`: `Why(profile, item, limit)` is a
|
**Explaining a recommendation** is `recommend/explain.go`: `Why(profile, item, limit)` is a
|
||||||
pure function turning the learned weights into the phrases a detail page shows. It is kept
|
pure function turning the learned weights into the phrases a detail page shows. It is kept
|
||||||
apart from `Score` on purpose — the scorer decides *order* and may be opaque, this decides
|
apart from `Score` on purpose — the scorer decides *order* and may be opaque, this decides
|
||||||
|
|||||||
-1
File diff suppressed because one or more lines are too long
-12
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,9 +13,9 @@
|
|||||||
rel="icon"
|
rel="icon"
|
||||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
|
||||||
/>
|
/>
|
||||||
<script type="module" crossorigin src="/admin/assets/index-CEU6X4jy.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-CahtXjpP.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
|
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-CASotpHk.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-Cg_z5PGS.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { UpdatesPage } from './pages/Updates';
|
|||||||
import { TasksPage } from './pages/Tasks';
|
import { TasksPage } from './pages/Tasks';
|
||||||
import { IntegrationsPage } from './pages/Integrations';
|
import { IntegrationsPage } from './pages/Integrations';
|
||||||
import { MaintenancePage } from './pages/Maintenance';
|
import { MaintenancePage } from './pages/Maintenance';
|
||||||
|
import { SettingsPage } from './pages/Settings';
|
||||||
import { ImportsPage } from './pages/Imports';
|
import { ImportsPage } from './pages/Imports';
|
||||||
import { LogsPage } from './pages/Logs';
|
import { LogsPage } from './pages/Logs';
|
||||||
import { JourneysPage } from './pages/Journeys';
|
import { JourneysPage } from './pages/Journeys';
|
||||||
@@ -86,6 +87,7 @@ export function App() {
|
|||||||
<Route path="tasks" element={<TasksPage />} />
|
<Route path="tasks" element={<TasksPage />} />
|
||||||
<Route path="integrations" element={<IntegrationsPage />} />
|
<Route path="integrations" element={<IntegrationsPage />} />
|
||||||
<Route path="maintenance" element={<MaintenancePage />} />
|
<Route path="maintenance" element={<MaintenancePage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="imports" element={<ImportsPage />} />
|
<Route path="imports" element={<ImportsPage />} />
|
||||||
<Route path="logs" element={<LogsPage />} />
|
<Route path="logs" element={<LogsPage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -528,3 +528,38 @@ export interface RuntimeStatus {
|
|||||||
memoryLimit: number;
|
memoryLimit: number;
|
||||||
configuredLimit?: string;
|
configuredLimit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- gateway settings ---------- */
|
||||||
|
|
||||||
|
/** GatewaySettings is the operator's overrides. Every field is optional in meaning: an
|
||||||
|
* empty string or a zero means "whatever the container was started with", and -1 means
|
||||||
|
* off for the three that can be switched off. */
|
||||||
|
export interface GatewaySettings {
|
||||||
|
timezone: string;
|
||||||
|
logLevel: string;
|
||||||
|
sessionIdleDays: number;
|
||||||
|
sonarrAlertMinutes: number;
|
||||||
|
radarrAlertMinutes: number;
|
||||||
|
embyHealthSeconds: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GatewaySettingValues is the same vocabulary as plain values, used twice: once for what
|
||||||
|
* the container was deployed with and once for what is actually in force. */
|
||||||
|
export interface GatewaySettingValues {
|
||||||
|
timezone: string;
|
||||||
|
logLevel: string;
|
||||||
|
sessionIdleDays: number;
|
||||||
|
sonarrAlertMinutes: number;
|
||||||
|
radarrAlertMinutes: number;
|
||||||
|
embyHealthSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GatewaySettingsResponse {
|
||||||
|
settings: GatewaySettings;
|
||||||
|
deployed: GatewaySettingValues;
|
||||||
|
effective: GatewaySettingValues;
|
||||||
|
logLevels: string[] | null;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -255,6 +255,13 @@ export function Layout() {
|
|||||||
<span className="account-avatar account-avatar-large" aria-hidden="true">{initial}</span>
|
<span className="account-avatar account-avatar-large" aria-hidden="true">{initial}</span>
|
||||||
<span><small>Signed in as</small><b>{currentUser}</b></span>
|
<span><small>Signed in as</small><b>{currentUser}</b></span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* The gateway's own settings live here rather than on the rail: the rail
|
||||||
|
is the household — its users, its content, its televisions — and this
|
||||||
|
is the server process those pages are served by. */}
|
||||||
|
<NavLink to="/admin/settings" role="menuitem" onClick={() => setAccountOpen(false)}>
|
||||||
|
<Icon name="sliders" />
|
||||||
|
Gateway settings
|
||||||
|
</NavLink>
|
||||||
<form method="post" action="/admin/logout">
|
<form method="post" action="/admin/logout">
|
||||||
<button type="submit" role="menuitem">
|
<button type="submit" role="menuitem">
|
||||||
<Icon name="logout" />
|
<Icon name="logout" />
|
||||||
|
|||||||
+21
-2
@@ -268,6 +268,19 @@ export const nav: NavGroup[] = [
|
|||||||
intro: 'Take Memby offline now or schedule daily quiet time.',
|
intro: 'Take Memby offline now or schedule daily quiet time.',
|
||||||
icon: 'wrench',
|
icon: 'wrench',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
/* Hidden because its way in is the account menu in the top bar, not the rail:
|
||||||
|
these are settings for the server process rather than for the household, and
|
||||||
|
they belong beside "signed in as". It is still declared here so the page search
|
||||||
|
can find it and the router has one place a page is named. */
|
||||||
|
id: 'gateway-settings',
|
||||||
|
path: '/admin/settings',
|
||||||
|
label: 'Gateway settings',
|
||||||
|
title: 'Gateway settings',
|
||||||
|
intro: 'Timezone, logging and the other server-level settings for this gateway.',
|
||||||
|
icon: 'sliders',
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'integrations',
|
id: 'integrations',
|
||||||
path: '/admin/integrations',
|
path: '/admin/integrations',
|
||||||
@@ -322,9 +335,15 @@ export const nav: NavGroup[] = [
|
|||||||
export const allNavItems: NavItem[] = nav.flatMap((group) => group.items);
|
export const allNavItems: NavItem[] = nav.flatMap((group) => group.items);
|
||||||
|
|
||||||
/** searchableNavItems is what the omni search offers: every page that has a URL of its
|
/** searchableNavItems is what the omni search offers: every page that has a URL of its
|
||||||
* own, which is every page that is not addressed by an id in the path. */
|
* own, which is every page that is not addressed by an id in the path. That test is the
|
||||||
|
* path rather than `hidden`, because the two answer different questions — a page can be
|
||||||
|
* off the rail (gateway settings is reached from the account menu) and still be a real
|
||||||
|
* address somebody would type. A page about one person or one television is the case
|
||||||
|
* this excludes: without knowing which person, the URL does not exist. */
|
||||||
export const searchableNavItems = nav.flatMap((group) =>
|
export const searchableNavItems = nav.flatMap((group) =>
|
||||||
group.items.filter((item) => !item.hidden).map((item) => ({ ...item, group: group.label ?? '' })),
|
group.items
|
||||||
|
.filter((item) => !item.path.includes(':'))
|
||||||
|
.map((item) => ({ ...item, group: group.label ?? '' })),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function navItem(id: string): NavItem | undefined {
|
export function navItem(id: string): NavItem | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/* 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.
|
||||||
|
*
|
||||||
|
* The whole page is an amendment to `.env`. Every field is blank by default and blank
|
||||||
|
* means "whatever this container was started with", which is printed beside it — so an
|
||||||
|
* operator can always see what they are overriding, and clearing a field is a real undo
|
||||||
|
* rather than a value they have to remember. */
|
||||||
|
|
||||||
|
/** 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'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
timezone: string;
|
||||||
|
logLevel: string;
|
||||||
|
sessionIdleDays: string;
|
||||||
|
sonarrAlertMinutes: string;
|
||||||
|
radarrAlertMinutes: string;
|
||||||
|
embyHealthSeconds: 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),
|
||||||
|
embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, 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,
|
||||||
|
}),
|
||||||
|
'Every setting is back to what this container was deployed with.',
|
||||||
|
);
|
||||||
|
if (saved) setDraft(draftFrom(saved.settings));
|
||||||
|
await reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deployed = data?.deployed;
|
||||||
|
const effective = data?.effective;
|
||||||
|
const levels = data?.logLevels ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead
|
||||||
|
title="Gateway settings"
|
||||||
|
intro="Server-level settings for this gateway, changeable without a redeployment."
|
||||||
|
/>
|
||||||
|
<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: 'Household 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: 'Episode alert window', value: describe(effective.sonarrAlertMinutes, 'minute') },
|
||||||
|
{ label: 'Film alert window', value: describe(effective.radarrAlertMinutes, 'minute') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Overrides"
|
||||||
|
intro="Leave a field empty to use the value this container was deployed with, shown beneath it. 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 deployed values
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="fields">
|
||||||
|
<Field
|
||||||
|
label="Household 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="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="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 override the deployed configuration in the database, so they survive a
|
||||||
|
restart — but a deployment rewrites <code>.env</code>, not this, and the two can
|
||||||
|
then disagree. Anything meant to be permanent belongs 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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+25
-1
@@ -337,9 +337,33 @@ a {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
}
|
}
|
||||||
.account-panel form {
|
/* A link and a submit button sit in this menu side by side, and they have to be one row
|
||||||
|
shape: the operator reads them as two entries of the same list, not as a link above a
|
||||||
|
form. */
|
||||||
|
.account-panel > a,
|
||||||
|
.account-panel form button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.account-panel > a:hover {
|
||||||
|
background: var(--surface-hi);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.account-panel > a {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
.account-panel form {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
.account-panel form button {
|
.account-panel form button {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+1
-1
@@ -666,7 +666,7 @@ can review and revoke signed-in TVs from the app's Settings screen.
|
|||||||
| `MEMBY_SONARR_ALERT_WINDOW` | `3h` | How long after air time an "aired" banner stays current; `0` disables banners |
|
| `MEMBY_SONARR_ALERT_WINDOW` | `3h` | How long after air time an "aired" banner stays current; `0` disables banners |
|
||||||
| `MEMBY_RADARR_URL` | *empty* | Radarr address reachable by the gateway; empty disables integration |
|
| `MEMBY_RADARR_URL` | *empty* | Radarr address reachable by the gateway; empty disables integration |
|
||||||
| `MEMBY_RADARR_API_KEY` | *empty* | Radarr Settings → General → Security API key |
|
| `MEMBY_RADARR_API_KEY` | *empty* | Radarr Settings → General → Security API key |
|
||||||
| `MEMBY_RADARR_TTL` | `5m` | Shared Redis lifetime for the five-day digital-release calendar |
|
| `MEMBY_RADARR_TTL` | `5m` | Shared Redis lifetime for the month-long digital-release calendar |
|
||||||
| `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports |
|
| `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports |
|
||||||
| `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this |
|
| `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this |
|
||||||
| `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis |
|
| `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis |
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
|
// A variable rather than a fixed level: the console can move it at runtime, which is
|
||||||
|
// the only way turning debug on is any use — a restart to watch something happen
|
||||||
|
// restarts the thing being watched.
|
||||||
|
logLevel := &slog.LevelVar{}
|
||||||
|
logLevel.Set(logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL")))
|
||||||
logCapacity := logging.ParseCapacity(os.Getenv("MEMBY_LOG_BUFFER_CAPACITY"), 5_000)
|
logCapacity := logging.ParseCapacity(os.Getenv("MEMBY_LOG_BUFFER_CAPACITY"), 5_000)
|
||||||
logFormat := logging.ParseFormat(os.Getenv("MEMBY_LOG_FORMAT"))
|
logFormat := logging.ParseFormat(os.Getenv("MEMBY_LOG_FORMAT"))
|
||||||
logHistoryPath := strings.TrimSpace(os.Getenv("MEMBY_LOG_HISTORY_PATH"))
|
logHistoryPath := strings.TrimSpace(os.Getenv("MEMBY_LOG_HISTORY_PATH"))
|
||||||
@@ -70,13 +74,13 @@ func main() {
|
|||||||
// this" is a real question that a reader should never have to scroll for.
|
// this" is a real question that a reader should never have to scroll for.
|
||||||
log = log.With("version", buildinfo.Version())
|
log = log.With("version", buildinfo.Version())
|
||||||
|
|
||||||
if err := run(log, events); err != nil {
|
if err := run(log, events, logLevel); err != nil {
|
||||||
log.Error("fatal", "error", err)
|
log.Error("fatal", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(log *slog.Logger, events *logging.Buffer) error {
|
func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) error {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -267,6 +271,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
|||||||
AdminEvents: adminBus,
|
AdminEvents: adminBus,
|
||||||
Scheduler: sched,
|
Scheduler: sched,
|
||||||
Integrations: dispatcher,
|
Integrations: dispatcher,
|
||||||
|
LogLevel: logLevel,
|
||||||
})
|
})
|
||||||
if err := server.LoadQuietTime(ctx); err != nil {
|
if err := server.LoadQuietTime(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -301,10 +306,16 @@ func run(log *slog.Logger, events *logging.Buffer) error {
|
|||||||
if err := server.LoadMaintenance(ctx); err != nil {
|
if err := server.LoadMaintenance(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// The operator's runtime amendments to what this container was started with. Loaded
|
||||||
|
// before the watchers below, because two of them read it on their first tick.
|
||||||
|
if err := server.LoadGatewaySettings(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go server.WatchGatewaySettings(ctx, 30*time.Second)
|
||||||
go server.WatchMaintenance(ctx, 30*time.Second)
|
go server.WatchMaintenance(ctx, 30*time.Second)
|
||||||
go server.WatchQuietTime(ctx, 30*time.Second)
|
go server.WatchQuietTime(ctx, 30*time.Second)
|
||||||
// One probe per gateway, not per TV: the answer is the same for the whole house.
|
// One probe per gateway, not per TV: the answer is the same for the whole house.
|
||||||
go server.WatchEmbyReachability(ctx, cfg.EmbyHealthInterval)
|
go server.WatchEmbyReachability(ctx)
|
||||||
// One Sonarr catalogue reading per day records lifecycle changes for the household and
|
// One Sonarr catalogue reading per day records lifecycle changes for the household and
|
||||||
// materialises cancellation notifications for every known viewer.
|
// materialises cancellation notifications for every known viewer.
|
||||||
go server.WatchSonarrLifecycle(ctx, 24*time.Hour)
|
go server.WatchSonarrLifecycle(ctx, 24*time.Hour)
|
||||||
|
|||||||
@@ -38,11 +38,14 @@ const (
|
|||||||
TypeLoginFailed = "auth.login_failed"
|
TypeLoginFailed = "auth.login_failed"
|
||||||
TypeLogout = "auth.logout"
|
TypeLogout = "auth.logout"
|
||||||
TypeDeviceRegistered = "device.registered"
|
TypeDeviceRegistered = "device.registered"
|
||||||
|
TypeDeviceFirstUse = "device.first_use"
|
||||||
|
TypeDeviceUpdated = "device.updated"
|
||||||
TypeDeviceRemoved = "device.removed"
|
TypeDeviceRemoved = "device.removed"
|
||||||
TypeDeviceRenamed = "device.renamed"
|
TypeDeviceRenamed = "device.renamed"
|
||||||
TypeAdminSignIn = "admin.sign_in"
|
TypeAdminSignIn = "admin.sign_in"
|
||||||
TypeServerStarted = "server.started"
|
TypeServerStarted = "server.started"
|
||||||
TypeMaintenanceChanged = "server.maintenance"
|
TypeMaintenanceChanged = "server.maintenance"
|
||||||
|
TypeSettingsChanged = "server.settings"
|
||||||
TypeTaskCompleted = "task.completed"
|
TypeTaskCompleted = "task.completed"
|
||||||
TypeTaskFailed = "task.failed"
|
TypeTaskFailed = "task.failed"
|
||||||
TypeIntegrationFailed = "integration.failed"
|
TypeIntegrationFailed = "integration.failed"
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ func (s *Server) adminRoutes() http.Handler {
|
|||||||
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
||||||
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
||||||
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
|
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
|
||||||
|
mux.Handle("GET /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
|
||||||
|
mux.Handle("POST /admin/api/gateway-settings", s.adminAuth(s.handleAdminGatewaySettings))
|
||||||
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
|
||||||
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
|
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
|
||||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||||
@@ -867,4 +869,5 @@ func (s *Server) handleAdminViews(w http.ResponseWriter, r *http.Request) {
|
|||||||
type syncerHandle interface {
|
type syncerHandle interface {
|
||||||
Running() bool
|
Running() bool
|
||||||
Sync(ctx context.Context, kind, trigger string) (library.Result, error)
|
Sync(ctx context.Context, kind, trigger string) (library.Result, error)
|
||||||
|
Find(ctx context.Context, term string, limit int) ([]json.RawMessage, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request)
|
|||||||
s.loggerFor(r.Context()).Warn("device version cleanup failed",
|
s.loggerFor(r.Context()).Warn("device version cleanup failed",
|
||||||
"removed_device_id", deviceID, "error", err)
|
"removed_device_id", deviceID, "error", err)
|
||||||
}
|
}
|
||||||
|
if err := s.store.DeleteDeviceActivityDays(r.Context(), deviceID); err != nil {
|
||||||
|
s.loggerFor(r.Context()).Warn("device activity cleanup failed",
|
||||||
|
"removed_device_id", deviceID, "error", err)
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminGatewaySettingsResponse is deliberately three things at once: what the operator has
|
||||||
|
// chosen, what the container was started with, and what is therefore in force. A settings
|
||||||
|
// page that showed only the first would leave every empty field looking like a value of
|
||||||
|
// nothing, when an empty field here means "whatever .env says" — and the operator has no
|
||||||
|
// other way to see what that is without opening a file on the NAS.
|
||||||
|
type adminGatewaySettingsResponse struct {
|
||||||
|
Settings store.GatewaySettings `json:"settings"`
|
||||||
|
Deployed deployedGatewaySettings `json:"deployed"`
|
||||||
|
Effective deployedGatewaySettings `json:"effective"`
|
||||||
|
// LogLevels is the vocabulary rather than a list the console keeps its own copy of,
|
||||||
|
// for the reason the preference catalogue is served with the accounts page: a value
|
||||||
|
// the server would refuse must never be offerable.
|
||||||
|
LogLevels []string `json:"logLevels"`
|
||||||
|
// Version and Timezone name the process this page is about, so the page can identify
|
||||||
|
// the gateway it is changing without a second request.
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse {
|
||||||
|
settings := s.gatewaySettings.get()
|
||||||
|
return adminGatewaySettingsResponse{
|
||||||
|
Settings: settings,
|
||||||
|
Deployed: s.deployedSettings(),
|
||||||
|
Effective: deployedGatewaySettings{
|
||||||
|
Timezone: s.householdTimezoneName(),
|
||||||
|
LogLevel: s.effectiveLogLevel(),
|
||||||
|
SessionIdleDays: int(s.sessionIdleExpiry() / (24 * time.Hour)),
|
||||||
|
SonarrAlertMinutes: int(s.sonarrAlertWindow() / time.Minute),
|
||||||
|
RadarrAlertMinutes: int(s.radarrAlertWindow() / time.Minute),
|
||||||
|
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
|
||||||
|
},
|
||||||
|
LogLevels: store.GatewayLogLevels,
|
||||||
|
Version: buildinfo.Version(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) effectiveLogLevel() string {
|
||||||
|
if s.logLevel != nil {
|
||||||
|
return levelName(s.logLevel.Level())
|
||||||
|
}
|
||||||
|
return levelName(s.deployedLogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminGatewaySettings serves the page and accepts a write on the same path, which
|
||||||
|
// is the shape the *arr policy routes already take.
|
||||||
|
func (s *Server) handleAdminGatewaySettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusOK, s.gatewaySettingsResponse())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req store.GatewaySettings
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previous := s.gatewaySettings.get()
|
||||||
|
_, operator, _ := s.browserSession(r, adminSessionPurpose)
|
||||||
|
req.UpdatedBy = operator
|
||||||
|
stored, err := s.store.SetGatewaySettings(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(r.Context()).Error("gateway settings write failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not save gateway settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Applied here rather than waited for: the watcher would pick this up within thirty
|
||||||
|
// seconds, and an operator who has just turned debug logging on and gone to look at
|
||||||
|
// the log would spend that half minute believing it had not worked.
|
||||||
|
s.gatewaySettings.set(stored)
|
||||||
|
s.applyLogLevel(stored.LogLevel)
|
||||||
|
|
||||||
|
s.loggerFor(r.Context()).Info("gateway settings changed",
|
||||||
|
"timezone", s.householdTimezoneName(), "log_level", s.effectiveLogLevel(),
|
||||||
|
"session_idle_days", int(s.sessionIdleExpiry()/(24*time.Hour)),
|
||||||
|
"operator", operator)
|
||||||
|
if summary := gatewaySettingsChanges(previous, stored); summary != "" {
|
||||||
|
s.publishAdmin(r.Context(), adminevents.Event{
|
||||||
|
Type: adminevents.TypeSettingsChanged,
|
||||||
|
Severity: adminevents.SeverityWarning,
|
||||||
|
Title: "Gateway settings changed",
|
||||||
|
Summary: summary,
|
||||||
|
Actor: operator,
|
||||||
|
Link: "/admin/settings",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.gatewaySettingsResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatewaySettingsChanges is what the notification says, and returns empty when nothing
|
||||||
|
// moved. The console re-posts the whole document, so a save that changed nothing is an
|
||||||
|
// ordinary event — the preferenceChanges rule, for the same reason: a feed that records
|
||||||
|
// every press of Save is one nobody reads.
|
||||||
|
func gatewaySettingsChanges(before, after store.GatewaySettings) string {
|
||||||
|
changes := []string{}
|
||||||
|
if before.Timezone != after.Timezone {
|
||||||
|
changes = append(changes, "timezone")
|
||||||
|
}
|
||||||
|
if before.LogLevel != after.LogLevel {
|
||||||
|
changes = append(changes, "log level")
|
||||||
|
}
|
||||||
|
if before.SessionIdleDays != after.SessionIdleDays {
|
||||||
|
changes = append(changes, "session expiry")
|
||||||
|
}
|
||||||
|
if before.SonarrAlertMinutes != after.SonarrAlertMinutes {
|
||||||
|
changes = append(changes, "episode alert window")
|
||||||
|
}
|
||||||
|
if before.RadarrAlertMinutes != after.RadarrAlertMinutes {
|
||||||
|
changes = append(changes, "film alert window")
|
||||||
|
}
|
||||||
|
if before.EmbyHealthSeconds != after.EmbyHealthSeconds {
|
||||||
|
changes = append(changes, "Emby health probe")
|
||||||
|
}
|
||||||
|
switch len(changes) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
return "The gateway's " + changes[0] + " was changed"
|
||||||
|
default:
|
||||||
|
return "The gateway's " + joinPhrase(changes) + " were changed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPhrase(values []string) string {
|
||||||
|
switch len(values) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
return values[0]
|
||||||
|
}
|
||||||
|
out := ""
|
||||||
|
for index, value := range values[:len(values)-1] {
|
||||||
|
if index > 0 {
|
||||||
|
out += ", "
|
||||||
|
}
|
||||||
|
out += value
|
||||||
|
}
|
||||||
|
return out + " and " + values[len(values)-1]
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ type integrationEvent struct {
|
|||||||
var integrationEventCatalogue = []integrationEvent{
|
var integrationEventCatalogue = []integrationEvent{
|
||||||
{adminevents.TypeLogin, "User signed in", "A television signed in with a known device.", "Access"},
|
{adminevents.TypeLogin, "User signed in", "A television signed in with a known device.", "Access"},
|
||||||
{adminevents.TypeDeviceRegistered, "New device", "A television signed in for the first time.", "Access"},
|
{adminevents.TypeDeviceRegistered, "New device", "A television signed in for the first time.", "Access"},
|
||||||
|
{adminevents.TypeDeviceFirstUse, "First use today", "A television opened Memby for the first time that day.", "Access"},
|
||||||
|
{adminevents.TypeDeviceUpdated, "App updated", "A television finished updating to a new build.", "Access"},
|
||||||
{adminevents.TypeLoginFailed, "Sign-in refused", "Emby refused the credentials offered.", "Access"},
|
{adminevents.TypeLoginFailed, "Sign-in refused", "Emby refused the credentials offered.", "Access"},
|
||||||
{adminevents.TypeLogout, "User signed out", "A television signed itself out.", "Access"},
|
{adminevents.TypeLogout, "User signed out", "A television signed itself out.", "Access"},
|
||||||
{adminevents.TypeDeviceRemoved, "Device removed", "A television was removed from an account.", "Access"},
|
{adminevents.TypeDeviceRemoved, "Device removed", "A television was removed from an account.", "Access"},
|
||||||
@@ -37,6 +39,7 @@ var integrationEventCatalogue = []integrationEvent{
|
|||||||
{adminevents.TypeAdminSignIn, "Admin sign-in", "Somebody signed into this console.", "Access"},
|
{adminevents.TypeAdminSignIn, "Admin sign-in", "Somebody signed into this console.", "Access"},
|
||||||
{adminevents.TypeServerStarted, "Server started", "The gateway came up, usually after a deployment.", "System"},
|
{adminevents.TypeServerStarted, "Server started", "The gateway came up, usually after a deployment.", "System"},
|
||||||
{adminevents.TypeMaintenanceChanged, "Maintenance changed", "Memby was taken offline or brought back.", "System"},
|
{adminevents.TypeMaintenanceChanged, "Maintenance changed", "Memby was taken offline or brought back.", "System"},
|
||||||
|
{adminevents.TypeSettingsChanged, "Gateway settings changed", "A server-level setting was changed in the console.", "System"},
|
||||||
{adminevents.TypeTaskCompleted, "Scheduled task finished", "A background job did some work.", "System"},
|
{adminevents.TypeTaskCompleted, "Scheduled task finished", "A background job did some work.", "System"},
|
||||||
{adminevents.TypeTaskFailed, "Scheduled task failed", "A background job could not complete.", "System"},
|
{adminevents.TypeTaskFailed, "Scheduled task failed", "A background job could not complete.", "System"},
|
||||||
{adminevents.TypeIntegrationFailed, "Integration failed", "An outgoing webhook could not be delivered.", "System"},
|
{adminevents.TypeIntegrationFailed, "Integration failed", "An outgoing webhook could not be delivered.", "System"},
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ func (s *Server) handleAdminRecommendations(w http.ResponseWriter, r *http.Reque
|
|||||||
contextName = "default"
|
contextName = "default"
|
||||||
}
|
}
|
||||||
intent := recommend.RankIntent{
|
intent := recommend.RankIntent{
|
||||||
ID: "admin:" + contextName, Now: now, Location: s.cfg.SonarrLocation,
|
ID: "admin:" + contextName, Now: now, Location: s.householdLocation(),
|
||||||
HouseholdScores: household, Compatibility: map[string]float64{},
|
HouseholdScores: household, Compatibility: map[string]float64{},
|
||||||
}
|
}
|
||||||
switch contextName {
|
switch contextName {
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ func liveAlerts(stored []storedAlert, now time.Time) []clientAlert {
|
|||||||
// sonarrAiredAlerts reads the calendar through the same cache the five-day schedule row
|
// sonarrAiredAlerts reads the calendar through the same cache the five-day schedule row
|
||||||
// uses, so polling clients never cost a Sonarr request of their own.
|
// uses, so polling clients never cost a Sonarr request of their own.
|
||||||
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
|
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
|
||||||
if !s.sonarrEnabled(ctx) || s.cfg.SonarrAlertWindow <= 0 {
|
window := s.sonarrAlertWindow()
|
||||||
|
if !s.sonarrEnabled(ctx) || window <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
row, err := s.sonarrAiringTodayRow(ctx)
|
row, err := s.sonarrAiringTodayRow(ctx)
|
||||||
@@ -199,11 +200,7 @@ func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
|
|||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
location := s.cfg.SonarrLocation
|
return buildSonarrAlerts(items, time.Now().In(s.householdLocation()), window)
|
||||||
if location == nil {
|
|
||||||
location = time.Local
|
|
||||||
}
|
|
||||||
return buildSonarrAlerts(items, time.Now().In(location), s.cfg.SonarrAlertWindow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSonarrAlerts announces episodes that have aired but are not in Emby yet — the
|
// buildSonarrAlerts announces episodes that have aired but are not in Emby yet — the
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ type Server struct {
|
|||||||
recommendationBuilds recommendationBuilds
|
recommendationBuilds recommendationBuilds
|
||||||
maintenance maintenanceState
|
maintenance maintenanceState
|
||||||
quietTime quietTimeState
|
quietTime quietTimeState
|
||||||
|
// gatewaySettings is the operator's runtime amendment to what the container was
|
||||||
|
// started with. deployedLogLevel is remembered beside the live level variable
|
||||||
|
// because clearing an override has to restore something, and the level variable
|
||||||
|
// itself has by then been moved.
|
||||||
|
gatewaySettings gatewaySettingsState
|
||||||
|
logLevel *slog.LevelVar
|
||||||
|
deployedLogLevel slog.Level
|
||||||
updatePolicy updatePolicyCache
|
updatePolicy updatePolicyCache
|
||||||
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
|
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
|
||||||
// a TV can show why playback stopped even if it missed the announcement.
|
// a TV can show why playback stopped even if it missed the announcement.
|
||||||
@@ -129,6 +136,10 @@ type Deps struct {
|
|||||||
AdminEvents *adminevents.Bus
|
AdminEvents *adminevents.Bus
|
||||||
Scheduler *scheduler.Scheduler
|
Scheduler *scheduler.Scheduler
|
||||||
Integrations *integrations.Dispatcher
|
Integrations *integrations.Dispatcher
|
||||||
|
// LogLevel is the live level of the process's own logger, so the console can turn
|
||||||
|
// debug on and watch the thing it turned it on for. Nil is allowed and means the
|
||||||
|
// level is fixed at whatever the container was started with.
|
||||||
|
LogLevel *slog.LevelVar
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, deps Deps) *Server {
|
func New(cfg config.Config, deps Deps) *Server {
|
||||||
@@ -152,9 +163,19 @@ func New(cfg config.Config, deps Deps) *Server {
|
|||||||
adminEvents: deps.AdminEvents,
|
adminEvents: deps.AdminEvents,
|
||||||
scheduler: deps.Scheduler,
|
scheduler: deps.Scheduler,
|
||||||
integrations: deps.Integrations,
|
integrations: deps.Integrations,
|
||||||
|
|
||||||
|
logLevel: deps.LogLevel,
|
||||||
|
deployedLogLevel: deployedLevel(deps.LogLevel),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deployedLevel(level *slog.LevelVar) slog.Level {
|
||||||
|
if level == nil {
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
return level.Level()
|
||||||
|
}
|
||||||
|
|
||||||
// publishAdmin reports something an operator would want to know about.
|
// publishAdmin reports something an operator would want to know about.
|
||||||
//
|
//
|
||||||
// Every caller treats it as fire-and-forget, which is why it returns nothing: the feed is
|
// Every caller treats it as fire-and-forget, which is why it returns nothing: the feed is
|
||||||
@@ -409,6 +430,9 @@ func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) stor
|
|||||||
s.log.Warn("device version record failed",
|
s.log.Warn("device version record failed",
|
||||||
"device_id", sess.DeviceID, "error", err)
|
"device_id", sess.DeviceID, "error", err)
|
||||||
}
|
}
|
||||||
|
// And it is the only place an operator would otherwise learn that an update
|
||||||
|
// they offered was actually taken.
|
||||||
|
s.announceDeviceUpdate(r.Context(), sess, previousVersion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sess
|
return sess
|
||||||
|
|||||||
@@ -297,6 +297,10 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, curr
|
|||||||
s.loggerFor(r.Context()).Warn("device version cleanup failed",
|
s.loggerFor(r.Context()).Warn("device version cleanup failed",
|
||||||
"removed_device_id", deviceID, "error", err)
|
"removed_device_id", deviceID, "error", err)
|
||||||
}
|
}
|
||||||
|
if err := s.store.DeleteDeviceActivityDays(r.Context(), deviceID); err != nil {
|
||||||
|
s.loggerFor(r.Context()).Warn("device activity cleanup failed",
|
||||||
|
"removed_device_id", deviceID, "error", err)
|
||||||
|
}
|
||||||
// A device disappearing from a household is worth a line: the next thing that TV
|
// A device disappearing from a household is worth a line: the next thing that TV
|
||||||
// reports is a sign-in, and the two together explain each other.
|
// reports is a sign-in, and the two together explain each other.
|
||||||
s.loggerFor(r.Context()).Info("device signed out remotely", "removed_device_id", deviceID)
|
s.loggerFor(r.Context()).Info("device signed out remotely", "removed_device_id", deviceID)
|
||||||
@@ -335,6 +339,9 @@ func (s *Server) retireSupersededDevices(ctx context.Context, devices []store.Su
|
|||||||
if err := s.store.DeleteDeviceVersions(ctx, ids...); err != nil {
|
if err := s.store.DeleteDeviceVersions(ctx, ids...); err != nil {
|
||||||
s.loggerFor(ctx).Warn("device version cleanup failed", "error", err)
|
s.loggerFor(ctx).Warn("device version cleanup failed", "error", err)
|
||||||
}
|
}
|
||||||
|
if err := s.store.DeleteDeviceActivityDays(ctx, ids...); err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("device activity cleanup failed", "error", err)
|
||||||
|
}
|
||||||
s.loggerFor(ctx).Info("device identity superseded", "retired_device_ids", ids)
|
s.loggerFor(ctx).Info("device identity superseded", "retired_device_ids", ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,10 +86,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) sonarrLocation() *time.Location {
|
func (s *Server) sonarrLocation() *time.Location {
|
||||||
if s.cfg.SonarrLocation != nil {
|
return s.householdLocation()
|
||||||
return s.cfg.SonarrLocation
|
|
||||||
}
|
|
||||||
return time.Local
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) sonarrCalendarMonth(
|
func (s *Server) sonarrCalendarMonth(
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noteDailyFirstUse announces the first time a television opened Memby on a given day.
|
||||||
|
//
|
||||||
|
// It is called from the home request rather than from the auth middleware, which every
|
||||||
|
// call passes through, because "opened Memby" means the launcher composing. A set left on
|
||||||
|
// overnight polls /v1/status every ten seconds, so anchoring on any authenticated request
|
||||||
|
// would announce that set at midnight — an event nobody did, timed to the hour nobody is
|
||||||
|
// reading the feed.
|
||||||
|
//
|
||||||
|
// It is called before the cached response is served, deliberately: a household whose home
|
||||||
|
// row cache is still warm from the last set to switch on has still just been opened by
|
||||||
|
// this one, and the mark is a single indexed insert either way.
|
||||||
|
//
|
||||||
|
// Everything about it is best-effort. It cannot fail a launcher.
|
||||||
|
func (s *Server) noteDailyFirstUse(ctx context.Context, sess store.Session) {
|
||||||
|
if s.store == nil || sess.DeviceID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
first, err := s.store.MarkDeviceDay(
|
||||||
|
ctx, sess.DeviceID, sess.EmbyUserID, time.Now().In(s.sonarrLocation()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("device day mark failed",
|
||||||
|
"device_id", sess.DeviceID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !first {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.loggerFor(ctx).Info("first use today",
|
||||||
|
"device_id", sess.DeviceID, "client", clientLogValue(sess.ClientVersion))
|
||||||
|
s.publishAdmin(ctx, adminevents.Event{
|
||||||
|
Type: adminevents.TypeDeviceFirstUse,
|
||||||
|
Title: "Opened Memby",
|
||||||
|
Summary: fmt.Sprintf("%s opened Memby on %s for the first time today",
|
||||||
|
displayName(sess.Username), displayName(sess.DeviceName)),
|
||||||
|
Actor: sess.Username, Target: sess.DeviceName,
|
||||||
|
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
|
||||||
|
Metadata: adminevents.Meta(map[string]any{
|
||||||
|
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
|
||||||
|
"version": sess.ClientVersion,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceDeviceUpdate reports a television that has finished updating itself.
|
||||||
|
//
|
||||||
|
// A set that updates in place never signs in again, so the first request carrying the new
|
||||||
|
// build is the only evidence there is that the update worked — and an update that was
|
||||||
|
// offered and never taken looks exactly like one that was, until this arrives.
|
||||||
|
//
|
||||||
|
// `from` being empty is not an update: it is a television this gateway had never been
|
||||||
|
// told the build of, which is an old APK or a session predating identity capture, and
|
||||||
|
// announcing it as an upgrade would claim a version moved when nothing is known to have
|
||||||
|
// moved. A version that went *backwards* is still announced, because a sideloaded
|
||||||
|
// downgrade is news an operator wants at least as much as an upgrade.
|
||||||
|
func (s *Server) announceDeviceUpdate(ctx context.Context, sess store.Session, from string) {
|
||||||
|
if from == "" || sess.ClientVersion == "" || from == sess.ClientVersion {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.loggerFor(ctx).Info("client build changed",
|
||||||
|
"device_id", sess.DeviceID, "from", from, "to", sess.ClientVersion)
|
||||||
|
s.publishAdmin(ctx, adminevents.Event{
|
||||||
|
Type: adminevents.TypeDeviceUpdated,
|
||||||
|
Title: "App updated",
|
||||||
|
Summary: fmt.Sprintf("%s updated from %s to %s",
|
||||||
|
displayName(sess.DeviceName), from, sess.ClientVersion),
|
||||||
|
Actor: sess.Username, Target: sess.DeviceName,
|
||||||
|
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
|
||||||
|
Metadata: adminevents.Meta(map[string]any{
|
||||||
|
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
|
||||||
|
"from": from, "to": sess.ClientVersion,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -63,6 +63,28 @@ func (h *embyHealth) begin(interval time.Duration, now time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// retune follows the operator changing the probe's cadence, or switching it off, without
|
||||||
|
// disturbing what the probe has already found. It is deliberately not `begin`: that
|
||||||
|
// starts a fresh watch and drops the Emby version with it, which the About page reads and
|
||||||
|
// which is still true whatever the interval is now.
|
||||||
|
func (h *embyHealth) retune(interval time.Duration, now time.Time) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if interval <= 0 {
|
||||||
|
h.state.monitored = false
|
||||||
|
h.state.retryEvery = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.state.monitored {
|
||||||
|
h.state = embyHealthState{
|
||||||
|
monitored: true, reachable: true, since: now, retryEvery: interval,
|
||||||
|
version: h.state.version,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.state.retryEvery = interval
|
||||||
|
}
|
||||||
|
|
||||||
// record folds one probe result in and reports whether the published verdict changed.
|
// record folds one probe result in and reports whether the published verdict changed.
|
||||||
func (h *embyHealth) record(ok bool, version string, now time.Time) {
|
func (h *embyHealth) record(ok bool, version string, now time.Time) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/logging"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gatewaySettingsState caches the operator's overrides in memory, the way maintenance is
|
||||||
|
// cached: several of these are read on the request path — the household's timezone is
|
||||||
|
// read by every home response — and none of them is worth a query.
|
||||||
|
type gatewaySettingsState struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
value store.GatewaySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewaySettingsState) get() store.GatewaySettings {
|
||||||
|
g.mu.RLock()
|
||||||
|
defer g.mu.RUnlock()
|
||||||
|
return g.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gatewaySettingsState) set(value store.GatewaySettings) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadGatewaySettings primes the cache and applies the settings that live in the running
|
||||||
|
// process rather than being read where they are used. Called at boot and after a write.
|
||||||
|
func (s *Server) LoadGatewaySettings(ctx context.Context) error {
|
||||||
|
if s.store == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
settings, err := s.store.GatewaySettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.gatewaySettings.set(settings)
|
||||||
|
s.applyLogLevel(settings.LogLevel)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchGatewaySettings re-reads the overrides periodically, so a change made directly in
|
||||||
|
// the database — or by another instance — is picked up without a restart. The same reason
|
||||||
|
// WatchMaintenance exists.
|
||||||
|
func (s *Server) WatchGatewaySettings(ctx context.Context, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := s.LoadGatewaySettings(ctx); err != nil {
|
||||||
|
s.log.Warn("gateway settings refresh failed",
|
||||||
|
"component", "settings", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLogLevel moves the running process's log level. It is a no-op on a gateway wired
|
||||||
|
// without a level variable — every unit test in this package — and an empty override
|
||||||
|
// restores the level the container was started with, which is what makes clearing the
|
||||||
|
// setting in the console a real undo rather than a value the operator has to remember.
|
||||||
|
func (s *Server) applyLogLevel(level string) {
|
||||||
|
if s.logLevel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if level == "" {
|
||||||
|
s.logLevel.Set(s.deployedLogLevel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logLevel.Set(logging.ParseLevel(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- effective values -------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Each of these is "the override, or what was deployed". They are the only readers of the
|
||||||
|
// cached document, so a setting is added by adding one of these beside its config field
|
||||||
|
// rather than by teaching every call site that an override exists.
|
||||||
|
|
||||||
|
// householdLocation is the household's idea of what day it is. Everything that groups by
|
||||||
|
// a local day reads it: the schedule rows, the hero rotation, sign-in history and the
|
||||||
|
// first-use notification.
|
||||||
|
func (s *Server) householdLocation() *time.Location {
|
||||||
|
if name := s.gatewaySettings.get().Timezone; name != "" {
|
||||||
|
if location, err := time.LoadLocation(name); err == nil {
|
||||||
|
return location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.cfg.SonarrLocation != nil {
|
||||||
|
return s.cfg.SonarrLocation
|
||||||
|
}
|
||||||
|
return time.Local
|
||||||
|
}
|
||||||
|
|
||||||
|
// householdTimezoneName is what the console and the sign-in history print. It names the
|
||||||
|
// zone in force rather than the one deployed, so a page cannot claim a grouping that is
|
||||||
|
// not the one the rows were grouped by.
|
||||||
|
func (s *Server) householdTimezoneName() string {
|
||||||
|
return s.householdLocation().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sessionIdleExpiry() time.Duration {
|
||||||
|
if days := s.gatewaySettings.get().SessionIdleDays; days > 0 {
|
||||||
|
return time.Duration(days) * 24 * time.Hour
|
||||||
|
}
|
||||||
|
return s.cfg.SessionIdleExpiry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sonarrAlertWindow() time.Duration {
|
||||||
|
return overrideWindow(s.gatewaySettings.get().SonarrAlertMinutes, time.Minute,
|
||||||
|
s.cfg.SonarrAlertWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) radarrAlertWindow() time.Duration {
|
||||||
|
return overrideWindow(s.gatewaySettings.get().RadarrAlertMinutes, time.Minute,
|
||||||
|
s.cfg.RadarrAlertWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) embyHealthInterval() time.Duration {
|
||||||
|
return overrideWindow(s.gatewaySettings.get().EmbyHealthSeconds, time.Second,
|
||||||
|
s.cfg.EmbyHealthInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func overrideWindow(value int, unit, deployed time.Duration) time.Duration {
|
||||||
|
switch {
|
||||||
|
case value < 0:
|
||||||
|
return 0
|
||||||
|
case value > 0:
|
||||||
|
return time.Duration(value) * unit
|
||||||
|
default:
|
||||||
|
return deployed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deployedGatewaySettings describes what the container was started with, so the console
|
||||||
|
// can show the value a cleared field falls back to. Deliberately not the same shape as
|
||||||
|
// the overrides: these are facts, not choices, and nothing may write them back.
|
||||||
|
type deployedGatewaySettings struct {
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
LogLevel string `json:"logLevel"`
|
||||||
|
SessionIdleDays int `json:"sessionIdleDays"`
|
||||||
|
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
|
||||||
|
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
|
||||||
|
EmbyHealthSeconds int `json:"embyHealthSeconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deployedSettings() deployedGatewaySettings {
|
||||||
|
timezone := ""
|
||||||
|
if s.cfg.SonarrLocation != nil {
|
||||||
|
timezone = s.cfg.SonarrLocation.String()
|
||||||
|
}
|
||||||
|
return deployedGatewaySettings{
|
||||||
|
Timezone: timezone,
|
||||||
|
LogLevel: levelName(s.deployedLogLevel),
|
||||||
|
SessionIdleDays: int(s.cfg.SessionIdleExpiry / (24 * time.Hour)),
|
||||||
|
SonarrAlertMinutes: int(s.cfg.SonarrAlertWindow / time.Minute),
|
||||||
|
RadarrAlertMinutes: int(s.cfg.RadarrAlertWindow / time.Minute),
|
||||||
|
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func levelName(level slog.Level) string {
|
||||||
|
switch {
|
||||||
|
case level < slog.LevelDebug:
|
||||||
|
return "trace"
|
||||||
|
case level < slog.LevelInfo:
|
||||||
|
return "debug"
|
||||||
|
case level < slog.LevelWarn:
|
||||||
|
return "info"
|
||||||
|
case level < slog.LevelError:
|
||||||
|
return "warn"
|
||||||
|
default:
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/logging"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The three meanings a duration-like override carries. Zero is not "off" — that is the
|
||||||
|
// whole point of the -1, and reading it as off would silently disable an alert window an
|
||||||
|
// operator had merely left alone.
|
||||||
|
func TestOverrideWindowSeparatesOffFromDeployed(t *testing.T) {
|
||||||
|
deployed := 15 * time.Minute
|
||||||
|
if got := overrideWindow(0, time.Minute, deployed); got != deployed {
|
||||||
|
t.Fatalf("zero should fall back to the deployed value, got %s", got)
|
||||||
|
}
|
||||||
|
if got := overrideWindow(store.GatewaySettingsOff, time.Minute, deployed); got != 0 {
|
||||||
|
t.Fatalf("a negative override should switch the window off, got %s", got)
|
||||||
|
}
|
||||||
|
if got := overrideWindow(45, time.Minute, deployed); got != 45*time.Minute {
|
||||||
|
t.Fatalf("a positive override should be taken in the given unit, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The probe loop must keep ticking while it is switched off, or turning it back on would
|
||||||
|
// need a restart of the gateway — which is the thing the setting exists to avoid.
|
||||||
|
func TestEmbyProbeDelayKeepsTickingWhileOff(t *testing.T) {
|
||||||
|
if got := embyProbeDelay(0); got != time.Minute {
|
||||||
|
t.Fatalf("a switched-off probe should still wake up, got %s", got)
|
||||||
|
}
|
||||||
|
if got := embyProbeDelay(30 * time.Second); got != 30*time.Second {
|
||||||
|
t.Fatalf("a live probe should wait its interval, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewaySettingsChangesReportsOnlyWhatMoved(t *testing.T) {
|
||||||
|
before := store.GatewaySettings{Timezone: "Pacific/Auckland", SessionIdleDays: 30}
|
||||||
|
if summary := gatewaySettingsChanges(before, before); summary != "" {
|
||||||
|
t.Fatalf("an unchanged save must not be news, got %q", summary)
|
||||||
|
}
|
||||||
|
one := before
|
||||||
|
one.LogLevel = "debug"
|
||||||
|
if summary := gatewaySettingsChanges(before, one); summary != "The gateway's log level was changed" {
|
||||||
|
t.Fatalf("one change reads wrong: %q", summary)
|
||||||
|
}
|
||||||
|
two := one
|
||||||
|
two.SessionIdleDays = 60
|
||||||
|
summary := gatewaySettingsChanges(before, two)
|
||||||
|
if summary != "The gateway's log level and session expiry were changed" {
|
||||||
|
t.Fatalf("two changes read wrong: %q", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelNameCoversTheVocabulary(t *testing.T) {
|
||||||
|
for _, level := range store.GatewayLogLevels {
|
||||||
|
if got := levelName(parseTestLevel(t, level)); got != level {
|
||||||
|
t.Fatalf("%s round-tripped as %s", level, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTestLevel(t *testing.T, name string) slog.Level {
|
||||||
|
t.Helper()
|
||||||
|
return logging.ParseLevel(name)
|
||||||
|
}
|
||||||
@@ -1055,10 +1055,7 @@ func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []he
|
|||||||
if s.sonarr == nil || s.store == nil {
|
if s.sonarr == nil || s.store == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
location := s.cfg.SonarrLocation
|
location := s.householdLocation()
|
||||||
if location == nil {
|
|
||||||
location = time.Local
|
|
||||||
}
|
|
||||||
dayStart := localDayStart(now.In(location), location)
|
dayStart := localDayStart(now.In(location), location)
|
||||||
key := heroPremiereCachePrefix + dayStart.Format("2006-01-02")
|
key := heroPremiereCachePrefix + dayStart.Format("2006-01-02")
|
||||||
|
|
||||||
|
|||||||
@@ -97,20 +97,61 @@ func heroScheduleTimeZone(location *time.Location) string {
|
|||||||
return location.String()
|
return location.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// heroSearchLimit is what either half of the picker's search may contribute, and what the
|
||||||
|
// merged answer is trimmed back to.
|
||||||
|
const heroSearchLimit = 20
|
||||||
|
|
||||||
|
// handleAdminHeroSearch answers the picker from the imported catalogue and from Emby.
|
||||||
|
//
|
||||||
|
// The catalogue alone is up to a sync interval stale, so a film imported this afternoon
|
||||||
|
// was simply not findable here until the next hourly pass — and pinning is the one hero
|
||||||
|
// decision an operator makes about a title *because* it has just arrived. Emby is asked
|
||||||
|
// as well and `Find` imports what it returns, which is what makes a fresh id usable by the
|
||||||
|
// policy validation and by the hero row itself rather than only by this list.
|
||||||
|
//
|
||||||
|
// Either half may fail without failing the search: a stale answer and a live one are both
|
||||||
|
// better than an error, and the two are deliberately asked in that order so a gateway with
|
||||||
|
// no Emby credentials configured still has a picker.
|
||||||
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
items, err := s.store.SearchLibrary(r.Context(), r.URL.Query().Get("q"), 20)
|
term := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
items, err := s.store.SearchLibrary(r.Context(), term, heroSearchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(r.Context()).Error("hero library search failed", "error", err)
|
s.loggerFor(r.Context()).Error("hero library search failed", "error", err)
|
||||||
writeError(w, http.StatusInternalServerError, "could not search the library")
|
writeError(w, http.StatusInternalServerError, "could not search the library")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if s.syncer != nil && term != "" {
|
||||||
|
found, findErr := s.syncer.Find(r.Context(), term, heroSearchLimit)
|
||||||
|
if findErr != nil {
|
||||||
|
s.loggerFor(r.Context()).Warn("hero live search unavailable", "error", findErr)
|
||||||
|
}
|
||||||
|
items = append(items, found...)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": mergeHeroSearchResults(items, heroSearchLimit)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeHeroSearchResults turns both halves of the search into one list.
|
||||||
|
//
|
||||||
|
// Almost every title comes back from both, so the dedupe is the ordinary case rather than
|
||||||
|
// the exception, and it keeps the first sighting: the catalogue answers first, and its
|
||||||
|
// ranking is the one an operator has been reading all along. Anything an id appears in
|
||||||
|
// only once is either a title Emby has and the last import missed — the whole point — or
|
||||||
|
// one deleted from Emby that the catalogue has not swept yet.
|
||||||
|
func mergeHeroSearchResults(items []json.RawMessage, limit int) []heroAdminItem {
|
||||||
results := make([]heroAdminItem, 0, len(items))
|
results := make([]heroAdminItem, 0, len(items))
|
||||||
|
seen := make(map[string]bool, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
if item, ok := adminHeroItem(raw); ok {
|
if len(results) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
item, ok := adminHeroItem(raw)
|
||||||
|
if !ok || seen[item.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[item.ID] = true
|
||||||
results = append(results, item)
|
results = append(results, item)
|
||||||
}
|
}
|
||||||
}
|
return results
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"items": results})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func heroSearchPayload(id, name, itemType string) json.RawMessage {
|
||||||
|
return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType + `"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The picker asks two sources that mostly agree, so one title arriving twice is the
|
||||||
|
// ordinary case rather than a fault, and the catalogue's ranking is the one kept.
|
||||||
|
func TestHeroSearchMergeKeepsTheFirstSighting(t *testing.T) {
|
||||||
|
merged := mergeHeroSearchResults([]json.RawMessage{
|
||||||
|
heroSearchPayload("1", "Arrival", "Movie"),
|
||||||
|
heroSearchPayload("2", "Severance", "Series"),
|
||||||
|
heroSearchPayload("1", "Arrival", "Movie"),
|
||||||
|
}, 20)
|
||||||
|
|
||||||
|
if len(merged) != 2 {
|
||||||
|
t.Fatalf("merged %d titles, want 2", len(merged))
|
||||||
|
}
|
||||||
|
if merged[0].ID != "1" || merged[1].ID != "2" {
|
||||||
|
t.Fatalf("merge reordered the catalogue's answer: %+v", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole reason Emby is asked: a title imported since the last pass is in one half of
|
||||||
|
// the answer only, and it must survive into the list an operator can pin from.
|
||||||
|
func TestHeroSearchMergeAdoptsATitleTheCatalogueMissed(t *testing.T) {
|
||||||
|
merged := mergeHeroSearchResults([]json.RawMessage{
|
||||||
|
heroSearchPayload("1", "Arrival", "Movie"),
|
||||||
|
heroSearchPayload("1", "Arrival", "Movie"),
|
||||||
|
heroSearchPayload("9", "Arrival of the Birds", "Movie"),
|
||||||
|
}, 20)
|
||||||
|
|
||||||
|
if len(merged) != 2 || merged[1].ID != "9" {
|
||||||
|
t.Fatalf("a title only Emby knew about was dropped: %+v", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hero is a film or a show. An episode matching the term, and a synthetic schedule card
|
||||||
|
// that cannot be played at all, are both things the picker must not offer.
|
||||||
|
func TestHeroSearchMergeOffersOnlyPinnableTitles(t *testing.T) {
|
||||||
|
merged := mergeHeroSearchResults([]json.RawMessage{
|
||||||
|
heroSearchPayload("1", "Severance S01E01", "Episode"),
|
||||||
|
json.RawMessage(`{"Id":"2","Name":"Dune","Type":"Movie","MembySource":"radarr"}`),
|
||||||
|
heroSearchPayload("3", "Dune", "Movie"),
|
||||||
|
}, 20)
|
||||||
|
|
||||||
|
if len(merged) != 1 || merged[0].ID != "3" {
|
||||||
|
t.Fatalf("picker offered something unpinnable: %+v", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeroSearchMergeTrimsToTheLimit(t *testing.T) {
|
||||||
|
items := make([]json.RawMessage, 0, 30)
|
||||||
|
for index := range 30 {
|
||||||
|
items = append(items, heroSearchPayload(string(rune('a'+index)), "Title", "Movie"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if merged := mergeHeroSearchResults(items, 20); len(merged) != 20 {
|
||||||
|
t.Fatalf("merged %d titles, want the limit of 20", len(merged))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ type homeResponse struct {
|
|||||||
// handleHome answers the entire launcher in one round trip.
|
// handleHome answers the entire launcher in one round trip.
|
||||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
s.noteDailyFirstUse(ctx, sess)
|
||||||
limit := queryInt(r, "limit", 24, 100)
|
limit := queryInt(r, "limit", 24, 100)
|
||||||
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
|
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
|
||||||
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
||||||
@@ -203,10 +204,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
location := s.cfg.SonarrLocation
|
location := s.householdLocation()
|
||||||
if location == nil {
|
|
||||||
location = time.UTC
|
|
||||||
}
|
|
||||||
window := homeForYouWindowAt(time.Now().In(location))
|
window := homeForYouWindowAt(time.Now().In(location))
|
||||||
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
|
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -51,6 +51,20 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
sched.Register(scheduler.Task{
|
||||||
|
ID: "device-activity-cleanup",
|
||||||
|
Name: "Device activity cleanup",
|
||||||
|
Group: "Housekeeping",
|
||||||
|
Description: fmt.Sprintf(
|
||||||
|
"Removes the daily first-use marks that are older than %d days.",
|
||||||
|
int(store.DeviceActivityRetention/(24*time.Hour))),
|
||||||
|
Interval: 24 * time.Hour,
|
||||||
|
Run: func(ctx context.Context) (string, error) {
|
||||||
|
removed, err := s.store.PruneDeviceActivityDays(ctx, store.DeviceActivityRetention)
|
||||||
|
return countDetail(removed, "activity mark"), err
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
sched.Register(scheduler.Task{
|
sched.Register(scheduler.Task{
|
||||||
ID: "integration-cleanup",
|
ID: "integration-cleanup",
|
||||||
Name: "Integration delivery cleanup",
|
Name: "Integration delivery cleanup",
|
||||||
@@ -103,10 +117,13 @@ func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
|
|||||||
Group: "Housekeeping",
|
Group: "Housekeeping",
|
||||||
Description: fmt.Sprintf(
|
Description: fmt.Sprintf(
|
||||||
"Retires gateway tokens unused for %d days, along with the Emby token each one holds.",
|
"Retires gateway tokens unused for %d days, along with the Emby token each one holds.",
|
||||||
int(s.cfg.SessionIdleExpiry/(24*time.Hour))),
|
int(s.sessionIdleExpiry()/(24*time.Hour))),
|
||||||
Interval: 6 * time.Hour,
|
Interval: 6 * time.Hour,
|
||||||
Run: func(ctx context.Context) (string, error) {
|
Run: func(ctx context.Context) (string, error) {
|
||||||
removed, err := s.store.DeleteIdleSessions(ctx, s.cfg.SessionIdleExpiry)
|
// Read at run time rather than closed over: the description above is written
|
||||||
|
// once when the task is registered, but the sweep itself must follow the
|
||||||
|
// operator's setting without a restart.
|
||||||
|
removed, err := s.store.DeleteIdleSessions(ctx, s.sessionIdleExpiry())
|
||||||
return countDetail(removed, "idle session"), err
|
return countDetail(removed, "idle session"), err
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -87,10 +87,7 @@ func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time {
|
|||||||
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
location := s.cfg.SonarrLocation
|
location := s.householdLocation()
|
||||||
if location == nil {
|
|
||||||
location = time.UTC
|
|
||||||
}
|
|
||||||
parsed, err := time.ParseInLocation("2006-01-02", raw, location)
|
parsed, err := time.ParseInLocation("2006-01-02", raw, location)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
@@ -105,10 +102,7 @@ func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time {
|
|||||||
// asked to do the grouping in it rather than the console doing it in the browser's zone,
|
// asked to do the grouping in it rather than the console doing it in the browser's zone,
|
||||||
// for the reason store.LoginDays gives.
|
// for the reason store.LoginDays gives.
|
||||||
func (s *Server) zoneName() string {
|
func (s *Server) zoneName() string {
|
||||||
if s.cfg.SonarrLocation != nil {
|
return s.householdTimezoneName()
|
||||||
return s.cfg.SonarrLocation.String()
|
|
||||||
}
|
|
||||||
return "UTC"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminLoginsResponse struct {
|
type adminLoginsResponse struct {
|
||||||
|
|||||||
@@ -13,9 +13,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const radarrCalendarCachePrefix = "radarr:calendar:v3:"
|
const radarrCalendarCachePrefix = "radarr:calendar:v3:"
|
||||||
const radarrScheduleDays = 5
|
|
||||||
|
// radarrScheduleDays is a month where the Sonarr schedule row's is a week, because films
|
||||||
|
// and episodes arrive at quite different rates. A household's Sonarr calendar fills a week
|
||||||
|
// several times over; its Radarr calendar, measured against the real catalogue, yields
|
||||||
|
// about one title a fortnight — at five days the row was empty or held a single card most
|
||||||
|
// of the time, which reads as a broken shelf rather than a quiet month.
|
||||||
|
const radarrScheduleDays = 30
|
||||||
|
|
||||||
|
// radarrTheatricalDelayDays is the median cinema-to-digital gap for recent releases, used
|
||||||
|
// only to estimate a digital date Radarr has not published. It shares a figure with
|
||||||
|
// radarrScheduleDays by coincidence, not by meaning — they are free to move apart.
|
||||||
const radarrTheatricalDelayDays = 30
|
const radarrTheatricalDelayDays = 30
|
||||||
|
|
||||||
|
// radarrWeekdayLabelDays is how far out a bare weekday still names an unambiguous day.
|
||||||
|
// Beyond it the label carries a date, or a release five weeks away reads as this Friday.
|
||||||
|
const radarrWeekdayLabelDays = 6
|
||||||
|
|
||||||
type radarrRelease struct {
|
type radarrRelease struct {
|
||||||
at time.Time
|
at time.Time
|
||||||
estimated bool
|
estimated bool
|
||||||
@@ -185,7 +199,7 @@ func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Ti
|
|||||||
}
|
}
|
||||||
localRelease := release.at.In(location)
|
localRelease := release.at.In(location)
|
||||||
item.MembyAirsAt = localRelease.Format(time.RFC3339)
|
item.MembyAirsAt = localRelease.Format(time.RFC3339)
|
||||||
item.MembyAirDayLabel = scheduleAirDayLabel(localRelease, now, location)
|
item.MembyAirDayLabel = radarrReleaseDayLabel(localRelease, now, location)
|
||||||
item.MembyAirLabel = digitalReleaseLabel(localRelease, now, location, release.estimated)
|
item.MembyAirLabel = digitalReleaseLabel(localRelease, now, location, release.estimated)
|
||||||
switch {
|
switch {
|
||||||
case movie.HasFile:
|
case movie.HasFile:
|
||||||
@@ -205,11 +219,27 @@ func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Ti
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// radarrReleaseDayLabel is the card's own day chip. It is Radarr's rather than
|
||||||
|
// scheduleAirDayLabel because that one answers for a seven-day Sonarr window, where every
|
||||||
|
// date it can be handed is inside the coming week and a weekday is never ambiguous.
|
||||||
|
func radarrReleaseDayLabel(release, now time.Time, location *time.Location) string {
|
||||||
|
today := localDayStart(now.In(location), location)
|
||||||
|
releaseDay := localDayStart(release.In(location), location)
|
||||||
|
switch {
|
||||||
|
case releaseDay.Equal(today):
|
||||||
|
return "Today"
|
||||||
|
case releaseDay.Equal(today.AddDate(0, 0, 1)):
|
||||||
|
return "Tomorrow"
|
||||||
|
case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)):
|
||||||
|
return releaseDay.Format("Monday")
|
||||||
|
default:
|
||||||
|
return releaseDay.Format("2 Jan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func digitalReleaseLabel(release, now time.Time, location *time.Location, estimated bool) string {
|
func digitalReleaseLabel(release, now time.Time, location *time.Location, estimated bool) string {
|
||||||
release = release.In(location)
|
today := localDayStart(now.In(location), location)
|
||||||
now = now.In(location)
|
releaseDay := localDayStart(release.In(location), location)
|
||||||
today := localDayStart(now, location)
|
|
||||||
releaseDay := localDayStart(release, location)
|
|
||||||
prefix := "Digital release "
|
prefix := "Digital release "
|
||||||
if estimated {
|
if estimated {
|
||||||
prefix = "Estimated digital release "
|
prefix = "Estimated digital release "
|
||||||
@@ -219,8 +249,10 @@ func digitalReleaseLabel(release, now time.Time, location *time.Location, estima
|
|||||||
return prefix + "today"
|
return prefix + "today"
|
||||||
case releaseDay.Equal(today.AddDate(0, 0, 1)):
|
case releaseDay.Equal(today.AddDate(0, 0, 1)):
|
||||||
return prefix + "tomorrow"
|
return prefix + "tomorrow"
|
||||||
|
case releaseDay.Before(today.AddDate(0, 0, radarrWeekdayLabelDays+1)):
|
||||||
|
return prefix + releaseDay.Format("Monday")
|
||||||
default:
|
default:
|
||||||
return prefix + release.Format("Monday")
|
return prefix + releaseDay.Format("2 January")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,12 +73,13 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.RadarrAlertWindow <= 0 {
|
window := s.radarrAlertWindow()
|
||||||
|
if window <= 0 {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.publishAlert(r.Context(), alert, s.cfg.RadarrAlertWindow)
|
s.publishAlert(r.Context(), alert, window)
|
||||||
s.loggerFor(r.Context()).Info("radarr import announced",
|
s.loggerFor(r.Context()).Info("radarr import announced",
|
||||||
"movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality)
|
"movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true})
|
||||||
|
|||||||
@@ -8,50 +8,91 @@ import (
|
|||||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInFiveDayWindow(t *testing.T) {
|
func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInMonthWindow(t *testing.T) {
|
||||||
location := time.FixedZone("NZST", 12*60*60)
|
location := time.FixedZone("NZST", 12*60*60)
|
||||||
now := time.Date(2026, 7, 30, 9, 0, 0, 0, location)
|
now := time.Date(2026, 7, 30, 9, 0, 0, 0, location)
|
||||||
digitalToday := time.Date(2026, 7, 30, 0, 0, 0, 0, location).UTC()
|
day := func(y int, m time.Month, d int) time.Time {
|
||||||
digitalSunday := time.Date(2026, 8, 2, 0, 0, 0, 0, location).UTC()
|
return time.Date(y, m, d, 0, 0, 0, 0, location).UTC()
|
||||||
outside := time.Date(2026, 8, 4, 0, 0, 0, 0, location).UTC()
|
}
|
||||||
theatricalOnly := time.Date(2026, 7, 1, 0, 0, 0, 0, location).UTC()
|
digitalToday := day(2026, 7, 30)
|
||||||
oldDigital := time.Date(1993, 4, 9, 0, 0, 0, 0, location).UTC()
|
digitalSunday := day(2026, 8, 2)
|
||||||
modernRerelease := time.Date(2026, 7, 2, 0, 0, 0, 0, location).UTC()
|
// Three weeks out: inside the month window, past the point a weekday still names a day.
|
||||||
|
digitalLater := day(2026, 8, 20)
|
||||||
|
// The day the window closes, which is exclusive.
|
||||||
|
beyondWindow := day(2026, 8, 29)
|
||||||
|
theatricalOnly := day(2026, 7, 1)
|
||||||
|
oldDigital := day(1993, 4, 9)
|
||||||
|
modernRerelease := day(2026, 7, 2)
|
||||||
|
|
||||||
row, err := buildRadarrRow([]radarr.Movie{
|
row, err := buildRadarrRow([]radarr.Movie{
|
||||||
{ID: 1, Title: "Today", DigitalRelease: &digitalToday, Monitored: true},
|
{ID: 1, Title: "Today", DigitalRelease: &digitalToday, Monitored: true},
|
||||||
{ID: 2, Title: "Sunday", DigitalRelease: &digitalSunday, Monitored: true},
|
{ID: 2, Title: "Sunday", DigitalRelease: &digitalSunday, Monitored: true},
|
||||||
{ID: 3, Title: "Outside", DigitalRelease: &outside, Monitored: true},
|
{ID: 3, Title: "Three Weeks Out", DigitalRelease: &digitalLater, Monitored: true},
|
||||||
{ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true},
|
{ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true},
|
||||||
{ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true},
|
{ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true},
|
||||||
|
{ID: 6, Title: "Beyond Window", DigitalRelease: &beyondWindow, Monitored: true},
|
||||||
}, now, location)
|
}, now, location)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if row.ID != "radarr-upcoming-movies" || row.Kind != "movie-schedule" ||
|
if row.ID != "radarr-upcoming-movies" || row.Kind != "movie-schedule" ||
|
||||||
row.Title != "Upcoming Movie releases" || len(row.Items) != 3 {
|
row.Title != "Upcoming Movie releases" || len(row.Items) != 4 {
|
||||||
t.Fatalf("unexpected row: %+v", row)
|
t.Fatalf("unexpected row: %+v", row)
|
||||||
}
|
}
|
||||||
var first, second radarrScheduleItem
|
|
||||||
if err := json.Unmarshal(row.Items[0], &first); err != nil {
|
items := make([]radarrScheduleItem, len(row.Items))
|
||||||
|
for i, raw := range row.Items {
|
||||||
|
if err := json.Unmarshal(raw, &items[i]); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(row.Items[1], &second); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
if first.ID != "radarr:1" || first.MembyAirLabel != "Digital release today" {
|
want := []struct {
|
||||||
t.Fatalf("unexpected first item: %+v", first)
|
id, airLabel, dayLabel string
|
||||||
|
}{
|
||||||
|
{"radarr:1", "Digital release today", "Today"},
|
||||||
|
{"radarr:4", "Estimated digital release tomorrow", "Tomorrow"},
|
||||||
|
{"radarr:2", "Digital release Sunday", "Sunday"},
|
||||||
|
{"radarr:3", "Digital release 20 August", "20 Aug"},
|
||||||
}
|
}
|
||||||
if second.ID != "radarr:4" || second.MembyAirLabel != "Estimated digital release tomorrow" ||
|
for i, expect := range want {
|
||||||
second.MembyAvailabilityText != "Estimated digital release" {
|
got := items[i]
|
||||||
t.Fatalf("unexpected second item: %+v", second)
|
if got.ID != expect.id || got.MembyAirLabel != expect.airLabel ||
|
||||||
|
got.MembyAirDayLabel != expect.dayLabel {
|
||||||
|
t.Fatalf("item %d: got id=%s air=%q day=%q, want id=%s air=%q day=%q",
|
||||||
|
i, got.ID, got.MembyAirLabel, got.MembyAirDayLabel,
|
||||||
|
expect.id, expect.airLabel, expect.dayLabel)
|
||||||
}
|
}
|
||||||
var third radarrScheduleItem
|
|
||||||
if err := json.Unmarshal(row.Items[2], &third); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
if third.ID != "radarr:2" || third.MembyAirLabel != "Digital release Sunday" {
|
if items[1].MembyAvailabilityText != "Estimated digital release" {
|
||||||
t.Fatalf("unexpected third item: %+v", third)
|
t.Fatalf("unexpected estimated availability: %+v", items[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A weekday only names an unambiguous day inside the coming week. The month-long window
|
||||||
|
// routinely holds dates past that, where "Friday" would read as this Friday.
|
||||||
|
func TestRadarrReleaseLabelsCarryADateBeyondTheComingWeek(t *testing.T) {
|
||||||
|
location := time.FixedZone("NZST", 12*60*60)
|
||||||
|
now := time.Date(2026, 7, 30, 9, 0, 0, 0, location)
|
||||||
|
cases := []struct {
|
||||||
|
offset int
|
||||||
|
dayLabel string
|
||||||
|
airLabel string
|
||||||
|
}{
|
||||||
|
{0, "Today", "Digital release today"},
|
||||||
|
{1, "Tomorrow", "Digital release tomorrow"},
|
||||||
|
{2, "Saturday", "Digital release Saturday"},
|
||||||
|
{radarrWeekdayLabelDays, "Wednesday", "Digital release Wednesday"},
|
||||||
|
{radarrWeekdayLabelDays + 1, "6 Aug", "Digital release 6 August"},
|
||||||
|
{21, "20 Aug", "Digital release 20 August"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
release := now.AddDate(0, 0, tc.offset)
|
||||||
|
if got := radarrReleaseDayLabel(release, now, location); got != tc.dayLabel {
|
||||||
|
t.Errorf("offset %d: day label = %q, want %q", tc.offset, got, tc.dayLabel)
|
||||||
|
}
|
||||||
|
if got := digitalReleaseLabel(release, now, location, false); got != tc.airLabel {
|
||||||
|
t.Errorf("offset %d: air label = %q, want %q", tc.offset, got, tc.airLabel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func (s *Server) personalizeTitles(
|
|||||||
profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID)
|
profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID)
|
||||||
cfg := s.weightedConfig()
|
cfg := s.weightedConfig()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
location := s.cfg.SonarrLocation
|
location := s.householdLocation()
|
||||||
for index := range rows {
|
for index := range rows {
|
||||||
row := &rows[index]
|
row := &rows[index]
|
||||||
if progressRow(row.ID) {
|
if progressRow(row.ID) {
|
||||||
|
|||||||
@@ -112,27 +112,34 @@ func librarySyncTitle(changed int) string {
|
|||||||
//
|
//
|
||||||
// Only *transitions* are announced. A server that is down stays down, and repeating it
|
// Only *transitions* are announced. A server that is down stays down, and repeating it
|
||||||
// every minute would bury everything else.
|
// every minute would bury everything else.
|
||||||
func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Duration) {
|
// The cadence is read on every tick rather than fixed at start-up, because it is an
|
||||||
if interval <= 0 {
|
// operator setting the console can change: a loop that captured it would leave the one
|
||||||
return
|
// probe an operator is most likely to want to slow down or switch off needing a restart
|
||||||
}
|
// of the gateway to do either. A probe that is off still ticks, slowly, so that turning
|
||||||
ticker := time.NewTicker(interval)
|
// it back on does not need one either.
|
||||||
defer ticker.Stop()
|
func (s *Server) WatchEmbyReachability(ctx context.Context) {
|
||||||
|
|
||||||
// Start out assuming reachable: a gateway booting while Emby is down should not
|
// Start out assuming reachable: a gateway booting while Emby is down should not
|
||||||
// open with a banner about a state nobody has seen change.
|
// open with a banner about a state nobody has seen change.
|
||||||
reachable := true
|
reachable := true
|
||||||
failures := 0
|
failures := 0
|
||||||
|
interval := s.embyHealthInterval()
|
||||||
// The same probe feeds the live state on /v1/status, so one request to Emby answers
|
// The same probe feeds the live state on /v1/status, so one request to Emby answers
|
||||||
// both "did this just change" and "is it working right now". Declared before the
|
// both "did this just change" and "is it working right now". Declared before the
|
||||||
// first tick so a client asking during the opening minute learns the retry interval.
|
// first tick so a client asking during the opening minute learns the retry interval.
|
||||||
s.embyHealth.begin(interval, time.Now().UTC())
|
s.embyHealth.retune(interval, time.Now().UTC())
|
||||||
|
timer := time.NewTimer(embyProbeDelay(interval))
|
||||||
|
defer timer.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-timer.C:
|
||||||
if s.quietTimeActive() {
|
if next := s.embyHealthInterval(); next != interval {
|
||||||
|
interval = next
|
||||||
|
s.embyHealth.retune(interval, time.Now().UTC())
|
||||||
|
}
|
||||||
|
timer.Reset(embyProbeDelay(interval))
|
||||||
|
if interval <= 0 || s.quietTimeActive() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
@@ -160,6 +167,16 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// embyProbeDelay is how long to wait before looking again. With the probe switched off it
|
||||||
|
// is the interval at which the loop asks whether it has been switched back on, which is a
|
||||||
|
// minute because nothing is watching and nothing is being asked of Emby.
|
||||||
|
func embyProbeDelay(interval time.Duration) time.Duration {
|
||||||
|
if interval <= 0 {
|
||||||
|
return time.Minute
|
||||||
|
}
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
|
||||||
// reachabilityAlert is timestamped per transition, so the "back online" banner never
|
// reachabilityAlert is timestamped per transition, so the "back online" banner never
|
||||||
// collides with the "not responding" one it replaces.
|
// collides with the "not responding" one it replaces.
|
||||||
func (s *Server) reachabilityAlert(up bool) clientAlert {
|
func (s *Server) reachabilityAlert(up bool) clientAlert {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.50
|
0.1.52
|
||||||
|
|||||||
@@ -273,6 +273,66 @@ func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find searches Emby for a term and imports whatever comes back, answering with the
|
||||||
|
// payloads as they were stored.
|
||||||
|
//
|
||||||
|
// The catalogue is a copy, and a copy is only ever as fresh as the last import — up to
|
||||||
|
// MEMBY_SYNC_INTERVAL behind, an hour by default. That is invisible to a viewer, whose
|
||||||
|
// rows are read from Emby live, and very visible to an operator, who cannot pin a film to
|
||||||
|
// the hero until the gateway has heard of it. So the picker asks Emby itself, and what it
|
||||||
|
// finds is *adopted* rather than merely displayed: an id that came back from here resolves
|
||||||
|
// through LibraryItemsByID immediately, which is what the policy validation and the hero
|
||||||
|
// row both read, so nothing downstream has to know the title arrived early.
|
||||||
|
//
|
||||||
|
// It imports with exactly the fields a scheduled pass uses. Writing a thinner payload
|
||||||
|
// would leave an adopted title missing People, MediaStreams and ProviderIds until Emby
|
||||||
|
// next reported it changed — which for a film nobody edits again is never.
|
||||||
|
func (s *Syncer) Find(ctx context.Context, term string, limit int) ([]json.RawMessage, error) {
|
||||||
|
trimmed := strings.TrimSpace(term)
|
||||||
|
if trimmed == "" || limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cred, err := s.credentials(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
page, err := s.emby.Items(ctx, cred, url.Values{
|
||||||
|
"SearchTerm": {trimmed},
|
||||||
|
"IncludeItemTypes": {syncItemTypes},
|
||||||
|
"Recursive": {"true"},
|
||||||
|
"Limit": {strconv.Itoa(limit)},
|
||||||
|
"Fields": {syncFields},
|
||||||
|
"ImageTypeLimit": {"1"},
|
||||||
|
"EnableImages": {"true"},
|
||||||
|
"EnableImageTypes": {syncImageTypes},
|
||||||
|
"EnableTotalRecordCount": {"false"},
|
||||||
|
"EnableUserData": {"false"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("library: search emby: %w", err)
|
||||||
|
}
|
||||||
|
items := make([]store.LibraryItem, 0, len(page.Items))
|
||||||
|
found := make([]json.RawMessage, 0, len(page.Items))
|
||||||
|
for _, raw := range page.Items {
|
||||||
|
item, ok := toLibraryItem(raw)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
found = append(found, item.Payload)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// Stamped now, like any other import. A full pass sweeps rows carrying a stamp older
|
||||||
|
// than the pass itself, so a title adopted while one is running is never its victim.
|
||||||
|
if _, err := s.store.UpsertLibraryItems(ctx, items, time.Now().UTC()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.log.Info("library search adopted titles", "term", trimmed, "found", len(found))
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Schedule runs an incremental import on an interval until ctx is cancelled.
|
// Schedule runs an incremental import on an interval until ctx is cancelled.
|
||||||
//
|
//
|
||||||
// New episodes tend to land through the day and films weekly; an hourly incremental pass
|
// New episodes tend to land through the day and films weekly; an hourly incremental pass
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeviceActivityRetention is how long the per-day marks are kept. They are not history
|
||||||
|
// anybody reads — the notification they produced is the record, and that is itself pruned
|
||||||
|
// at thirty days — so this only has to be long enough that a clock correction or a
|
||||||
|
// database restored from a backup cannot make yesterday look like a day that never
|
||||||
|
// happened.
|
||||||
|
const DeviceActivityRetention = 90 * 24 * time.Hour
|
||||||
|
|
||||||
|
// MarkDeviceDay records that a television was in use on a household-local day, and
|
||||||
|
// reports whether this is the first time it has been seen today.
|
||||||
|
//
|
||||||
|
// The answer comes from the insert rather than from a read followed by a write, because
|
||||||
|
// every set in the house can reach this at once and two of them racing on the same row
|
||||||
|
// must not both be told they were first. `ON CONFLICT DO NOTHING` makes the primary key
|
||||||
|
// the arbiter: exactly one insert affects a row.
|
||||||
|
//
|
||||||
|
// It is keyed on the viewer as well as the television because a household set that two
|
||||||
|
// people sign into is two people opening Memby, and an operator reading the feed wants to
|
||||||
|
// know which of them it was.
|
||||||
|
func (s *Store) MarkDeviceDay(
|
||||||
|
ctx context.Context, deviceID, userID string, day time.Time,
|
||||||
|
) (bool, error) {
|
||||||
|
if deviceID == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO device_activity_days (device_id, emby_user_id, day)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (device_id, emby_user_id, day) DO NOTHING`,
|
||||||
|
deviceID, userID, day.Format("2006-01-02"))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("store: mark device day: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneDeviceActivityDays drops marks older than the retention window.
|
||||||
|
func (s *Store) PruneDeviceActivityDays(
|
||||||
|
ctx context.Context, retention time.Duration,
|
||||||
|
) (int64, error) {
|
||||||
|
if retention <= 0 {
|
||||||
|
retention = DeviceActivityRetention
|
||||||
|
}
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`DELETE FROM device_activity_days WHERE first_seen_at < now() - $1::interval`,
|
||||||
|
fmt.Sprintf("%d seconds", int64(retention.Seconds())))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("store: prune device activity days: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDeviceActivityDays retires a television's marks along with the television, the
|
||||||
|
// way its build history is retired: a set that is gone must not be able to announce a
|
||||||
|
// first use it can no longer have.
|
||||||
|
func (s *Store) DeleteDeviceActivityDays(ctx context.Context, deviceIDs ...string) error {
|
||||||
|
if len(deviceIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`DELETE FROM device_activity_days WHERE device_id = ANY($1::text[])`, deviceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete device activity days: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GatewaySettingsKey is the app_settings row backing the gateway's own settings.
|
||||||
|
const GatewaySettingsKey = "gateway_settings"
|
||||||
|
|
||||||
|
// GatewaySettings is the handful of server-level decisions an operator can change
|
||||||
|
// without a redeployment.
|
||||||
|
//
|
||||||
|
// Every field is an *override* of the value the container was started with, and the zero
|
||||||
|
// value means "whatever was deployed". That is the whole design: `.env` remains the
|
||||||
|
// configuration — it is what a fresh container comes up with, what deploy-server.ps1
|
||||||
|
// writes and what an operator locked out of the console still has — and this row is a
|
||||||
|
// runtime amendment to it, so the console can always show the deployed value beside the
|
||||||
|
// one in force and an operator can put a setting back by clearing it rather than by
|
||||||
|
// remembering what it used to be.
|
||||||
|
//
|
||||||
|
// A window that can legitimately be *off* therefore needs a value distinct from "not
|
||||||
|
// overridden", which is why the two alert windows and the health interval take -1 for off
|
||||||
|
// and 0 for deployed. A plain zero would make "turn this off" indistinguishable from
|
||||||
|
// "leave it alone", and the operator would find the setting quietly ignored.
|
||||||
|
type GatewaySettings struct {
|
||||||
|
// Timezone is an IANA name. It decides the household's local day, which is what the
|
||||||
|
// schedule rows, the hero rotation, the sign-in history and the first-use
|
||||||
|
// notifications are all grouped by.
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
// LogLevel is one of trace, debug, info, warn, error. It takes effect immediately on
|
||||||
|
// the running process, which is the point of it being here: turning debug on to watch
|
||||||
|
// something happen is worth nothing if it costs a restart of the thing being watched.
|
||||||
|
LogLevel string `json:"logLevel"`
|
||||||
|
// SessionIdleDays is how long a television may go unused before it is signed out.
|
||||||
|
SessionIdleDays int `json:"sessionIdleDays"`
|
||||||
|
// SonarrAlertMinutes and RadarrAlertMinutes are how long a "just aired" or "new movie
|
||||||
|
// added" banner stays on offer to a television that was switched off at the time.
|
||||||
|
SonarrAlertMinutes int `json:"sonarrAlertMinutes"`
|
||||||
|
RadarrAlertMinutes int `json:"radarrAlertMinutes"`
|
||||||
|
// EmbyHealthSeconds is how often the reachability probe asks Emby whether it is there.
|
||||||
|
EmbyHealthSeconds int `json:"embyHealthSeconds"`
|
||||||
|
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GatewaySettingsOff is the value a duration-like override takes to mean "switched off",
|
||||||
|
// as distinct from the zero that means "use the deployed value".
|
||||||
|
const GatewaySettingsOff = -1
|
||||||
|
|
||||||
|
// GatewayLogLevels is the vocabulary the console offers, in order of severity. It is here
|
||||||
|
// rather than in the API because normalisation has to refuse a level nothing can parse,
|
||||||
|
// and refusing is worth nothing if the list it refuses against lives somewhere else.
|
||||||
|
var GatewayLogLevels = []string{"trace", "debug", "info", "warn", "error"}
|
||||||
|
|
||||||
|
func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
|
||||||
|
settings.Timezone = strings.TrimSpace(settings.Timezone)
|
||||||
|
if settings.Timezone != "" {
|
||||||
|
// A timezone that will not load is dropped rather than stored: the alternative is
|
||||||
|
// a row that every later read has to fail on, and the failure would surface as a
|
||||||
|
// household whose idea of "today" quietly reverted with nothing saying why.
|
||||||
|
if _, err := time.LoadLocation(settings.Timezone); err != nil {
|
||||||
|
settings.Timezone = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings.LogLevel = strings.ToLower(strings.TrimSpace(settings.LogLevel))
|
||||||
|
if settings.LogLevel != "" && !containsString(GatewayLogLevels, settings.LogLevel) {
|
||||||
|
settings.LogLevel = ""
|
||||||
|
}
|
||||||
|
settings.SessionIdleDays = clampOverride(settings.SessionIdleDays, 1, 3650, false)
|
||||||
|
settings.SonarrAlertMinutes = clampOverride(settings.SonarrAlertMinutes, 1, 24*60, true)
|
||||||
|
settings.RadarrAlertMinutes = clampOverride(settings.RadarrAlertMinutes, 1, 7*24*60, true)
|
||||||
|
settings.EmbyHealthSeconds = clampOverride(settings.EmbyHealthSeconds, 10, 3600, true)
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampOverride keeps 0 meaning "deployed" and, where the setting can be switched off,
|
||||||
|
// keeps every negative number meaning off rather than only -1 — an operator typing -5 has
|
||||||
|
// said the same thing, and storing it verbatim would produce a second value with the same
|
||||||
|
// meaning that every reader would have to know about.
|
||||||
|
func clampOverride(value, low, high int, offAllowed bool) int {
|
||||||
|
if value == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if value < 0 {
|
||||||
|
if offAllowed {
|
||||||
|
return GatewaySettingsOff
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return max(low, min(value, high))
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, value string) bool {
|
||||||
|
for _, candidate := range values {
|
||||||
|
if candidate == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GatewaySettings(ctx context.Context) (GatewaySettings, error) {
|
||||||
|
var raw []byte
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT value FROM app_settings WHERE key = $1`, GatewaySettingsKey).Scan(&raw)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return GatewaySettings{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return GatewaySettings{}, fmt.Errorf("store: read gateway settings: %w", err)
|
||||||
|
}
|
||||||
|
var settings GatewaySettings
|
||||||
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||||
|
return GatewaySettings{}, fmt.Errorf("store: decode gateway settings: %w", err)
|
||||||
|
}
|
||||||
|
return normalizeGatewaySettings(settings), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGatewaySettings writes the overrides and returns what was actually stored, because
|
||||||
|
// normalisation can refuse a value and the console must show what is in force rather than
|
||||||
|
// what was typed.
|
||||||
|
func (s *Store) SetGatewaySettings(
|
||||||
|
ctx context.Context, settings GatewaySettings,
|
||||||
|
) (GatewaySettings, error) {
|
||||||
|
settings = normalizeGatewaySettings(settings)
|
||||||
|
settings.UpdatedAt = time.Now().UTC()
|
||||||
|
raw, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return GatewaySettings{}, err
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO app_settings (key, value, updated_at)
|
||||||
|
VALUES ($1, $2::jsonb, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||||
|
GatewaySettingsKey, string(raw))
|
||||||
|
if err != nil {
|
||||||
|
return GatewaySettings{}, fmt.Errorf("store: write gateway settings: %w", err)
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
settings := normalizeGatewaySettings(GatewaySettings{
|
||||||
|
Timezone: " Not/AZone ", LogLevel: "LOUD", SessionIdleDays: -4,
|
||||||
|
})
|
||||||
|
if settings.Timezone != "" {
|
||||||
|
t.Fatalf("an unloadable timezone should be dropped, got %q", settings.Timezone)
|
||||||
|
}
|
||||||
|
if settings.LogLevel != "" {
|
||||||
|
t.Fatalf("an unknown level should be dropped, got %q", settings.LogLevel)
|
||||||
|
}
|
||||||
|
// Session expiry cannot be switched off — a household with no expiry at all is a
|
||||||
|
// database full of live Emby tokens — so a negative reads as "leave it alone".
|
||||||
|
if settings.SessionIdleDays != 0 {
|
||||||
|
t.Fatalf("session expiry should not be switchable off, got %d", settings.SessionIdleDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeGatewaySettingsKeepsTheThreeMeanings(t *testing.T) {
|
||||||
|
settings := normalizeGatewaySettings(GatewaySettings{
|
||||||
|
Timezone: "Pacific/Auckland", LogLevel: " Debug ",
|
||||||
|
SonarrAlertMinutes: -9, RadarrAlertMinutes: 0, EmbyHealthSeconds: 5,
|
||||||
|
})
|
||||||
|
if settings.Timezone != "Pacific/Auckland" {
|
||||||
|
t.Fatalf("a real zone should survive, got %q", settings.Timezone)
|
||||||
|
}
|
||||||
|
if settings.LogLevel != "debug" {
|
||||||
|
t.Fatalf("a level should be trimmed and folded, got %q", settings.LogLevel)
|
||||||
|
}
|
||||||
|
if settings.SonarrAlertMinutes != GatewaySettingsOff {
|
||||||
|
t.Fatalf("every negative should collapse to the one off value, got %d",
|
||||||
|
settings.SonarrAlertMinutes)
|
||||||
|
}
|
||||||
|
if settings.RadarrAlertMinutes != 0 {
|
||||||
|
t.Fatalf("zero must keep meaning deployed, got %d", settings.RadarrAlertMinutes)
|
||||||
|
}
|
||||||
|
// Below the floor rather than refused: an operator asking for a five-second probe has
|
||||||
|
// said "as often as possible", and the answer to that is the fastest allowed.
|
||||||
|
if settings.EmbyHealthSeconds != 10 {
|
||||||
|
t.Fatalf("a value under the floor should clamp to it, got %d", settings.EmbyHealthSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,27 @@ CREATE TABLE IF NOT EXISTS device_versions (
|
|||||||
CREATE INDEX IF NOT EXISTS device_versions_recent_idx
|
CREATE INDEX IF NOT EXISTS device_versions_recent_idx
|
||||||
ON device_versions (device_id, last_seen_at DESC);
|
ON device_versions (device_id, last_seen_at DESC);
|
||||||
|
|
||||||
|
-- One row per television per day it was used.
|
||||||
|
--
|
||||||
|
-- It exists to answer "is this the first time this set has opened Memby today", which is
|
||||||
|
-- a question a counter or a timestamp cannot answer safely: every television in the house
|
||||||
|
-- asks at once, so the answer has to come from the insert itself. The primary key is what
|
||||||
|
-- makes it atomic — the row either went in, which means first, or it did not.
|
||||||
|
--
|
||||||
|
-- The day is the *household's* local day, computed against MEMBY_TIMEZONE and stored as a
|
||||||
|
-- plain date, because "today" is a thing the people watching have an opinion about and
|
||||||
|
-- UTC does not agree with it for most of a New Zealand evening.
|
||||||
|
CREATE TABLE IF NOT EXISTS device_activity_days (
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
emby_user_id TEXT NOT NULL,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (device_id, emby_user_id, day)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS device_activity_days_day_idx
|
||||||
|
ON device_activity_days (day);
|
||||||
|
|
||||||
-- The imported library.
|
-- The imported library.
|
||||||
--
|
--
|
||||||
-- payload is Emby's item JSON verbatim, so rows served from here are byte-identical to
|
-- payload is Emby's item JSON verbatim, so rows served from here are byte-identical to
|
||||||
|
|||||||
Reference in New Issue
Block a user