0.2.63 update
This commit is contained in:
+2
-2
@@ -14,8 +14,8 @@ POSTGRES_PASSWORD=7dfc3eb07108013c7bea9787457397551b5eb9059adac51aea4741f4ec649b
|
||||
# Fixed NAS host port used by the mserver.sublogue.com reverse proxy.
|
||||
MEMBY_PORT=32768
|
||||
|
||||
# INFO is recommended. DEBUG also logs successful health, status and artwork requests,
|
||||
# playback progress reports and search terms.
|
||||
# INFO is recommended. DEBUG adds diagnostic request and playback detail; TRACE adds the
|
||||
# deepest request and playback-negotiation trace. Sensitive structured values are redacted.
|
||||
MEMBY_LOG_LEVEL=INFO
|
||||
MEMBY_LOG_BUFFER_CAPACITY=5000
|
||||
# The Compose default is a named-volume-backed JSONL archive restored by the admin log.
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
## 0.1.42 — 2026-08-14
|
||||
- Improved: Server Logs keeps its headings visible and shows each structured log record’s full details, including gateway version, in an expandable view.
|
||||
|
||||
## 0.1.41 — 2026-08-14
|
||||
- Improved: Sonarr and Radarr can be enabled or disabled independently from the Integrations page without removing their saved settings.
|
||||
|
||||
## 0.1.40 — 2026-08-14
|
||||
- Fixed: TV requests use Memby's safe Sonarr request policy: a selected 720p profile, normal monitoring and no immediate backlog search unless an operator enables it.
|
||||
|
||||
## 0.2.62 — 2026-08-13
|
||||
- Fixed: Playback is back to the way it worked in 0.2.60. The groundwork for Memby playing video itself caused problems, so it has been withdrawn and will return once it is ready.
|
||||
|
||||
|
||||
-1
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -12,9 +12,9 @@
|
||||
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"
|
||||
/>
|
||||
<script type="module" crossorigin src="/admin/assets/index-BQ_ZF8Re.js"></script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-BpHOHb2g.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/admin/assets/router-BwjLFE7Y.js">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BIKdFbmA.css">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-CXwJRCVF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -392,9 +392,19 @@ export interface SonarrRequestPolicy {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RadarrRequestPolicy extends SonarrRequestPolicy {}
|
||||
|
||||
export interface ArrIntegrationStatus {
|
||||
sonarrConfigured: boolean;
|
||||
radarrConfigured: boolean;
|
||||
sonarrEnabled: boolean;
|
||||
radarrEnabled: boolean;
|
||||
}
|
||||
|
||||
/* ---------- logs ---------- */
|
||||
|
||||
export interface LogEvent {
|
||||
sequence: number;
|
||||
occurredAt: string;
|
||||
level: string;
|
||||
message: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { nav } from '../nav';
|
||||
import { useNotifications } from '../lib/notifications';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
import { time } from '../lib/format';
|
||||
import { Confirm } from './ui';
|
||||
|
||||
/* The shell: a top bar spanning the width, a rail down the left, and the page.
|
||||
*
|
||||
@@ -105,12 +106,12 @@ export function Layout() {
|
||||
const { version, online, checkedAt, status, setMaintenance } = useGateway();
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
const [changingAvailability, setChangingAvailability] = useState(false);
|
||||
const [confirmingOffline, setConfirmingOffline] = useState(false);
|
||||
const location = useLocation();
|
||||
const offline = Boolean(status?.maintenance?.enabled);
|
||||
|
||||
const toggleAvailability = async () => {
|
||||
if (changingAvailability || !status) return;
|
||||
if (!offline && !window.confirm('Take Memby offline for every television?')) return;
|
||||
setChangingAvailability(true);
|
||||
try {
|
||||
await setMaintenance(!offline);
|
||||
@@ -162,7 +163,7 @@ export function Layout() {
|
||||
aria-pressed={offline}
|
||||
disabled={!status || changingAvailability}
|
||||
title={offline ? 'Bring Memby back online' : 'Take Memby offline'}
|
||||
onClick={() => void toggleAvailability()}
|
||||
onClick={() => offline ? void toggleAvailability() : setConfirmingOffline(true)}
|
||||
>
|
||||
<span className="dot" />
|
||||
<b>{offline ? 'offline' : online ? 'online' : 'not responding'}</b>
|
||||
@@ -182,6 +183,20 @@ export function Layout() {
|
||||
<main className="page" id="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
{confirmingOffline ? (
|
||||
<Confirm
|
||||
title="Take Memby offline?"
|
||||
body="Every television will stop working immediately. Viewers will see the maintenance message configured on the Maintenance page, while this console remains available."
|
||||
confirmLabel="Go offline"
|
||||
destructive
|
||||
busy={changingAvailability}
|
||||
onConfirm={() => {
|
||||
setConfirmingOffline(false);
|
||||
void toggleAvailability();
|
||||
}}
|
||||
onCancel={() => setConfirmingOffline(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
@@ -11,31 +11,27 @@ import {
|
||||
Chip,
|
||||
Confirm,
|
||||
Empty,
|
||||
Field,
|
||||
Grid,
|
||||
Loading,
|
||||
PageHead,
|
||||
PlainTiles,
|
||||
Tag,
|
||||
Toggle,
|
||||
} from '../components/ui';
|
||||
|
||||
type Mode = 'default' | 'on' | 'off';
|
||||
|
||||
interface Pending {
|
||||
action: 'safe-mode' | 'rollback' | 'reset';
|
||||
action: 'safe-mode' | 'rollback' | 'reset' | 'feature';
|
||||
title: string;
|
||||
body: string;
|
||||
label: string;
|
||||
key?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function FeaturesPage() {
|
||||
const { status, error, loading, reload } = useGateway();
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
// The choices the operator has made but not published. Held apart from the server's
|
||||
// answer so a poll landing mid-edit cannot take a half-made decision away — the rule the
|
||||
// previous console needed `Admin.settled` for.
|
||||
const [draft, setDraft] = useState<Record<string, Mode>>({});
|
||||
const [pending, setPending] = useState<Pending | null>(null);
|
||||
|
||||
const policy = status?.features;
|
||||
@@ -43,20 +39,6 @@ export function FeaturesPage() {
|
||||
const clients = status?.clients ?? [];
|
||||
const revision = policy?.revision ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Adopt the server's state only where the operator has not expressed a preference.
|
||||
if (!policy) return;
|
||||
setDraft((current) => {
|
||||
const next = { ...current };
|
||||
for (const feature of policy.features ?? []) {
|
||||
if (next[feature.key] === undefined) {
|
||||
next[feature.key] = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default';
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [policy]);
|
||||
|
||||
const act = (action: string, key: string, message: string, overrides?: Record<string, boolean>) =>
|
||||
run(key, async () => {
|
||||
await wrap(
|
||||
@@ -71,24 +53,17 @@ export function FeaturesPage() {
|
||||
message,
|
||||
);
|
||||
setPending(null);
|
||||
if (action === 'save') {
|
||||
// Published: the draft is now the server's state, so stop holding it.
|
||||
setDraft({});
|
||||
}
|
||||
await reload();
|
||||
});
|
||||
|
||||
const publish = () => {
|
||||
const overrides: Record<string, boolean> = {};
|
||||
for (const [key, mode] of Object.entries(draft)) {
|
||||
if (mode === 'on') overrides[key] = true;
|
||||
if (mode === 'off') overrides[key] = false;
|
||||
}
|
||||
return act('save', 'save', 'Published.', overrides);
|
||||
};
|
||||
|
||||
const capable = clients.filter((client) => (client.capabilities ?? []).includes('server_features_v1')).length;
|
||||
const safeMode = Boolean(policy?.safeMode);
|
||||
const overridesFor = (key: string, enabled: boolean) => ({
|
||||
...Object.fromEntries(
|
||||
features.filter((feature) => feature.source === 'override').map((feature) => [feature.key, feature.enabled]),
|
||||
),
|
||||
[key]: enabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -160,18 +135,18 @@ export function FeaturesPage() {
|
||||
actions={<Tag tone={feature.enabled ? 'ok' : undefined}>{feature.enabled ? 'active' : 'off'}</Tag>}
|
||||
footer={<span className="hint">↳ {feature.recovery}</span>}
|
||||
>
|
||||
<Field label="Mode">
|
||||
<select
|
||||
value={draft[feature.key] ?? 'default'}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, [feature.key]: event.target.value as Mode }))
|
||||
}
|
||||
>
|
||||
<option value="default">Safe default</option>
|
||||
<option value="on">Forced on</option>
|
||||
<option value="off">Forced off</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Toggle
|
||||
label={feature.enabled ? 'On' : 'Off'}
|
||||
hint="Changing this applies the feature policy to every compatible television."
|
||||
checked={feature.enabled}
|
||||
disabled={busy === feature.key}
|
||||
onChange={(enabled) => setPending({
|
||||
action: 'feature', key: feature.key, enabled,
|
||||
title: `${enabled ? 'Turn on' : 'Turn off'} ${feature.name}?`,
|
||||
body: `${enabled ? 'Enable' : 'Disable'} this feature for every compatible television. ${feature.recovery}`,
|
||||
label: enabled ? 'Turn on' : 'Turn off',
|
||||
})}
|
||||
/>
|
||||
<div className="chips">
|
||||
<Chip>{feature.key}</Chip>
|
||||
<Chip>protocol {num(feature.minimumProtocol)}+</Chip>
|
||||
@@ -187,9 +162,6 @@ export function FeaturesPage() {
|
||||
|
||||
<Card>
|
||||
<div className="row">
|
||||
<Button variant="primary" busy={busy === 'save'} onClick={() => void publish()}>
|
||||
Publish changes
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!policy.canRollback}
|
||||
onClick={() =>
|
||||
@@ -233,7 +205,12 @@ export function FeaturesPage() {
|
||||
confirmLabel={pending.label}
|
||||
destructive={pending.action !== 'rollback'}
|
||||
busy={busy === pending.action}
|
||||
onConfirm={() => void act(pending.action, pending.action, `${pending.label} done.`)}
|
||||
onConfirm={() => void act(
|
||||
pending.action === 'feature' ? 'save' : pending.action,
|
||||
pending.action === 'feature' ? pending.key ?? 'feature' : pending.action,
|
||||
`${pending.label} done.`,
|
||||
pending.action === 'feature' && pending.key ? overridesFor(pending.key, Boolean(pending.enabled)) : undefined,
|
||||
)}
|
||||
onCancel={() => setPending(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Tag,
|
||||
Toggle,
|
||||
} from '../components/ui';
|
||||
import type { Integration, IntegrationEventOption, IntegrationsResponse, SonarrRequestPolicy } from '../api/types';
|
||||
import type { ArrIntegrationStatus, Integration, IntegrationEventOption, IntegrationsResponse, RadarrRequestPolicy, SonarrRequestPolicy } from '../api/types';
|
||||
|
||||
/* Integrations: administrative events going out to somewhere else.
|
||||
*
|
||||
@@ -111,7 +111,9 @@ export function IntegrationsPage() {
|
||||
|
||||
<Banner message={error} />
|
||||
|
||||
<ArrIntegrationCard />
|
||||
<SonarrRequestCard />
|
||||
<RadarrRequestCard />
|
||||
|
||||
{(data?.dropped ?? 0) > 0 ? (
|
||||
<Note tone="warn">
|
||||
@@ -170,6 +172,27 @@ export function IntegrationsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ArrIntegrationCard() {
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
const { data, error, loading, reload } = useQuery<ArrIntegrationStatus>('/admin/api/arr-integrations');
|
||||
const update = (next: Partial<ArrIntegrationStatus>) => run('arr-integrations', async () => {
|
||||
if (!data) return;
|
||||
await wrap(() => api.post('/admin/api/arr-integrations', {
|
||||
sonarrEnabled: next.sonarrEnabled ?? data.sonarrEnabled,
|
||||
radarrEnabled: next.radarrEnabled ?? data.radarrEnabled,
|
||||
}), 'Integration settings saved.');
|
||||
await reload();
|
||||
});
|
||||
return <Card title="Sonarr and Radarr" intro="Turn either service off without removing its address, API key or request policy. Disabled services are not offered for Memby requests." icon="plug" tone="info">
|
||||
<Banner message={error ?? ''} />
|
||||
{loading ? <Loading rows={2} /> : <>
|
||||
<Toggle label="Sonarr enabled" hint={data?.sonarrConfigured ? 'Off stops Memby sending or looking up TV requests through Sonarr.' : 'Sonarr is not configured.'} checked={Boolean(data?.sonarrEnabled)} disabled={!data?.sonarrConfigured || busy === 'arr-integrations'} onChange={(sonarrEnabled) => void update({ sonarrEnabled })} />
|
||||
<Toggle label="Radarr enabled" hint={data?.radarrConfigured ? 'Off stops Memby sending or looking up film requests through Radarr.' : 'Radarr is not configured.'} checked={Boolean(data?.radarrEnabled)} disabled={!data?.radarrConfigured || busy === 'arr-integrations'} onChange={(radarrEnabled) => void update({ radarrEnabled })} />
|
||||
</>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function SonarrRequestCard() {
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
@@ -219,6 +242,44 @@ function SonarrRequestCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function RadarrRequestCard() {
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
const { data, error, loading, reload } = useQuery<RadarrRequestPolicy>('/admin/api/radarr-request-policy');
|
||||
const [profileId, setProfileId] = useState(0);
|
||||
const [searchImmediately, setSearchImmediately] = useState(false);
|
||||
useEffect(() => {
|
||||
if (data) { setProfileId(data.qualityProfileId); setSearchImmediately(data.searchImmediately); }
|
||||
}, [data]);
|
||||
const save = () => run('radarr-request-policy', async () => {
|
||||
await wrap(() => api.post('/admin/api/radarr-request-policy', { qualityProfileId: profileId, searchImmediately }), 'Radarr movie request policy saved.');
|
||||
await reload();
|
||||
});
|
||||
const selected = data?.profiles.find((profile) => profile.id === profileId);
|
||||
return (
|
||||
<Card
|
||||
title="Radarr movie requests"
|
||||
intro="The policy Memby uses when a viewer requests a film. The film remains monitored; an immediate Radarr search is an explicit choice."
|
||||
icon="tv"
|
||||
tone={data?.configured ? 'ok' : 'warn'}
|
||||
actions={data?.configured ? <Tag tone="ok">configured</Tag> : <Tag tone="warn">needs attention</Tag>}
|
||||
footer={<Button variant="primary" busy={busy === 'radarr-request-policy'} disabled={loading || profileId <= 0} onClick={() => void save()}>Save Radarr policy</Button>}
|
||||
>
|
||||
<Banner message={error ?? data?.error ?? ''} />
|
||||
{loading ? <Loading rows={2} /> : <>
|
||||
<div className="fields"><Field label="Request quality profile" hint="Memby stores this Radarr profile ID. 720p is the recommended safe default; Memby will never fall back to Any.">
|
||||
<select value={profileId} onChange={(event) => setProfileId(Number(event.target.value))} disabled={!data?.profiles.length}>
|
||||
<option value={0}>Choose a quality profile…</option>
|
||||
{data?.profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}{profile.recommended ? ' — recommended (720p)' : ''}</option>)}
|
||||
</select>
|
||||
</Field></div>
|
||||
<Toggle label="Search for the film immediately after request" hint="Off adds and monitors the film without asking Radarr to search. Enable only when requests should start an immediate search." checked={searchImmediately} onChange={setSearchImmediately} />
|
||||
{selected ? <Note tone="info">Requested films will use <b>{selected.name}</b> (profile ID {selected.id}), remain monitored, and {searchImmediately ? 'start an immediate search.' : 'not start an immediate search.'}</Note> : null}
|
||||
</>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationCard({
|
||||
integration,
|
||||
catalogue,
|
||||
|
||||
+22
-15
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { num, when } from '../lib/format';
|
||||
import { Banner, Button, Card, Field, PageHead } from '../components/ui';
|
||||
@@ -18,9 +18,10 @@ const DRAW = 2_500;
|
||||
const POLL_MS = 5_000;
|
||||
|
||||
/* The same order the server's own console lines use — who and where first, the reason for
|
||||
the line last — so a log read here and a log read over SSH look alike. `version` is
|
||||
dropped: it is the same on every line and is reported once in the top bar instead. */
|
||||
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration'];
|
||||
the line last — so a log read here and a log read over SSH look alike. The gateway
|
||||
version deliberately remains visible: a server-log export is often read away from the
|
||||
console whose top bar would otherwise supply it. */
|
||||
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration', 'version', 'gateway_version'];
|
||||
|
||||
function orderedFields(attributes: Record<string, unknown>): [string, unknown][] {
|
||||
const rank = (key: string) => {
|
||||
@@ -28,11 +29,11 @@ function orderedFields(attributes: Record<string, unknown>): [string, unknown][]
|
||||
if (at >= 0) return at;
|
||||
return key === 'error' ? 1000 : 100;
|
||||
};
|
||||
return Object.entries(attributes)
|
||||
.filter(([key]) => key !== 'version')
|
||||
.sort((a, b) => rank(a[0]) - rank(b[0]));
|
||||
return Object.entries(attributes).sort((a, b) => rank(a[0]) - rank(b[0]));
|
||||
}
|
||||
|
||||
const readableKey = (key: string) => key.replace(/_/g, ' ');
|
||||
|
||||
const haystack = (event: LogEvent) =>
|
||||
[event.message, ...Object.entries(event.attributes ?? {}).flat()].join(' ').toLowerCase();
|
||||
|
||||
@@ -157,25 +158,31 @@ export function LogsPage() {
|
||||
</div>
|
||||
|
||||
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-live="polite">
|
||||
<div className="loghead" aria-hidden="true">
|
||||
<span>Time</span>
|
||||
<span>Level</span>
|
||||
<span>Event</span>
|
||||
<span>Details</span>
|
||||
</div>
|
||||
{visible.length === 0 ? (
|
||||
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
|
||||
) : (
|
||||
visible.map((event, index) => (
|
||||
<div className="logline" key={`${event.occurredAt}:${index}`} data-level={event.level}>
|
||||
<time>{when(event.occurredAt)}</time>
|
||||
<time title={`Log record ${event.sequence}`}>{when(event.occurredAt)}<small>#{event.sequence}</small></time>
|
||||
{/* The level is a class on the line as well as its own column: scrolling a
|
||||
log is looking for the one line that is not INFO, and a coloured word
|
||||
four columns in is easy to scroll past. */}
|
||||
<span className="lvl">{event.level}</span>
|
||||
<span className="msg">{event.message}</span>
|
||||
<span className="attrs">
|
||||
{orderedFields(event.attributes ?? {}).map(([key, value]) => (
|
||||
<span key={key}>
|
||||
{' '}
|
||||
<b>{key}=</b>
|
||||
{String(value)}
|
||||
</span>
|
||||
))}
|
||||
<details className="logdetails">
|
||||
<summary>{orderedFields(event.attributes ?? {}).map(([key, value]) => <span key={key}><b>{key}=</b>{String(value)} </span>)}</summary>
|
||||
<dl>
|
||||
<dt>Log record</dt><dd>{event.sequence}</dd>
|
||||
{orderedFields(event.attributes ?? {}).map(([key, value]) => <Fragment key={key}><dt>{readableKey(key)}</dt><dd>{String(value)}</dd></Fragment>)}
|
||||
</dl>
|
||||
</details>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
|
||||
+58
-3
@@ -744,9 +744,9 @@ a {
|
||||
background: var(--surface);
|
||||
}
|
||||
.tile .glyph {
|
||||
/* Tiles keep their figures left-aligned for quick scanning, but the visual glyph is a
|
||||
standalone marker and follows the centred treatment used by glyph plates elsewhere. */
|
||||
margin: 0 auto 9px;
|
||||
/* Summary cards share tile padding, so their marker always starts at the same top-left
|
||||
inset. The SVG itself remains centred by .glyph's grid treatment. */
|
||||
margin: 0 0 9px;
|
||||
}
|
||||
.tile b {
|
||||
display: block;
|
||||
@@ -1442,6 +1442,22 @@ select {
|
||||
background: #080a0e;
|
||||
font: 12px/1.6 var(--mono);
|
||||
}
|
||||
.loghead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 46px minmax(180px, 1fr) minmax(240px, 2fr);
|
||||
gap: 12px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #10131a;
|
||||
color: var(--quiet);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.logline {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -1457,6 +1473,11 @@ select {
|
||||
flex: 0 0 auto;
|
||||
color: var(--quiet);
|
||||
}
|
||||
.logline time small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.logline .lvl {
|
||||
flex: 0 0 46px;
|
||||
font-weight: 700;
|
||||
@@ -1480,10 +1501,44 @@ select {
|
||||
.logline .attrs {
|
||||
color: var(--quiet);
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.logline {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 46px minmax(180px, 1fr) minmax(240px, 2fr);
|
||||
}
|
||||
}
|
||||
.logline .attrs b {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.logdetails {
|
||||
min-width: 0;
|
||||
}
|
||||
.logdetails summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.logdetails summary::-webkit-details-marker { display: none; }
|
||||
.logdetails summary::before {
|
||||
content: '›';
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.logdetails[open] summary::before { transform: rotate(90deg); }
|
||||
.logdetails dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, max-content) minmax(0, 1fr);
|
||||
gap: 4px 12px;
|
||||
margin: 7px 0 2px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, .025);
|
||||
}
|
||||
.logdetails dt { color: var(--muted); }
|
||||
.logdetails dd { min-width: 0; margin: 0; color: var(--text); overflow-wrap: anywhere; }
|
||||
|
||||
pre.code {
|
||||
margin: 0;
|
||||
|
||||
@@ -15,6 +15,10 @@ val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?)
|
||||
// The Memby gateway container. When set, the client talks to it instead of Emby and
|
||||
// becomes a thin renderer; blank keeps the direct-to-Emby path above.
|
||||
val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim()
|
||||
// Diagnostic verbosity is a build-time switch so a support APK can capture deep network
|
||||
// and Media3 state without changing call sites. Valid values: INFO, DEBUG, TRACE.
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
// Kept in BuildConfig so the TV can show the exact corresponding-source location and
|
||||
// the complete legal documents offline. Deployments can override the public source URL
|
||||
@@ -90,6 +94,7 @@ android {
|
||||
|
||||
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "DIAGNOSTIC_LOG_LEVEL", "\"$membyDiagnosticLogLevel\"")
|
||||
buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
|
||||
buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText))
|
||||
buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText))
|
||||
|
||||
@@ -41,6 +41,7 @@ object EmbyServiceFactory {
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
|
||||
.addInterceptor(DiagnosticNetworkInterceptor("emby"))
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.gatewayAudioTokens
|
||||
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
|
||||
import com.ponzischeme89.memby.update.RequiredUpdateSignal
|
||||
import com.ponzischeme89.memby.diagnostics.MembyDiagnostics
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -37,6 +38,7 @@ object GatewayServiceFactory {
|
||||
// read timeout here only ever means Emby itself is struggling behind it.
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.addInterceptor(GatewayAuthInterceptor(tokenProvider))
|
||||
.addInterceptor(DiagnosticNetworkInterceptor("gateway"))
|
||||
.addInterceptor(RequiredUpdateInterceptor)
|
||||
.build()
|
||||
|
||||
@@ -49,6 +51,28 @@ object GatewayServiceFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/** Timings and outcomes only: request URLs are stripped of query parameters and no headers are read. */
|
||||
internal class DiagnosticNetworkInterceptor(private val backend: String) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val started = android.os.SystemClock.elapsedRealtime()
|
||||
MembyDiagnostics.trace("http_started", "backend" to backend, "method" to request.method, "url" to MembyDiagnostics.safeUrl(request.url))
|
||||
return try {
|
||||
chain.proceed(request).also { response ->
|
||||
MembyDiagnostics.debug("http_finished", "backend" to backend, "method" to request.method,
|
||||
"url" to MembyDiagnostics.safeUrl(request.url), "status" to response.code,
|
||||
"duration_ms" to (android.os.SystemClock.elapsedRealtime() - started),
|
||||
"correlation" to response.header("X-Memby-Correlation"))
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
MembyDiagnostics.debug("http_failed", "backend" to backend, "method" to request.method,
|
||||
"url" to MembyDiagnostics.safeUrl(request.url), "duration_ms" to (android.os.SystemClock.elapsedRealtime() - started),
|
||||
"exception" to error.javaClass.simpleName, "detail" to error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the gateway token as a bearer header. Image URLs cannot carry headers, so those
|
||||
* are built with a `t=` query parameter instead (see EmbyRepository's URL helpers).
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ponzischeme89.memby.diagnostics
|
||||
|
||||
import android.util.Log
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/** Small, dependency-free diagnostic log shared by network and playback code. */
|
||||
object MembyDiagnostics {
|
||||
private const val TAG = "MembyDiagnostic"
|
||||
private val rank = mapOf("TRACE" to 0, "DEBUG" to 1, "INFO" to 2)
|
||||
private val configured = rank[BuildConfig.DIAGNOSTIC_LOG_LEVEL] ?: 2
|
||||
|
||||
fun debug(event: String, vararg fields: Pair<String, Any?>) = write("DEBUG", event, fields)
|
||||
fun trace(event: String, vararg fields: Pair<String, Any?>) = write("TRACE", event, fields)
|
||||
fun info(event: String, vararg fields: Pair<String, Any?>) = write("INFO", event, fields)
|
||||
|
||||
private fun write(level: String, event: String, fields: Array<out Pair<String, Any?>>) {
|
||||
if ((rank[level] ?: Int.MAX_VALUE) < configured) return
|
||||
val text = buildString {
|
||||
append("event=").append(event)
|
||||
fields.forEach { (key, value) -> append(' ').append(key).append('=').append(safe(key, value)) }
|
||||
}
|
||||
when (level) { "INFO" -> Log.i(TAG, text); else -> Log.d(TAG, text) }
|
||||
}
|
||||
|
||||
fun safeUrl(url: HttpUrl): String = url.newBuilder().query(null).build().toString()
|
||||
private fun safe(key: String, value: Any?): String {
|
||||
val lower = key.lowercase()
|
||||
if (lower.contains("token") || lower.contains("password") || lower.contains("secret") || lower.contains("cookie") || lower.contains("authorization") || lower.contains("key")) return "[redacted]"
|
||||
return value?.toString()?.replace(SENSITIVE_QUERY_VALUE, "$1[redacted]") ?: "null"
|
||||
}
|
||||
|
||||
private val SENSITIVE_QUERY_VALUE = Regex("([?&](?:api[_-]?key|token|auth(?:orization)?|t)=)[^&\\s]+", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import coil.load
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.diagnostics.MembyDiagnostics
|
||||
import com.ponzischeme89.memby.data.audioPassthroughPreference
|
||||
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
|
||||
import com.ponzischeme89.memby.data.IntroSegment
|
||||
@@ -609,6 +610,9 @@ class PlayerActivity : ComponentActivity() {
|
||||
trace.mark(PlaybackTrace.PLAYER_BUILT)
|
||||
playback.addListener(object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
MembyDiagnostics.debug("media3_state", "playback" to playSessionId, "item" to itemId, "state" to media3StateName(playbackState),
|
||||
"play_when_ready" to playback.playWhenReady, "position_ms" to playback.currentPosition,
|
||||
"buffered_ms" to playback.bufferedPosition)
|
||||
recordPlaybackState(playbackState)
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
@@ -640,6 +644,8 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
MembyDiagnostics.debug("media3_playing", "playback" to playSessionId, "item" to itemId, "is_playing" to isPlaying,
|
||||
"position_ms" to playback.currentPosition)
|
||||
if (playbackStarted && !stopReported) {
|
||||
reportProgress(
|
||||
playback.currentPosition,
|
||||
@@ -664,6 +670,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
MembyDiagnostics.trace("media3_tracks_changed", "playback" to playSessionId, "item" to itemId, "groups" to tracks.groups.size)
|
||||
updateStreamStatus(tracks)
|
||||
ensureSubtitleSelected(tracks)
|
||||
}
|
||||
@@ -674,10 +681,14 @@ class PlayerActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
MembyDiagnostics.debug("media3_error", "playback" to playSessionId, "item" to itemId, "code" to error.errorCodeName,
|
||||
"exception" to error.cause?.javaClass?.simpleName, "detail" to error.message)
|
||||
handlePlaybackError(error)
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
MembyDiagnostics.info("media3_first_frame", "playback" to playSessionId, "item" to itemId,
|
||||
"startup_ms" to (SystemClock.elapsedRealtime() - requestStartedAtMs))
|
||||
renderedFirstFrame = true
|
||||
trailerStartupTimeoutJob?.cancel()
|
||||
trailerStartupTimeoutJob = null
|
||||
@@ -1726,6 +1737,14 @@ class PlayerActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun media3StateName(state: Int): String = when (state) {
|
||||
Player.STATE_IDLE -> "idle"
|
||||
Player.STATE_BUFFERING -> "buffering"
|
||||
Player.STATE_READY -> "ready"
|
||||
Player.STATE_ENDED -> "ended"
|
||||
else -> "unknown($state)"
|
||||
}
|
||||
|
||||
private fun updateStreamStatus(tracks: Tracks) {
|
||||
val selected = tracks.groups.flatMap { group ->
|
||||
(0 until group.length)
|
||||
|
||||
+12
-8
@@ -61,8 +61,8 @@ The server writes one aligned line per event, designed for
|
||||
`docker compose logs -f server` and for the admin page's live log:
|
||||
|
||||
```
|
||||
2026-08-03 11:33:40 INFO playback requested component=playback user=matt device="Living room" client=0.1.60 title="Severance – Good News About Hell" item=184223 type=Episode play_method=DirectStream resume=12m0s runtime=57m0s version=0.1.0
|
||||
2026-08-03 11:33:40 INFO request component=home user=matt device="Living room" client=0.1.60 protocol=1 method=GET path=/v1/home status=200 duration=412ms cache=miss version=0.1.0
|
||||
2026-08-03 11:33:40 INFO playback requested component=playback user=matt device="Living room" client=0.1.60 correlation=req-8F2A1C play_session_id=8F2A1C title="Severance – Good News About Hell" item=184223 type=Episode play_method=DirectStream resume=12m0s runtime=57m0s version=0.1.0
|
||||
2026-08-03 11:33:40 INFO request component=home user=matt device="Living room" client=0.1.60 correlation=req-4D91B7 protocol=1 method=GET path=/v1/home status=200 duration=412ms cache=miss version=0.1.0
|
||||
```
|
||||
|
||||
The timestamp is a column rather than a `time=` field, and the fields are always in the
|
||||
@@ -75,9 +75,11 @@ into the admin log when the gateway starts. Compose mounts that path from the pe
|
||||
`memby-logs` volume, so replacing the container for a new version keeps the previous
|
||||
history. The archive is compacted to the configured buffer capacity and remains bounded.
|
||||
|
||||
Every line from a request carries the viewer, the television, the app build and the
|
||||
`component` — the part of the app the call came from, derived from the route, so it is
|
||||
right even for an APK too old to report anything about itself.
|
||||
Every line from a request carries the viewer, the television, the app build, a correlation
|
||||
identifier and the `component` — the part of the app the call came from, derived from the
|
||||
route, so it is right even for an APK too old to report anything about itself. The same
|
||||
identifier is returned as `X-Memby-Correlation`; the TV's network diagnostics record it
|
||||
beside the request outcome. Playback events also carry the Emby play-session identifier.
|
||||
|
||||
Beyond the per-request line, these are logged as events in their own right: sign-in,
|
||||
sign-out and rejected sign-ins; a device removed or renamed from another TV; what
|
||||
@@ -88,8 +90,10 @@ library imports (start, progress per 500-item page, final counts and duration).
|
||||
|
||||
At the default `MEMBY_LOG_LEVEL=INFO`, successful health checks, live maintenance polls,
|
||||
artwork requests, search terms and ten-second playback progress reports are hidden;
|
||||
warnings and failures from those routes are still shown. Set `MEMBY_LOG_LEVEL=DEBUG`
|
||||
temporarily and recreate the server container when they are useful during diagnosis.
|
||||
warnings and failures from those routes are still shown. Set `MEMBY_LOG_LEVEL=DEBUG` for
|
||||
diagnostic request and playback detail, or `TRACE` for the deepest negotiation and request
|
||||
trace, then recreate the server container. URLs and sensitive structured attributes are
|
||||
redacted before they reach either the console or admin log history.
|
||||
|
||||
The build that wrote a line is `internal/buildinfo/VERSION`, embedded at compile time —
|
||||
bump it with a meaningful server change. It is also on `/healthz` and in the admin rail.
|
||||
@@ -614,7 +618,7 @@ can review and revoke signed-in TVs from the app's Settings screen.
|
||||
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
|
||||
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
|
||||
| `MEMBY_LISTEN_ADDR` | `:8080` outside Compose; `:32768` in the NAS stack | |
|
||||
| `MEMBY_LOG_LEVEL` | `INFO` | Use `DEBUG` for successful probe, status-poll and artwork requests |
|
||||
| `MEMBY_LOG_LEVEL` | `INFO` | `DEBUG` adds diagnostic request/playback detail; `TRACE` adds deep negotiation tracing |
|
||||
| `MEMBY_LOG_BUFFER_CAPACITY` | `5000` | Bounded persistent admin event history; `0` disables capture |
|
||||
| `MEMBY_LOG_HISTORY_PATH` | `/data/logs/events.jsonl` | JSONL history restored after container replacement |
|
||||
| `MEMBY_GOMEMLIMIT` | `384MiB` | Compose value passed to Go as `GOMEMLIMIT` |
|
||||
|
||||
@@ -61,6 +61,10 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||
mux.Handle("GET /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
||||
mux.Handle("POST /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
|
||||
mux.Handle("GET /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy))
|
||||
mux.Handle("POST /admin/api/radarr-request-policy", s.adminAuth(s.handleAdminRadarrRequestPolicy))
|
||||
mux.Handle("GET /admin/api/arr-integrations", s.adminAuth(s.handleAdminArrIntegrations))
|
||||
mux.Handle("POST /admin/api/arr-integrations", s.adminAuth(s.handleAdminArrIntegrations))
|
||||
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
|
||||
mux.Handle("GET /admin/api/hero/search", s.adminAuth(s.handleAdminHeroSearch))
|
||||
mux.Handle("POST /admin/api/hero-policy", s.adminAuth(s.handleAdminHeroPolicy))
|
||||
@@ -459,8 +463,10 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
valid := make(map[string]bool, len(known))
|
||||
usernames := make(map[string]string, len(known))
|
||||
for _, user := range known {
|
||||
valid[user.ID] = true
|
||||
usernames[user.ID] = user.Username
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
allowed := make([]string, 0, len(req.AllowedUserIDs))
|
||||
@@ -482,7 +488,15 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusInternalServerError, "could not save request access")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("media request access changed", "users", len(allowed))
|
||||
allowedNames := make([]string, 0, len(allowed))
|
||||
for _, id := range allowed {
|
||||
allowedNames = append(allowedNames, usernames[id])
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("Media Request access level updated",
|
||||
"gateway_version", buildinfo.Version(),
|
||||
"allowed_usernames", strings.Join(allowedNames, ", "),
|
||||
"allowed_user_ids", strings.Join(allowed, ", "),
|
||||
"allowed_users", len(allowed))
|
||||
writeJSON(w, http.StatusOK, policy)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
type arrIntegrationStatus struct {
|
||||
SonarrConfigured bool `json:"sonarrConfigured"`
|
||||
RadarrConfigured bool `json:"radarrConfigured"`
|
||||
SonarrEnabled bool `json:"sonarrEnabled"`
|
||||
RadarrEnabled bool `json:"radarrEnabled"`
|
||||
}
|
||||
|
||||
func (s *Server) arrIntegrationStatus(r *http.Request) arrIntegrationStatus {
|
||||
policy, err := s.store.ArrIntegrationPolicy(r.Context())
|
||||
if err != nil {
|
||||
policy = store.DefaultArrIntegrationPolicy()
|
||||
}
|
||||
return arrIntegrationStatus{SonarrConfigured: s.sonarr != nil, RadarrConfigured: s.radarr != nil, SonarrEnabled: s.sonarr != nil && policy.SonarrEnabled, RadarrEnabled: s.radarr != nil && policy.RadarrEnabled}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminArrIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, s.arrIntegrationStatus(r))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SonarrEnabled bool `json:"sonarrEnabled"`
|
||||
RadarrEnabled bool `json:"radarrEnabled"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetArrIntegrationPolicy(r.Context(), store.ArrIntegrationPolicy{SonarrEnabled: req.SonarrEnabled, RadarrEnabled: req.RadarrEnabled}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not save integration settings")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("arr integrations changed", "sonarr_enabled", req.SonarrEnabled, "radarr_enabled", req.RadarrEnabled)
|
||||
writeJSON(w, http.StatusOK, s.arrIntegrationStatus(r))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
type radarrProfileOption struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Recommended bool `json:"recommended"`
|
||||
}
|
||||
|
||||
type radarrRequestAdminPolicy struct {
|
||||
QualityProfileID int `json:"qualityProfileId"`
|
||||
SearchImmediately bool `json:"searchImmediately"`
|
||||
Profiles []radarrProfileOption `json:"profiles"`
|
||||
RecommendedID int `json:"recommendedId"`
|
||||
Configured bool `json:"configured"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) radarrRequestAdminPolicy(ctx context.Context) radarrRequestAdminPolicy {
|
||||
policy, err := s.store.RadarrRequestPolicy(ctx)
|
||||
if err != nil {
|
||||
return radarrRequestAdminPolicy{Profiles: []radarrProfileOption{}, Error: "Could not read the Radarr request policy."}
|
||||
}
|
||||
result := radarrRequestAdminPolicy{QualityProfileID: policy.QualityProfileID, SearchImmediately: policy.SearchImmediately, Profiles: []radarrProfileOption{}}
|
||||
if s.radarr == nil {
|
||||
result.Error = "Radarr is not configured."
|
||||
return result
|
||||
}
|
||||
profiles, err := s.radarr.QualityProfiles(ctx)
|
||||
if err != nil {
|
||||
result.Error = "Could not read Radarr quality profiles."
|
||||
return result
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
recommended := is720pProfile(profile.Name)
|
||||
if recommended && result.RecommendedID == 0 {
|
||||
result.RecommendedID = profile.ID
|
||||
}
|
||||
if profile.ID == policy.QualityProfileID {
|
||||
result.Configured = true
|
||||
}
|
||||
result.Profiles = append(result.Profiles, radarrProfileOption{ID: profile.ID, Name: profile.Name, Recommended: recommended})
|
||||
}
|
||||
if policy.QualityProfileID == 0 && result.RecommendedID != 0 {
|
||||
result.QualityProfileID, result.Configured = result.RecommendedID, true
|
||||
}
|
||||
if !result.Configured && result.Error == "" {
|
||||
result.Error = "Choose an existing Radarr quality profile before accepting movie requests."
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type radarrRequestPolicyRequest struct {
|
||||
QualityProfileID int `json:"qualityProfileId"`
|
||||
SearchImmediately bool `json:"searchImmediately"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminRadarrRequestPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
writeJSON(w, http.StatusOK, s.radarrRequestAdminPolicy(r.Context()))
|
||||
return
|
||||
}
|
||||
if s.radarr == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "Radarr is not configured")
|
||||
return
|
||||
}
|
||||
var req radarrRequestPolicyRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if req.QualityProfileID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "choose a Radarr quality profile")
|
||||
return
|
||||
}
|
||||
profiles, err := s.radarr.QualityProfiles(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "could not validate Radarr quality profiles")
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, profile := range profiles {
|
||||
if profile.ID == req.QualityProfileID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusBadRequest, "the selected Radarr quality profile no longer exists")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetRadarrRequestPolicy(r.Context(), store.RadarrRequestPolicy{QualityProfileID: req.QualityProfileID, SearchImmediately: req.SearchImmediately}); err != nil {
|
||||
s.loggerFor(r.Context()).Error("Radarr request policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save Radarr request policy")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("Radarr request policy changed", "quality_profile_id", req.QualityProfileID, "search_immediately", req.SearchImmediately)
|
||||
writeJSON(w, http.StatusOK, s.radarrRequestAdminPolicy(r.Context()))
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func liveAlerts(stored []storedAlert, now time.Time) []clientAlert {
|
||||
// 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.
|
||||
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
|
||||
if s.sonarr == nil || s.cfg.SonarrAlertWindow <= 0 {
|
||||
if !s.sonarrEnabled(ctx) || s.cfg.SonarrAlertWindow <= 0 {
|
||||
return nil
|
||||
}
|
||||
row, err := s.sonarrAiringTodayRow(ctx)
|
||||
|
||||
@@ -153,6 +153,30 @@ func (s *Server) publishAdmin(ctx context.Context, event adminevents.Event) {
|
||||
s.adminEvents.Publish(ctx, event)
|
||||
}
|
||||
|
||||
func (s *Server) sonarrEnabled(ctx context.Context) bool {
|
||||
if s.sonarr == nil || s.store == nil {
|
||||
return false
|
||||
}
|
||||
policy, err := s.store.ArrIntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err)
|
||||
return false
|
||||
}
|
||||
return policy.SonarrEnabled
|
||||
}
|
||||
|
||||
func (s *Server) radarrEnabled(ctx context.Context) bool {
|
||||
if s.radarr == nil || s.store == nil {
|
||||
return false
|
||||
}
|
||||
policy, err := s.store.ArrIntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("arr integration policy read failed", "error", err)
|
||||
return false
|
||||
}
|
||||
return policy.RadarrEnabled
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
// The client API lives on its own mux so maintenance mode can gate all of it at
|
||||
// once, without the gate ever touching health checks or the admin page.
|
||||
@@ -404,6 +428,9 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
r, identity := withRequestIdentity(r)
|
||||
w.Header().Set("X-Memby-Correlation", identity.correlation)
|
||||
s.loggerFor(r.Context()).Log(r.Context(), serverlogging.LevelTrace, "request started",
|
||||
"method", r.Method, "path", r.URL.Path, "query_keys", queryKeys(r))
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
// Polling the live-log endpoint must not create another live-log record and
|
||||
@@ -417,7 +444,7 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
//
|
||||
// Path only: query strings can carry image tokens.
|
||||
level := requestLogLevel(r.URL.Path, rec.status)
|
||||
fields := []any{"component", identity.component}
|
||||
fields := []any{"component", identity.component, "correlation", identity.correlation}
|
||||
fields = append(fields, identity.viewerAttrs()...)
|
||||
// The app build keeps its placeholder where the viewer does not, because "which
|
||||
// build made this call" always has an answer worth seeing, including "it did
|
||||
@@ -439,6 +466,17 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// queryKeys is diagnostic context without values: query values can contain title searches,
|
||||
// tokens or other private values, while their keys are enough to explain the route shape.
|
||||
func queryKeys(r *http.Request) string {
|
||||
keys := make([]string, 0, len(r.URL.Query()))
|
||||
for key := range r.URL.Query() {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return strings.Join(keys, ",")
|
||||
}
|
||||
|
||||
func clientLogValue(value string) string {
|
||||
if value == "" {
|
||||
return "unknown"
|
||||
|
||||
@@ -62,7 +62,7 @@ type calendarDay struct {
|
||||
|
||||
func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||||
ctx := r.Context()
|
||||
if s.sonarr == nil || !s.featureEnabled(ctx, featureTVCalendar) {
|
||||
if !s.sonarrEnabled(ctx) || !s.featureEnabled(ctx, featureTVCalendar) {
|
||||
writeJSON(w, http.StatusOK, emptyCalendar())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ type homeResponse struct {
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
limit := queryInt(r, "limit", 24, 100)
|
||||
sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r)
|
||||
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
|
||||
sonarrSchedule := s.sonarrEnabled(r.Context()) && supportsSonarrSchedule(r)
|
||||
radarrSchedule := s.radarrEnabled(r.Context()) && supportsRadarrSchedule(r)
|
||||
hero := supportsHomeHero(r)
|
||||
key := cache.UserKey(
|
||||
sess.EmbyUserID,
|
||||
|
||||
@@ -84,7 +84,7 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
|
||||
}
|
||||
|
||||
func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
|
||||
if s.radarr == nil {
|
||||
if !s.radarrEnabled(r.Context()) {
|
||||
writeError(w, http.StatusNotFound, "unknown image")
|
||||
return
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemI
|
||||
}
|
||||
|
||||
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(r.Context()) {
|
||||
writeError(w, http.StatusNotFound, "unknown image")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -19,11 +21,12 @@ import (
|
||||
// still read what an inner layer filled in. A request is served on one goroutine and the
|
||||
// handler has returned by the time the middleware reads this, so no lock is needed.
|
||||
type requestIdentity struct {
|
||||
component string
|
||||
user string
|
||||
device string
|
||||
client string
|
||||
protocol string
|
||||
component string
|
||||
user string
|
||||
device string
|
||||
client string
|
||||
protocol string
|
||||
correlation string
|
||||
}
|
||||
|
||||
type identityKey struct{}
|
||||
@@ -31,9 +34,10 @@ type identityKey struct{}
|
||||
// withRequestIdentity installs an empty identity for this request and returns it.
|
||||
func withRequestIdentity(r *http.Request) (*http.Request, *requestIdentity) {
|
||||
identity := &requestIdentity{
|
||||
component: componentFor(r.URL.Path),
|
||||
client: clientVersion(r),
|
||||
protocol: clientProtocol(r),
|
||||
component: componentFor(r.URL.Path),
|
||||
client: clientVersion(r),
|
||||
protocol: clientProtocol(r),
|
||||
correlation: requestCorrelation(r),
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), identityKey{}, identity)), identity
|
||||
}
|
||||
@@ -79,9 +83,35 @@ func (i *requestIdentity) attrs() []any {
|
||||
if i.client != "" {
|
||||
attrs = append(attrs, "client", i.client)
|
||||
}
|
||||
if i.correlation != "" {
|
||||
attrs = append(attrs, "correlation", i.correlation)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// requestCorrelation accepts a client-supplied safe identifier or creates one at the
|
||||
// gateway boundary. The same value is attached to every log line within the exchange and
|
||||
// returned to the client, making a playback launch traceable through gateway and Emby work.
|
||||
func requestCorrelation(r *http.Request) string {
|
||||
if value := strings.TrimSpace(r.Header.Get("X-Memby-Correlation")); len(value) >= 6 && len(value) <= 64 {
|
||||
for _, rune := range value {
|
||||
if !(rune >= 'a' && rune <= 'z' || rune >= 'A' && rune <= 'Z' || rune >= '0' && rune <= '9' || rune == '-' || rune == '_') {
|
||||
return newCorrelation()
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
return newCorrelation()
|
||||
}
|
||||
|
||||
func newCorrelation() string {
|
||||
var raw [4]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "request-unknown"
|
||||
}
|
||||
return "req-" + strings.ToUpper(hex.EncodeToString(raw[:]))
|
||||
}
|
||||
|
||||
// viewerAttrs names the person and the television, and only when they are known: an
|
||||
// unauthenticated probe has neither, and "user=unknown" on every health check is noise.
|
||||
func (i *requestIdentity) viewerAttrs() []any {
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store.
|
||||
return
|
||||
}
|
||||
sonarrSeries := []sonarr.Series{}
|
||||
if s.sonarr != nil {
|
||||
if s.sonarrEnabled(r.Context()) {
|
||||
if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil {
|
||||
sonarrSeries = value
|
||||
} else {
|
||||
@@ -186,7 +186,7 @@ func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, ses
|
||||
func (s *Server) syncReturnNotifications(
|
||||
r *http.Request, sess store.Session, prefs store.NotificationPreferences,
|
||||
) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(r.Context()) {
|
||||
return
|
||||
}
|
||||
shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID)
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -161,9 +163,11 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
subtitleIndex = &parsed
|
||||
}
|
||||
}
|
||||
negotiationStarted := time.Now()
|
||||
forceTranscode := queryBool(r, "forceTranscode")
|
||||
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
|
||||
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
|
||||
queryBool(r, "forceTranscode"), s.effectivePlaybackCapabilities(ctx, sess),
|
||||
forceTranscode, s.effectivePlaybackCapabilities(ctx, sess),
|
||||
)
|
||||
streamURL := s.emby.StreamURL(cred, target.ID)
|
||||
if negotiatedURL != "" {
|
||||
@@ -198,6 +202,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
"subtitles", len(subtitles),
|
||||
"subtitle_track", clientLogValue(selectedSubtitleID),
|
||||
"subtitle_language", clientLogValue(subtitleLanguage),
|
||||
"media_source_id", mediaSourceID,
|
||||
"play_session_id", clientLogValue(playSessionID),
|
||||
"force_transcode", forceTranscode,
|
||||
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, playbackResponse{
|
||||
@@ -392,15 +400,20 @@ func (s *Server) playbackSubtitles(
|
||||
subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,
|
||||
capabilities emby.PlaybackCapabilities,
|
||||
) ([]playableSubtitle, string, string, string, string) {
|
||||
started := time.Now()
|
||||
log := s.loggerFor(ctx).With("item", itemID, "force_transcode", forceTranscode,
|
||||
"subtitle_index", subtitleIndex != nil, "resume", millisecondDuration(startTicks/ticksPerMillisecond))
|
||||
log.Log(ctx, serverlogging.LevelTrace, "Emby playback negotiation started")
|
||||
info, err := s.emby.PlaybackInfo(
|
||||
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode,
|
||||
capabilities,
|
||||
)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
|
||||
log.Warn("Emby playback negotiation failed", "duration", time.Since(started).Round(time.Millisecond), "error", err)
|
||||
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
|
||||
}
|
||||
if len(info.MediaSources) == 0 {
|
||||
log.Warn("Emby playback negotiation returned no media sources", "duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID)
|
||||
return []playableSubtitle{}, itemID, info.PlaySessionID, "", "DirectPlay"
|
||||
}
|
||||
out := make([]playableSubtitle, 0)
|
||||
@@ -457,12 +470,37 @@ func (s *Server) playbackSubtitles(
|
||||
// download that produced it.
|
||||
out = mergeSubtitleTracks(out, s.storedSubtitlesFor(ctx, itemID))
|
||||
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
|
||||
log.Debug("Emby playback source selected",
|
||||
"duration", time.Since(started).Round(time.Millisecond), "play_session_id", info.PlaySessionID,
|
||||
"media_source_id", source.ID, "media_sources", len(info.MediaSources), "subtitles", len(out),
|
||||
"play_method", playMethod, "selection_reason", playbackSelectionReason(source, forceTranscode || subtitleIndex != nil),
|
||||
"supports_direct_play", source.SupportsDirectPlay, "supports_direct_stream", source.SupportsDirectStream,
|
||||
"supports_transcoding", source.SupportsTranscoding)
|
||||
if delivery != "" {
|
||||
delivery = s.emby.DeliveryURL(cred, delivery)
|
||||
}
|
||||
return out, source.ID, info.PlaySessionID, delivery, playMethod
|
||||
}
|
||||
|
||||
func playbackSelectionReason(source emby.MediaSourceInfo, forceTranscode bool) string {
|
||||
switch {
|
||||
case forceTranscode && source.TranscodingURL != "":
|
||||
return "forced by viewer or burned-in subtitle"
|
||||
case source.SupportsDirectPlay:
|
||||
return "source supports direct play"
|
||||
case source.SupportsDirectStream && source.DirectStreamURL != "":
|
||||
return "direct play unavailable; source supports direct stream"
|
||||
case source.SupportsTranscoding && source.TranscodingURL != "":
|
||||
return "source requires transcoding"
|
||||
case source.DirectStreamURL != "":
|
||||
return "fallback direct-stream URL available"
|
||||
case source.TranscodingURL != "":
|
||||
return "fallback transcode URL available"
|
||||
default:
|
||||
return "Emby supplied no alternate delivery URL"
|
||||
}
|
||||
}
|
||||
|
||||
func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities {
|
||||
capabilities := emby.PlaybackCapabilities{}
|
||||
for _, value := range sess.ClientCapabilities {
|
||||
@@ -729,6 +767,8 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
log := s.loggerFor(r.Context()).With(
|
||||
"title", s.playbackTitles.name(report.ItemID),
|
||||
"item", report.ItemID,
|
||||
"media_source_id", clientLogValue(report.MediaSourceID),
|
||||
"play_session_id", clientLogValue(report.PlaySessionID),
|
||||
)
|
||||
|
||||
err := s.emby.ReportPlayback(
|
||||
@@ -756,6 +796,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
log.Info("playback started",
|
||||
"position", millisecondDuration(report.PositionMs),
|
||||
"play_method", clientLogValue(report.PlayMethod),
|
||||
"event_name", clientLogValue(report.EventName),
|
||||
)
|
||||
case "stopped":
|
||||
log.Info("playback stopped",
|
||||
@@ -767,6 +808,7 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
|
||||
log.Debug("playback progress",
|
||||
"position", millisecondDuration(report.PositionMs),
|
||||
"paused", report.IsPaused,
|
||||
"event_name", clientLogValue(report.EventName),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ type radarrScheduleItem struct {
|
||||
}
|
||||
|
||||
func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) {
|
||||
if s.radarr == nil {
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
location := s.cfg.RadarrLocation
|
||||
@@ -243,7 +243,7 @@ const radarrMovieCacheKey = "radarr:movies:v1"
|
||||
// Failure degrades to asking Radarr directly — a cache that is down costs latency, never the
|
||||
// answer.
|
||||
func (s *Server) radarrMovieCatalogue(ctx context.Context) ([]radarr.Movie, error) {
|
||||
if s.radarr == nil {
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return nil, fmt.Errorf("radarr: not configured")
|
||||
}
|
||||
if movies := s.cachedRadarrMovies(ctx); movies != nil {
|
||||
|
||||
@@ -48,7 +48,7 @@ type requestLookupResponse struct {
|
||||
}
|
||||
|
||||
func (s *Server) requestAllowed(r *http.Request, sess store.Session) bool {
|
||||
if s.store == nil || (s.sonarr == nil && s.radarr == nil) {
|
||||
if s.store == nil || (!s.sonarrEnabled(r.Context()) && !s.radarrEnabled(r.Context())) {
|
||||
return false
|
||||
}
|
||||
policy, err := s.store.RequestPolicy(r.Context())
|
||||
@@ -73,7 +73,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
|
||||
var movieCandidates []requestCandidate
|
||||
var seriesCandidates []requestCandidate
|
||||
var wg sync.WaitGroup
|
||||
if s.radarr != nil {
|
||||
if s.radarrEnabled(r.Context()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -98,7 +98,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s.sonarr != nil {
|
||||
if s.sonarrEnabled(r.Context()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -235,7 +235,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
|
||||
switch req.MediaType {
|
||||
case "movie":
|
||||
if s.radarr == nil {
|
||||
if !s.radarrEnabled(r.Context()) {
|
||||
s.logMediaRequest(r.Context(), req, "failed", errors.New("movie requests are not configured"))
|
||||
writeError(w, http.StatusServiceUnavailable, "movie requests are not configured")
|
||||
return
|
||||
@@ -265,13 +265,22 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
return
|
||||
}
|
||||
req.Title = movie.Title
|
||||
added, err := s.radarr.AddRequested(r.Context(), movie)
|
||||
requestOptions, rootFolder, profileName, err := s.radarrRequestOptions(r.Context())
|
||||
if err != nil {
|
||||
s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", "", 0, "", false, err)
|
||||
s.publishRadarrRequestConfigurationProblem(r.Context(), err)
|
||||
writeError(w, http.StatusServiceUnavailable, "movie requests are unavailable: "+err.Error())
|
||||
return
|
||||
}
|
||||
added, err := s.radarr.AddRequested(r.Context(), movie, rootFolder, requestOptions)
|
||||
if err != nil {
|
||||
s.logRadarrRequest(r.Context(), sess, req, movie, 0, "failed", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, err)
|
||||
s.logMediaRequest(r.Context(), req, "failed", err)
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that movie")
|
||||
return
|
||||
}
|
||||
req.Title = added.Title
|
||||
s.logRadarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil)
|
||||
s.recordMediaRequest(r.Context(), sess, req, added.Year,
|
||||
radarrCoverURL(added.Images, "poster"))
|
||||
s.logMediaRequest(r.Context(), req, "successful", nil)
|
||||
@@ -279,7 +288,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
return
|
||||
}
|
||||
case "series":
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(r.Context()) {
|
||||
s.logMediaRequest(r.Context(), req, "failed", errors.New("series requests are not configured"))
|
||||
writeError(w, http.StatusServiceUnavailable, "series requests are not configured")
|
||||
return
|
||||
@@ -392,7 +401,7 @@ func (s *Server) writeRequestUpstreamError(
|
||||
// sonarrRequestOptions validates every component before a POST can reach Sonarr. A missing
|
||||
// configured profile is an error, not permission to fall back to Sonarr's "Any" profile.
|
||||
func (s *Server) sonarrRequestOptions(ctx context.Context) (sonarr.RequestOptions, string, string, error) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(ctx) {
|
||||
return sonarr.RequestOptions{}, "", "", errors.New("Sonarr integration is unavailable")
|
||||
}
|
||||
policy, err := s.store.SonarrRequestPolicy(ctx)
|
||||
@@ -465,3 +474,74 @@ func (s *Server) publishSonarrRequestConfigurationProblem(ctx context.Context, e
|
||||
Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) radarrRequestOptions(ctx context.Context) (radarr.RequestOptions, string, string, error) {
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("Radarr integration is unavailable")
|
||||
}
|
||||
policy, err := s.store.RadarrRequestPolicy(ctx)
|
||||
if err != nil {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("could not read the Radarr request policy")
|
||||
}
|
||||
roots, err := s.radarr.RootFolders(ctx)
|
||||
if err != nil {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr root folders")
|
||||
}
|
||||
if len(roots) == 0 || strings.TrimSpace(roots[0].Path) == "" {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("Radarr has no valid root folder")
|
||||
}
|
||||
profiles, err := s.radarr.QualityProfiles(ctx)
|
||||
if err != nil {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("could not validate Radarr quality profiles")
|
||||
}
|
||||
profileID := policy.QualityProfileID
|
||||
if profileID == 0 {
|
||||
for _, profile := range profiles {
|
||||
if is720pProfile(profile.Name) {
|
||||
profileID = profile.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if profileID == 0 {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("no request quality profile is configured and Radarr has no 720p profile")
|
||||
}
|
||||
policy.QualityProfileID = profileID
|
||||
if err := s.store.SetRadarrRequestPolicy(ctx, policy); err != nil {
|
||||
return radarr.RequestOptions{}, "", "", errors.New("could not save the default Radarr request quality profile")
|
||||
}
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if profile.ID == profileID {
|
||||
return radarr.RequestOptions{QualityProfileID: profile.ID, SearchImmediately: policy.SearchImmediately}, roots[0].Path, profile.Name, nil
|
||||
}
|
||||
}
|
||||
return radarr.RequestOptions{}, "", "", errors.New("the configured Radarr request quality profile no longer exists")
|
||||
}
|
||||
|
||||
func (s *Server) logRadarrRequest(
|
||||
ctx context.Context, sess store.Session, req requestPayload, movie radarr.Movie, movieID int,
|
||||
outcome, profileName string, profileID int, rootFolder string, searchImmediately bool, err error,
|
||||
) {
|
||||
fields := []any{
|
||||
"user", clientLogValue(sess.Username), "user_id", sess.EmbyUserID,
|
||||
"title", clientLogValue(movie.Title), "tmdb_id", movie.TMDBID,
|
||||
"radarr_movie_id", movieID, "quality_profile", profileName,
|
||||
"quality_profile_id", profileID, "monitoring_strategy", "movie",
|
||||
"root_folder", rootFolder, "search_immediately", searchImmediately, "outcome", outcome,
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, "radarr_result", err.Error())
|
||||
s.loggerFor(ctx).Warn("Radarr movie request", fields...)
|
||||
return
|
||||
}
|
||||
fields = append(fields, "radarr_result", "created")
|
||||
s.loggerFor(ctx).Info("Radarr movie request", fields...)
|
||||
}
|
||||
|
||||
func (s *Server) publishRadarrRequestConfigurationProblem(ctx context.Context, err error) {
|
||||
s.publishAdmin(ctx, adminevents.Event{
|
||||
Type: "radarr.request_configuration", Severity: adminevents.SeverityError,
|
||||
Title: "Radarr movie requests need attention", Summary: err.Error(), Actor: "memby-server",
|
||||
Link: "/admin/integrations", Metadata: adminevents.Meta(map[string]any{"error": err.Error()}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func (s *Server) decorateRequests(
|
||||
)
|
||||
now := time.Now()
|
||||
|
||||
if len(movieIDs) > 0 && s.radarr != nil {
|
||||
if len(movieIDs) > 0 && s.radarrEnabled(ctx) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -111,7 +111,7 @@ func (s *Server) decorateRequests(
|
||||
}
|
||||
}()
|
||||
}
|
||||
if len(seriesIDs) > 0 && s.sonarr != nil {
|
||||
if len(seriesIDs) > 0 && s.sonarrEnabled(ctx) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
@@ -38,7 +38,7 @@ const sonarrScheduleDays = 5
|
||||
// Every failure degrades to asking Sonarr directly: a cache that is down must cost latency,
|
||||
// never the answer.
|
||||
func (s *Server) sonarrSeriesCatalogue(ctx context.Context) ([]sonarr.Series, error) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(ctx) {
|
||||
return nil, fmt.Errorf("sonarr: not configured")
|
||||
}
|
||||
if series := s.cachedSonarrSeries(ctx); series != nil {
|
||||
@@ -95,7 +95,7 @@ type prerollScheduleEntry struct {
|
||||
}
|
||||
|
||||
func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(r.Context()) {
|
||||
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
|
||||
return
|
||||
}
|
||||
@@ -340,7 +340,7 @@ func (index seriesIndex) logo(title string, year int) (string, string) {
|
||||
}
|
||||
|
||||
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
|
||||
if s.sonarr == nil {
|
||||
if !s.sonarrEnabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
location := s.cfg.SonarrLocation
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// daily. History stores changes rather than identical daily snapshots: it still records
|
||||
// the complete lifecycle while making an active-to-cancelled transition unambiguous.
|
||||
func (s *Server) WatchSonarrLifecycle(ctx context.Context, interval time.Duration) {
|
||||
if s.sonarr == nil || interval <= 0 {
|
||||
if !s.sonarrEnabled(ctx) || interval <= 0 {
|
||||
return
|
||||
}
|
||||
scan := func() {
|
||||
|
||||
@@ -59,7 +59,7 @@ func (h *consoleHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
var line strings.Builder
|
||||
line.WriteString(record.Time.UTC().Format(time.DateTime))
|
||||
line.WriteByte(' ')
|
||||
line.WriteString(pad(record.Level.String(), levelWidth))
|
||||
line.WriteString(pad(LevelName(record.Level), levelWidth))
|
||||
line.WriteByte(' ')
|
||||
line.WriteString(pad(record.Message, messageWidth))
|
||||
for _, f := range fields {
|
||||
@@ -103,7 +103,7 @@ func appendField(target []field, groups []string, attr slog.Attr) []field {
|
||||
return target
|
||||
}
|
||||
key := strings.Join(append(append([]string{}, groups...), attr.Key), ".")
|
||||
return append(target, field{key: key, value: attributeValue(attr.Value)})
|
||||
return append(target, field{key: key, value: safeAttribute(key, attributeValue(attr.Value))})
|
||||
}
|
||||
|
||||
// fieldRank puts the fields that identify *who and where* first, in the same order on
|
||||
@@ -111,15 +111,17 @@ func appendField(target []field, groups []string, attr slog.Attr) []field {
|
||||
// Anything unranked keeps the order the caller wrote it in, which is usually the order
|
||||
// that reads best for that particular event.
|
||||
var fieldRank = map[string]int{
|
||||
"component": 1,
|
||||
"user": 2,
|
||||
"device": 3,
|
||||
"client": 4,
|
||||
"protocol": 5,
|
||||
"method": 6,
|
||||
"path": 7,
|
||||
"status": 8,
|
||||
"duration": 9,
|
||||
"component": 1,
|
||||
"user": 2,
|
||||
"device": 3,
|
||||
"client": 4,
|
||||
"correlation": 5,
|
||||
"play_session_id": 6,
|
||||
"protocol": 7,
|
||||
"method": 8,
|
||||
"path": 9,
|
||||
"status": 10,
|
||||
"duration": 11,
|
||||
// Constant per process, so it belongs at the end of the line rather than in front
|
||||
// of the fields that differ between events.
|
||||
"version": 900,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -16,11 +17,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// LevelTrace is deliberately below DEBUG. It is disabled unless MEMBY_LOG_LEVEL=TRACE,
|
||||
// so tight diagnostic loops can be instrumented without making ordinary DEBUG unusable.
|
||||
const LevelTrace slog.Level = slog.LevelDebug - 4
|
||||
|
||||
// LevelName presents custom levels consistently in every configured output format.
|
||||
func LevelName(level slog.Level) string {
|
||||
if level == LevelTrace {
|
||||
return "TRACE"
|
||||
}
|
||||
return level.String()
|
||||
}
|
||||
|
||||
// ParseLevel returns a supported slog level, defaulting to INFO for empty or invalid
|
||||
// values. Keeping this forgiving prevents a typo in Docker configuration from stopping
|
||||
// the gateway.
|
||||
func ParseLevel(value string) slog.Level {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "TRACE":
|
||||
return LevelTrace
|
||||
case "DEBUG":
|
||||
return slog.LevelDebug
|
||||
case "WARN", "WARNING":
|
||||
@@ -128,6 +143,11 @@ func NewBuffered(
|
||||
if attr.Key == slog.TimeKey {
|
||||
return slog.String(slog.TimeKey, attr.Value.Time().UTC().Format(time.RFC3339))
|
||||
}
|
||||
if attr.Key == slog.LevelKey {
|
||||
if level, ok := attr.Value.Any().(slog.Level); ok {
|
||||
return slog.String(slog.LevelKey, LevelName(level))
|
||||
}
|
||||
}
|
||||
return attr
|
||||
},
|
||||
}
|
||||
@@ -233,7 +253,7 @@ func (h *captureHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
})
|
||||
h.buffer.append(Event{
|
||||
OccurredAt: record.Time.UTC(),
|
||||
Level: record.Level.String(),
|
||||
Level: LevelName(record.Level),
|
||||
Message: record.Message,
|
||||
Attributes: attributes,
|
||||
})
|
||||
@@ -266,7 +286,35 @@ func addAttribute(target map[string]string, groups []string, attr slog.Attr) {
|
||||
}
|
||||
return
|
||||
}
|
||||
target[key] = attributeValue(attr.Value)
|
||||
target[key] = safeAttribute(key, attributeValue(attr.Value))
|
||||
}
|
||||
|
||||
// safeAttribute is the final guard before a structured record reaches the browser-visible
|
||||
// buffer and optional on-disk history. Call sites should never log credentials, but this
|
||||
// makes one accidental token-bearing URL or header non-disclosive by construction.
|
||||
func safeAttribute(key, value string) string {
|
||||
lower := strings.ToLower(key)
|
||||
if strings.Contains(lower, "password") || strings.Contains(lower, "token") ||
|
||||
strings.Contains(lower, "secret") || strings.Contains(lower, "cookie") ||
|
||||
strings.Contains(lower, "authorization") || strings.Contains(lower, "api_key") || strings.Contains(lower, "apikey") {
|
||||
return "[redacted]"
|
||||
}
|
||||
if parsed, err := url.Parse(value); err == nil && parsed.RawQuery != "" {
|
||||
query := parsed.Query()
|
||||
changed := false
|
||||
for key := range query {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if strings.Contains(lowerKey, "token") || strings.Contains(lowerKey, "key") || strings.Contains(lowerKey, "auth") || lowerKey == "t" {
|
||||
query.Set(key, "[redacted]")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (b *Buffer) append(event Event) {
|
||||
|
||||
@@ -63,6 +63,29 @@ func TestConsoleQuotesOnlyAmbiguousValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggingRedactsSensitiveAttributesAndURLs(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger, buffer := NewBuffered(&output, LevelTrace, 10, FormatConsole)
|
||||
logger.Log(nil, LevelTrace, "diagnostic request",
|
||||
"authorization", "Bearer private-token",
|
||||
"url", "https://emby.example/stream?api_key=private-key&quality=720p",
|
||||
)
|
||||
|
||||
line := output.String()
|
||||
if strings.Contains(line, "private-token") || strings.Contains(line, "private-key") {
|
||||
t.Fatalf("console leaked a secret: %s", line)
|
||||
}
|
||||
if !strings.Contains(line, "TRACE") {
|
||||
t.Fatalf("console did not render TRACE: %s", line)
|
||||
}
|
||||
page := buffer.Events(0, 1)
|
||||
if len(page.Events) != 1 || page.Events[0].Level != "TRACE" ||
|
||||
page.Events[0].Attributes["authorization"] != "[redacted]" ||
|
||||
strings.Contains(page.Events[0].Attributes["url"], "private-key") {
|
||||
t.Fatalf("buffer leaked or mislabelled diagnostic event: %+v", page.Events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFormat(t *testing.T) {
|
||||
tests := map[string]Format{
|
||||
"": FormatConsole,
|
||||
@@ -144,6 +167,7 @@ func TestPersistentBufferRestoresTheRetainedTail(t *testing.T) {
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := map[string]slog.Level{
|
||||
"": slog.LevelInfo,
|
||||
"trace": LevelTrace,
|
||||
"debug": slog.LevelDebug,
|
||||
"WARNING": slog.LevelWarn,
|
||||
"error": slog.LevelError,
|
||||
|
||||
@@ -58,7 +58,31 @@ type RootFolder struct {
|
||||
}
|
||||
|
||||
type QualityProfile struct {
|
||||
ID int `json:"id"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// RequestOptions are Memby's deliberate movie-request policy. Radarr defaults are never
|
||||
// allowed to choose a profile or initiate a search on Memby's behalf.
|
||||
type RequestOptions struct {
|
||||
QualityProfileID int
|
||||
SearchImmediately bool
|
||||
}
|
||||
|
||||
func (c *Client) RootFolders(ctx context.Context) ([]RootFolder, error) {
|
||||
var roots []RootFolder
|
||||
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
func (c *Client) QualityProfiles(ctx context.Context) ([]QualityProfile, error) {
|
||||
var profiles []QualityProfile
|
||||
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
@@ -116,29 +140,22 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
// AddRequested adds a title, monitors it and asks Radarr to search for it immediately.
|
||||
// A request that merely creates an unmonitored catalogue row never reaches a downloader,
|
||||
// which is indistinguishable from a broken button to the viewer who made it.
|
||||
func (c *Client) AddRequested(ctx context.Context, movie Movie) (Movie, error) {
|
||||
var roots []RootFolder
|
||||
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
|
||||
return Movie{}, err
|
||||
// AddRequested adds a monitored movie using the supplied request policy.
|
||||
func (c *Client) AddRequested(ctx context.Context, movie Movie, rootFolder string, options RequestOptions) (Movie, error) {
|
||||
if strings.TrimSpace(rootFolder) == "" {
|
||||
return Movie{}, fmt.Errorf("radarr: request root folder is required")
|
||||
}
|
||||
var profiles []QualityProfile
|
||||
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
|
||||
return Movie{}, err
|
||||
}
|
||||
if len(roots) == 0 || len(profiles) == 0 {
|
||||
return Movie{}, fmt.Errorf("radarr: no root folder or quality profile configured")
|
||||
if options.QualityProfileID <= 0 {
|
||||
return Movie{}, fmt.Errorf("radarr: request quality profile is required")
|
||||
}
|
||||
movie.ID = 0
|
||||
movie.RootFolderPath = roots[0].Path
|
||||
movie.QualityProfileID = profiles[0].ID
|
||||
movie.RootFolderPath = rootFolder
|
||||
movie.QualityProfileID = options.QualityProfileID
|
||||
movie.Monitored = true
|
||||
body := struct {
|
||||
Movie
|
||||
AddOptions map[string]bool `json:"addOptions"`
|
||||
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": true}}
|
||||
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": options.SearchImmediately}}
|
||||
var added Movie
|
||||
if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil {
|
||||
return Movie{}, err
|
||||
|
||||
@@ -48,13 +48,9 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
|
||||
func TestAddRequestedUsesExplicitPolicyWithoutSearching(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/rootfolder":
|
||||
_, _ = w.Write([]byte(`[{"path":"/movies"}]`))
|
||||
case "/api/v3/qualityprofile":
|
||||
_, _ = w.Write([]byte(`[{"id":4}]`))
|
||||
case "/api/v3/movie":
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
@@ -65,8 +61,8 @@ func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
|
||||
t.Errorf("unexpected add body: %#v", body)
|
||||
}
|
||||
options := body["addOptions"].(map[string]any)
|
||||
if options["searchForMovie"] != true {
|
||||
t.Errorf("movie search was not enabled: %#v", body)
|
||||
if options["searchForMovie"] != false {
|
||||
t.Errorf("movie search was unexpectedly enabled: %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":9,"tmdbId":22,"title":"Arrival"}`))
|
||||
default:
|
||||
@@ -76,7 +72,7 @@ func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
|
||||
defer upstream.Close()
|
||||
|
||||
added, err := New(upstream.URL, "secret", time.Second).AddRequested(
|
||||
context.Background(), Movie{TMDBID: 22, Title: "Arrival"},
|
||||
context.Background(), Movie{TMDBID: 22, Title: "Arrival"}, "/movies", RequestOptions{QualityProfileID: 4},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -22,6 +22,55 @@ const RequestPolicyKey = "request_policy"
|
||||
// may ask; this policy answers the safe, household-wide way a TV request is created.
|
||||
const SonarrRequestPolicyKey = "sonarr_request_policy"
|
||||
|
||||
const RadarrRequestPolicyKey = "radarr_request_policy"
|
||||
|
||||
const ArrIntegrationPolicyKey = "arr_integration_policy"
|
||||
|
||||
// ArrIntegrationPolicy lets the operator stop either *arr integration without removing
|
||||
// credentials or request policy. Both start enabled for existing households.
|
||||
type ArrIntegrationPolicy struct {
|
||||
SonarrEnabled bool `json:"sonarrEnabled"`
|
||||
RadarrEnabled bool `json:"radarrEnabled"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func DefaultArrIntegrationPolicy() ArrIntegrationPolicy {
|
||||
return ArrIntegrationPolicy{SonarrEnabled: true, RadarrEnabled: true}
|
||||
}
|
||||
|
||||
func (s *Store) ArrIntegrationPolicy(ctx context.Context) (ArrIntegrationPolicy, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, ArrIntegrationPolicyKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultArrIntegrationPolicy(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultArrIntegrationPolicy(), fmt.Errorf("store: read arr integration policy: %w", err)
|
||||
}
|
||||
var policy ArrIntegrationPolicy
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return DefaultArrIntegrationPolicy(), fmt.Errorf("store: decode arr integration policy: %w", err)
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetArrIntegrationPolicy(ctx context.Context, policy ArrIntegrationPolicy) error {
|
||||
policy.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return 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()`,
|
||||
ArrIntegrationPolicyKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write arr integration policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlaybackPolicyKey controls presentation behaviour that should be adjustable without
|
||||
// shipping a new TV build.
|
||||
const PlaybackPolicyKey = "playback_policy"
|
||||
@@ -433,6 +482,55 @@ func (s *Store) SetSonarrRequestPolicy(ctx context.Context, policy SonarrRequest
|
||||
return nil
|
||||
}
|
||||
|
||||
// RadarrRequestPolicy is the movie equivalent of SonarrRequestPolicy. It persists the
|
||||
// stable profile id so a renamed or removed profile becomes a safe configuration error.
|
||||
type RadarrRequestPolicy struct {
|
||||
QualityProfileID int `json:"qualityProfileId"`
|
||||
SearchImmediately bool `json:"searchImmediately"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func DefaultRadarrRequestPolicy() RadarrRequestPolicy { return RadarrRequestPolicy{} }
|
||||
|
||||
func (s *Store) RadarrRequestPolicy(ctx context.Context) (RadarrRequestPolicy, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, RadarrRequestPolicyKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultRadarrRequestPolicy(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultRadarrRequestPolicy(), fmt.Errorf("store: read Radarr request policy: %w", err)
|
||||
}
|
||||
var policy RadarrRequestPolicy
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return DefaultRadarrRequestPolicy(), fmt.Errorf("store: decode Radarr request policy: %w", err)
|
||||
}
|
||||
if policy.QualityProfileID < 0 {
|
||||
policy.QualityProfileID = 0
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetRadarrRequestPolicy(ctx context.Context, policy RadarrRequestPolicy) error {
|
||||
if policy.QualityProfileID <= 0 {
|
||||
return fmt.Errorf("store: Radarr request quality profile is required")
|
||||
}
|
||||
policy.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return 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()`,
|
||||
RadarrRequestPolicyKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write Radarr request policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p RequestPolicy) Allows(userID string) bool {
|
||||
for _, allowed := range p.AllowedUserIDs {
|
||||
if allowed == userID {
|
||||
|
||||
Reference in New Issue
Block a user