0.1.52 Gateway

This commit is contained in:
ponzischeme89
2026-08-17 19:09:17 +12:00
parent d5a8d3ad88
commit 1da91e40a1
46 changed files with 1626 additions and 106 deletions
+3 -2
View File
@@ -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
+47
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+2
View File
@@ -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 />} />
+35
View File
@@ -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;
}
+7
View File
@@ -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
View File
@@ -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 {
+273
View File
@@ -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
View File
@@ -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
View File
@@ -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 |
+15 -4
View File
@@ -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"
+3
View File
@@ -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)
} }
+4
View File
@@ -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"},
+1 -1
View File
@@ -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 {
+3 -6
View File
@@ -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
+25 -1
View File
@@ -99,7 +99,14 @@ type Server struct {
recommendationBuilds recommendationBuilds recommendationBuilds recommendationBuilds
maintenance maintenanceState maintenance maintenanceState
quietTime quietTimeState quietTime quietTimeState
updatePolicy updatePolicyCache // 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
// 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.
embyHealth embyHealth embyHealth embyHealth
@@ -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
+7
View File
@@ -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)
} }
+1 -4
View File
@@ -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(
+86
View File
@@ -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,
}),
})
}
+22
View File
@@ -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()
+186
View File
@@ -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)
}
+1 -4
View File
@@ -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")
+47 -6
View File
@@ -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
} }
results := make([]heroAdminItem, 0, len(items)) if s.syncer != nil && term != "" {
for _, raw := range items { found, findErr := s.syncer.Find(r.Context(), term, heroSearchLimit)
if item, ok := adminHeroItem(raw); ok { if findErr != nil {
results = append(results, item) s.loggerFor(r.Context()).Warn("hero live search unavailable", "error", findErr)
} }
items = append(items, found...)
} }
writeJSON(w, http.StatusOK, map[string]any{"items": results}) 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))
seen := make(map[string]bool, len(items))
for _, raw := range items {
if len(results) >= limit {
break
}
item, ok := adminHeroItem(raw)
if !ok || seen[item.ID] {
continue
}
seen[item.ID] = true
results = append(results, item)
}
return results
} }
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
+66
View File
@@ -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))
}
}
+2 -4
View File
@@ -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 {
+19 -2
View File
@@ -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
}, },
}) })
+2 -8
View File
@@ -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 {
+39 -7
View File
@@ -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")
} }
} }
+3 -2
View File
@@ -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})
+65 -24
View File
@@ -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))
t.Fatal(err) for i, raw := range row.Items {
if err := json.Unmarshal(raw, &items[i]); err != nil {
t.Fatal(err)
}
} }
if err := json.Unmarshal(row.Items[1], &second); err != nil { want := []struct {
t.Fatal(err) 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 first.ID != "radarr:1" || first.MembyAirLabel != "Digital release today" { for i, expect := range want {
t.Fatalf("unexpected first item: %+v", first) got := items[i]
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)
}
} }
if second.ID != "radarr:4" || second.MembyAirLabel != "Estimated digital release tomorrow" || if items[1].MembyAvailabilityText != "Estimated digital release" {
second.MembyAvailabilityText != "Estimated digital release" { t.Fatalf("unexpected estimated availability: %+v", items[1])
t.Fatalf("unexpected second item: %+v", second)
} }
var third radarrScheduleItem }
if err := json.Unmarshal(row.Items[2], &third); err != nil {
t.Fatal(err) // 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"},
} }
if third.ID != "radarr:2" || third.MembyAirLabel != "Digital release Sunday" { for _, tc := range cases {
t.Fatalf("unexpected third item: %+v", third) 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)
}
} }
} }
+1 -1
View File
@@ -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) {
+27 -10
View File
@@ -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
View File
@@ -1 +1 @@
0.1.50 0.1.52
+60
View File
@@ -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
+73
View File
@@ -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
}
+148
View File
@@ -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)
}
}
+21
View File
@@ -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