0.2.78
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
## 0.2.78 - 2026-08-19
|
||||
- Removed: Genres row from the TV Series and Movies pages. Replaced by Genres page.
|
||||
- Improved: Added Mark as Watched button to Series detail pages.
|
||||
- Improved: In the backend, Intergrations has it's own tab.
|
||||
|
||||
## 0.2.77 - 2026-08-19
|
||||
- New: Films on the "Upcoming Movie releases" shelf now open a page of their own.
|
||||
- New: A trailer can be played from an upcoming film's page.
|
||||
|
||||
@@ -962,6 +962,47 @@ 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
|
||||
is where an accidental click lives.
|
||||
|
||||
**"25 goroutines" was the one figure on the console nobody could act on**, and
|
||||
`server/internal/runtimestats` is what replaced it. A count says nothing about what those
|
||||
goroutines are doing, which part of Memby they belong to, whether it is normal, or whether
|
||||
it has been climbing all week — so the overview's Process card now prints a *verdict* and
|
||||
signposts `/admin/runtime`, where the detail lives. The package is organised entirely by
|
||||
what a thing costs, and that is the design:
|
||||
|
||||
- **The registry is free**, so it runs always. A long-running worker is started through
|
||||
`runtimestats.Go(name, component, fn)` rather than a bare `go`, at every launch site in
|
||||
`main.go` plus the scheduler's loop and the integrations dispatcher, and the console can
|
||||
therefore *name* "Library ingest" instead of inferring it. `Starts` is on the record
|
||||
because a worker being restarted in a loop reads exactly like a healthy one from a single
|
||||
snapshot. Nothing in the package recovers a panic — reporting must never change what it
|
||||
is reporting on.
|
||||
- **The sampler is cheap**, so it runs on a minute's tick: a ring of `Sample`s four hours
|
||||
deep. A single instantaneous number cannot show a leak and this can. `trendOf` is pure and
|
||||
compares the median of the window's oldest quarter with its newest rather than fitting a
|
||||
line, because a burst — an import, a household arriving home — is exactly the shape
|
||||
least-squares reports as a trend; the threshold is proportional *with an absolute floor*,
|
||||
or a busy gateway is permanently "rising" and a quiet one calls one extra goroutine a leak.
|
||||
`DirectionUnknown` is a real answer and the honest one for the first quarter of an hour.
|
||||
- **The breakdown is expensive**, so it is a button. `CollectGoroutines` walks every stack,
|
||||
which stops the world, and the answer says when it was taken and what it cost.
|
||||
`parseGoroutines` is pure and pinned by a fixture carrying a real dump, and the categories
|
||||
must always **partition the total** — a breakdown whose parts do not add up is worse than
|
||||
none, which is what the unrecognised-state bucket and both admin tests exist for. The
|
||||
`componentRules` order is load-bearing: Memby's own packages are tested before the
|
||||
libraries they call, or every query in the gateway files under "Database pool".
|
||||
`?format=text` hands over the raw dump, which is the alternative to leaving a profiler
|
||||
endpoint permanently mounted.
|
||||
- **The verdict is the gateway's wording, not the console's** (`assess`, pure and tested),
|
||||
the stance every label the gateway prints takes: the threshold and the sentence explaining
|
||||
it belong together, and an older console must not be the thing deciding what "watch"
|
||||
means. Every note carries an **area** — a number that has moved is only actionable once it
|
||||
points somewhere — and a rise slower than `goroutineLeakPerHour` is not a note at all, or
|
||||
the card cries wolf on every busy evening.
|
||||
- **`Process` is what /proc knows and the runtime does not** — processor time, open sockets,
|
||||
the descriptor limit. `CPUKnown`/`FilesKnown` are separate from the figures because "no
|
||||
open sockets" and "could not look" are different answers, and a developer machine is the
|
||||
second.
|
||||
|
||||
**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)
|
||||
@@ -990,6 +1031,82 @@ the idle sign-out, the two alert windows and the Emby health probe. Things to pr
|
||||
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.
|
||||
|
||||
**Integrations are their own area, and they are one axis over the scheduler rather than a
|
||||
second system.** `/admin/integrations` is the overview, `/admin/integrations/{id}` is one
|
||||
service, and `internal/api/integrations_catalogue.go` is the only place a service is
|
||||
declared — the `featureCatalogue` shape. Before it, MDBList's settings were on a Movie
|
||||
ratings page, the Sonarr and Radarr switches were on a page about Discord webhooks, their
|
||||
request policies were beside those, and Tracearr could not be reached from the console at
|
||||
all: "is everything Memby depends on working" was four pages and one impossible question.
|
||||
Things to preserve:
|
||||
|
||||
- **A run history is not a second store.** An integration run *is* a
|
||||
`scheduled_task_runs` row: `scheduler.Task` carries an `Integration` id, the row records
|
||||
it, and `store.IntegrationRuns` reads the same table along the other axis. Giving the
|
||||
area a table of its own would mean two schedulers, two retention jobs and two places one
|
||||
piece of work could be recorded as having failed. What was added is four counters
|
||||
(`store.RunCounts` — processed, changed, skipped, failed) and `scheduler.Work`, the
|
||||
counting form of `Run`; a task declares one or the other and `Register` panics on both.
|
||||
The counters exist because "412 films checked, 7 updated" is the question an operator
|
||||
has and a sentence is a poor place to keep numbers — they cannot be compared between
|
||||
runs or sorted.
|
||||
- **Runs and Logs answer different questions and say so.** Logs are the technical events
|
||||
behind a failure; this is the operational record of what Memby attempted and what came of
|
||||
it. A failing service's page links to `/admin/logs?q=<id>` — which is why the log viewer
|
||||
seeds its text filter from `?q=` — so the two are joined rather than merged.
|
||||
- **The work is scheduler tasks now, and that is the half that changed behaviour.** The
|
||||
Sonarr lifecycle scan, the Radarr catalogue refresh, the Tracearr import and the daily
|
||||
For You rebuild were four goroutines with tickers of their own in `main`. A ticker could
|
||||
report nothing to an operator, could not be started by hand, and could not be counted
|
||||
against a service. `foryou.Service.Schedule` and `nextDailyRebuild` are gone with them;
|
||||
the "is the rebuild due" rule moved to `api.forYouRebuildDue`, which is pure and tested
|
||||
and compares **local calendar days** rather than subtracting 24 hours.
|
||||
- **A switched-off integration reports a *skipped* run, never silence and never an error.**
|
||||
Silence is indistinguishable from a scheduler that has stopped, and an error would put a
|
||||
red row in the console for a state the operator chose.
|
||||
- **One stored truth per integration, and one reader.** The switch lives where that
|
||||
service's configuration already lives — Sonarr and Radarr in `ArrIntegrationPolicy`,
|
||||
MDBList in `MDBListSettings.Enabled`, Tracearr in the new `integration_policy` document
|
||||
— and `integrationEnabled`/`SetEnabled` are the only ways in. Consolidating them would
|
||||
be a migration with a window in which two documents disagreed about whether a service
|
||||
was on. Absence means **on**, or the day this shipped would have switched every
|
||||
household's imports off.
|
||||
- **`integrationSuppressed` is not the negation of `integrationEnabled`.** The latter is
|
||||
also false for a service this deployment never configured, and a household can perfectly
|
||||
well point Sonarr's *webhook* at Memby without giving Memby Sonarr's API key. The import
|
||||
hooks use the former, and answer 200 while ignoring the payload — neither *arr
|
||||
re-delivers a rejection, so refusing would read as Memby losing imports rather than as
|
||||
the switch the operator set.
|
||||
- **A switch consulted once at start-up is not a switch.** `recommend.Engine.TracearrAllowed`
|
||||
is a function read at call time, wired in `main` after the server exists, and nil means
|
||||
allowed so every test behaves as it did. Switching Tracearr off costs the extra signal
|
||||
and never the row — the same degradation an unreachable Tracearr already produced.
|
||||
- **Health is probed on a schedule, not on page load.** The console polls; probing per
|
||||
request would make every open tab its own request against somebody's Sonarr. One
|
||||
`integration-health` task every five minutes fills `integrationHealthCache`, and the
|
||||
console's Test button calls the *same* `probeIntegration` — a test taking a different
|
||||
path could report a service as working while the row beside it stayed red. A switched-off
|
||||
service is not probed at all, which is what the switch promises.
|
||||
- **MDBList has no probe, and the page says so rather than drawing "never checked".**
|
||||
Its allowance is bought by the day, and spending a request of it to colour a status dot
|
||||
would have the console competing with the televisions for the thing it is reporting on.
|
||||
Its health comes from its own run history.
|
||||
- **The status word is the gateway's** (`integrationStatus`, pure and tested), the stance
|
||||
every label Memby prints takes. It resolves in priority order — unconfigured, disabled,
|
||||
running, then the evidence — and a failure followed by a success is *history rather than
|
||||
a verdict*, or one bad night would leave a service red until the retention job removed
|
||||
the row. `idle` is a real answer for a service that has not done anything yet; claiming
|
||||
either verdict would be a guess.
|
||||
- **Switching one off states what goes with it.** `Powers` in the catalogue is rendered on
|
||||
the service's page and repeated in the activity event, because "Sonarr disabled" tells an
|
||||
operator nothing they did not just do — what they may not have thought about is the
|
||||
television calendar going with it.
|
||||
- **`/admin/ratings` redirects** to the MDBList page rather than 404ing: it was
|
||||
bookmarkable and appears in older activity links. The old Integrations page survives as
|
||||
`/admin/integrations/webhooks`, which answers the opposite question from the rest of the
|
||||
area — those are services Memby *depends on*, that is a place Memby *posts to*, and
|
||||
removing every webhook leaves the gateway unchanged.
|
||||
|
||||
**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:
|
||||
|
||||
+1
-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
Vendored
+2
-2
@@ -13,9 +13,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-KrrVPZvy.js"></script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-CJIn-EsM.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-C5rUVO6U.css">
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-Bx4XLLdG.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+17
-2
@@ -14,7 +14,6 @@ import { ClientsPage } from './pages/Clients';
|
||||
import { LoginsPage } from './pages/Logins';
|
||||
import { DevicePage } from './pages/Device';
|
||||
import { LibraryPage } from './pages/Library';
|
||||
import { RatingsPage } from './pages/Ratings';
|
||||
import { RequestsPage } from './pages/Requests';
|
||||
import { RecommendationsPage } from './pages/Recommendations';
|
||||
import { InspectorPage } from './pages/Inspector';
|
||||
@@ -24,8 +23,12 @@ import { PlaybackPage } from './pages/Playback';
|
||||
import { SubtitlesPage } from './pages/Subtitles';
|
||||
import { UpdatesPage } from './pages/Updates';
|
||||
import { TasksPage } from './pages/Tasks';
|
||||
import { TaskPage } from './pages/Task';
|
||||
import { IntegrationsPage } from './pages/Integrations';
|
||||
import { IntegrationPage } from './pages/Integration';
|
||||
import { WebhooksPage } from './pages/Webhooks';
|
||||
import { MaintenancePage } from './pages/Maintenance';
|
||||
import { RuntimePage } from './pages/Runtime';
|
||||
import { SettingsPage } from './pages/Settings';
|
||||
import { ImportsPage } from './pages/Imports';
|
||||
import { LogsPage } from './pages/Logs';
|
||||
@@ -72,7 +75,6 @@ export function App() {
|
||||
<Route path="devices/:deviceId" element={<DevicePage />} />
|
||||
|
||||
<Route path="library" element={<LibraryPage />} />
|
||||
<Route path="ratings" element={<RatingsPage />} />
|
||||
<Route path="requests" element={<RequestsPage />} />
|
||||
|
||||
<Route path="recommendations" element={<RecommendationsPage />} />
|
||||
@@ -86,8 +88,14 @@ export function App() {
|
||||
<Route path="updates" element={<UpdatesPage />} />
|
||||
|
||||
<Route path="tasks" element={<TasksPage />} />
|
||||
<Route path="tasks/:taskId" element={<TaskPage />} />
|
||||
<Route path="integrations" element={<IntegrationsPage />} />
|
||||
{/* Ahead of the id route, or "webhooks" would be read as an integration
|
||||
id and open a page about a service that does not exist. */}
|
||||
<Route path="integrations/webhooks" element={<WebhooksPage />} />
|
||||
<Route path="integrations/:integrationId" element={<IntegrationPage />} />
|
||||
<Route path="maintenance" element={<MaintenancePage />} />
|
||||
<Route path="runtime" element={<RuntimePage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="imports" element={<ImportsPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
@@ -103,6 +111,13 @@ export function App() {
|
||||
{/* The old console redirected /admin/ to /admin/overview. Anything that
|
||||
still links there lands on the overview rather than on a 404. */}
|
||||
<Route path="overview" element={<Navigate to="/admin" replace />} />
|
||||
{/* MDBList's settings moved into the Integrations area. The address was
|
||||
bookmarkable and is in older activity-feed links, so it redirects
|
||||
rather than 404s. */}
|
||||
<Route
|
||||
path="ratings"
|
||||
element={<Navigate to="/admin/integrations/mdblist" replace />}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
+213
-2
@@ -385,9 +385,21 @@ export interface DeviceDetailResponse {
|
||||
|
||||
/* ---------- scheduled tasks ---------- */
|
||||
|
||||
/** What a run counted. Every task carries the shape; most count nothing, and a run whose
|
||||
* `processed` is zero counted nothing at all — the console prints no figures rather than
|
||||
* four zeroes. */
|
||||
export interface RunCounts {
|
||||
processed: number;
|
||||
changed: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface TaskRun {
|
||||
id: number;
|
||||
taskId: string;
|
||||
/** The external service this run belongs to, absent for the gateway's own work. */
|
||||
integrationId?: string;
|
||||
trigger: string;
|
||||
status: 'running' | 'success' | 'failed' | 'skipped';
|
||||
startedAt: string;
|
||||
@@ -395,6 +407,7 @@ export interface TaskRun {
|
||||
durationMs: number;
|
||||
detail?: string;
|
||||
error?: string;
|
||||
counts: RunCounts;
|
||||
}
|
||||
|
||||
export interface ScheduledTask {
|
||||
@@ -402,6 +415,8 @@ export interface ScheduledTask {
|
||||
name: string;
|
||||
description: string;
|
||||
group: string;
|
||||
/** The external service this job belongs to, absent for the gateway's own work. */
|
||||
integration?: string;
|
||||
intervalSeconds: number;
|
||||
/** The cadence declared in code. Differs from intervalSeconds only when overridden. */
|
||||
defaultIntervalSeconds: number;
|
||||
@@ -419,6 +434,68 @@ export interface TasksResponse {
|
||||
|
||||
/* ---------- integrations ---------- */
|
||||
|
||||
/* The external services Memby depends on, which is a different question from the webhook
|
||||
destinations below: those are places administrative events are posted to, these are
|
||||
things without which parts of Memby stop working. */
|
||||
|
||||
export type IntegrationStatus =
|
||||
| 'healthy'
|
||||
| 'error'
|
||||
| 'running'
|
||||
| 'disabled'
|
||||
| 'unconfigured'
|
||||
| 'idle';
|
||||
|
||||
export interface IntegrationProbe {
|
||||
reachable: boolean;
|
||||
checkedAt: string;
|
||||
error?: string;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface IntegrationFact {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: string;
|
||||
}
|
||||
|
||||
export interface IntegrationService {
|
||||
id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
address?: string;
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
status: IntegrationStatus;
|
||||
statusLabel: string;
|
||||
detail?: string;
|
||||
running: boolean;
|
||||
/** What stops working when this service is switched off. */
|
||||
powers: string[];
|
||||
lastRun?: TaskRun;
|
||||
lastSuccessAt?: string;
|
||||
lastFailureAt?: string;
|
||||
lastError?: string;
|
||||
nextRun?: string;
|
||||
runs: number;
|
||||
failures: number;
|
||||
health?: IntegrationProbe;
|
||||
/** Whether this service can be probed at all — see the MDBList note on the server. */
|
||||
probed: boolean;
|
||||
tasks: ScheduledTask[];
|
||||
facts?: IntegrationFact[];
|
||||
}
|
||||
|
||||
export interface IntegrationServicesResponse {
|
||||
services: IntegrationService[];
|
||||
runs: TaskRun[];
|
||||
}
|
||||
|
||||
export interface IntegrationServiceResponse {
|
||||
service: IntegrationService;
|
||||
runs: TaskRun[];
|
||||
}
|
||||
|
||||
export interface IntegrationHealth {
|
||||
integrationId: string;
|
||||
lastSuccess?: string;
|
||||
@@ -514,9 +591,53 @@ export interface LogResponse {
|
||||
|
||||
/* ---------- runtime ---------- */
|
||||
|
||||
export interface RuntimeStatus {
|
||||
/* The process figures, and the interpretation of them.
|
||||
*
|
||||
* The gateway sends the verdict rather than the console deriving one, for the reason every
|
||||
* label the gateway prints is its own: the threshold and the sentence explaining it belong
|
||||
* together, and a console built before a threshold existed must not be the thing deciding
|
||||
* what "watch" means. See server/internal/runtimestats. */
|
||||
|
||||
export type RuntimeLevel = 'ok' | 'watch' | 'bad';
|
||||
export type TrendDirection = 'unknown' | 'steady' | 'rising' | 'falling';
|
||||
|
||||
export interface RuntimeTrend {
|
||||
direction: TrendDirection;
|
||||
/** perHour is the observed rate of change in the value's own units — goroutines an hour,
|
||||
* bytes an hour — which is what makes "rising" something to act on rather than notice. */
|
||||
perHour: number;
|
||||
first: number;
|
||||
latest: number;
|
||||
min: number;
|
||||
max: number;
|
||||
spanSeconds: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface RuntimeSample {
|
||||
at: string;
|
||||
goroutines: number;
|
||||
gomaxprocs: number;
|
||||
heapInuse: number;
|
||||
sys: number;
|
||||
numGc: number;
|
||||
}
|
||||
|
||||
export interface RuntimeNote {
|
||||
level: RuntimeLevel;
|
||||
/** area names the part of Memby to look at. It is the note's most useful field: a number
|
||||
* that has moved is only actionable once it points somewhere. */
|
||||
area: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RuntimeHealth {
|
||||
level: RuntimeLevel;
|
||||
summary: string;
|
||||
notes?: RuntimeNote[];
|
||||
areas?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeMemory {
|
||||
heapAlloc: number;
|
||||
heapInuse: number;
|
||||
heapIdle: number;
|
||||
@@ -525,8 +646,98 @@ export interface RuntimeStatus {
|
||||
sys: number;
|
||||
nextGc: number;
|
||||
numGc: number;
|
||||
pauseTotalMs: number;
|
||||
pauseRecentMs: number;
|
||||
memoryLimit: number;
|
||||
configuredLimit?: string;
|
||||
/** heapShare is heap in use as a fraction of the limit, or 0 when there is no limit. */
|
||||
heapShare: number;
|
||||
gcPerHour: number;
|
||||
}
|
||||
|
||||
export interface RuntimeWorker {
|
||||
name: string;
|
||||
component: string;
|
||||
state: 'running' | 'finished';
|
||||
started: string;
|
||||
stopped?: string;
|
||||
starts: number;
|
||||
}
|
||||
|
||||
export interface RuntimeProcess {
|
||||
pid: number;
|
||||
cpuSeconds?: number;
|
||||
cpuPercent?: number;
|
||||
/** cpuKnown and filesKnown separate "nothing open" from "could not look": these come
|
||||
* from /proc, which the container has and a developer machine does not. */
|
||||
cpuKnown: boolean;
|
||||
openFiles?: number;
|
||||
openSockets?: number;
|
||||
fileLimit?: number;
|
||||
filesKnown: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeStatus {
|
||||
at: string;
|
||||
uptimeSeconds: number;
|
||||
goroutines: number;
|
||||
gomaxprocs: number;
|
||||
threads: number;
|
||||
goVersion: string;
|
||||
memory: RuntimeMemory;
|
||||
workers?: RuntimeWorker[];
|
||||
process: RuntimeProcess;
|
||||
samples?: RuntimeSample[];
|
||||
goroutineTrend: RuntimeTrend;
|
||||
heapTrend: RuntimeTrend;
|
||||
reservedTrend: RuntimeTrend;
|
||||
sampleEverySeconds: number;
|
||||
health: RuntimeHealth;
|
||||
}
|
||||
|
||||
/* The on-demand half. Walking every goroutine's stack stops the world, so this is asked
|
||||
for and never polled — which is also why it says when it was collected and what it cost. */
|
||||
|
||||
export interface RuntimeStateCount {
|
||||
state: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RuntimeCategory {
|
||||
category: string;
|
||||
label: string;
|
||||
description: string;
|
||||
count: number;
|
||||
states?: RuntimeStateCount[];
|
||||
}
|
||||
|
||||
export interface RuntimeComponentCount {
|
||||
component: string;
|
||||
count: number;
|
||||
longestWaitMinutes: number;
|
||||
}
|
||||
|
||||
export interface RuntimeStackGroup {
|
||||
count: number;
|
||||
component: string;
|
||||
category: string;
|
||||
state: string;
|
||||
function: string;
|
||||
file: string;
|
||||
createdBy?: string;
|
||||
longestWaitMinutes: number;
|
||||
}
|
||||
|
||||
export interface GoroutineReport {
|
||||
at: string;
|
||||
total: number;
|
||||
collectedInMs: number;
|
||||
dumpBytes: number;
|
||||
categories?: RuntimeCategory[];
|
||||
components?: RuntimeComponentCount[];
|
||||
groups?: RuntimeStackGroup[];
|
||||
groupsTotal: number;
|
||||
workers?: RuntimeWorker[];
|
||||
}
|
||||
|
||||
/* ---------- gateway settings ---------- */
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Icon } from './Icon';
|
||||
import { Tag } from './ui';
|
||||
import type { Tone } from '../lib/format';
|
||||
import type { RuntimeHealth, RuntimeLevel, RuntimeTrend } from '../api/types';
|
||||
|
||||
/* The two places the process figures are read — the overview's Process card and the
|
||||
Runtime page — say the same things about them, so the wording lives here rather than
|
||||
twice. None of it decides anything: the verdict, its sentences and the areas to
|
||||
investigate all arrive from the gateway, and this renders what it was handed. */
|
||||
|
||||
export const levelTone: Record<RuntimeLevel, Tone> = {
|
||||
ok: 'ok',
|
||||
watch: 'warn',
|
||||
bad: 'bad',
|
||||
};
|
||||
|
||||
const levelWord: Record<RuntimeLevel, string> = {
|
||||
ok: 'Healthy',
|
||||
watch: 'Worth watching',
|
||||
bad: 'Needs attention',
|
||||
};
|
||||
|
||||
/** RuntimeVerdict is the headline. It replaces a bare goroutine count with the answer that
|
||||
* count was standing in for, and when there is something to look at it names the area
|
||||
* rather than leaving an operator to work out which number matters. */
|
||||
export function RuntimeVerdict({
|
||||
health,
|
||||
compact,
|
||||
}: {
|
||||
health: RuntimeHealth;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const notes = health.notes ?? [];
|
||||
return (
|
||||
<div className="verdict" data-tone={levelTone[health.level] ?? 'ok'}>
|
||||
<div className="verdict-head">
|
||||
<Icon name={health.level === 'ok' ? 'check' : 'alert'} />
|
||||
<b>{levelWord[health.level] ?? 'Unknown'}</b>
|
||||
<span>{health.summary}</span>
|
||||
</div>
|
||||
{/* On the overview the summary is the whole of it — the card is a signpost, and the
|
||||
notes belong on the page it points at. */}
|
||||
{compact || notes.length === 0 ? null : (
|
||||
<ul className="verdict-notes">
|
||||
{notes.map((note, index) => (
|
||||
<li key={`${note.area}-${index}`}>
|
||||
<Tag tone={levelTone[note.level] ?? 'note'}>{note.area}</Tag>
|
||||
<span>{note.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** trendWord turns a trend into the half-sentence that sits under a figure. "Steady" is
|
||||
* worth printing: it is the answer to the question the figure raises, and a tile that
|
||||
* says nothing when nothing is wrong makes the operator check anyway. */
|
||||
export function trendWord(trend: RuntimeTrend, format: (value: number) => string): string {
|
||||
switch (trend.direction) {
|
||||
case 'rising':
|
||||
return `rising ${format(Math.abs(trend.perHour))} an hour`;
|
||||
case 'falling':
|
||||
return `falling ${format(Math.abs(trend.perHour))} an hour`;
|
||||
case 'steady':
|
||||
return 'steady';
|
||||
default:
|
||||
return 'gathering history';
|
||||
}
|
||||
}
|
||||
|
||||
/** uptime words how long the process has been up. Days matter here and seconds do not: the
|
||||
* question this answers is "has it restarted", not "how long exactly". */
|
||||
export function uptime(seconds: number): string {
|
||||
if (!seconds || seconds < 60) return `${Math.max(0, Math.round(seconds))}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ${minutes % 60}m`;
|
||||
return `${Math.floor(hours / 24)} days`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Glyph, Icon, type IconName } from './Icon';
|
||||
import type { Tone } from '../lib/format';
|
||||
import { allNavItems } from '../nav';
|
||||
import { useDocumentTitle } from '../lib/hooks';
|
||||
|
||||
/* The component vocabulary. Every page is built from what is below and adds nothing of its
|
||||
own: a screen that needs a look it cannot get from here is a missing component, not a
|
||||
@@ -27,6 +28,9 @@ export function PageHead({
|
||||
const pageIcon = icon ?? allNavItems.find((item) =>
|
||||
matchPath({ path: item.path, end: true }, location.pathname),
|
||||
)?.icon;
|
||||
// The tab is named after the heading, so the two can never disagree — see
|
||||
// useDocumentTitle for why this hangs off PageHead rather than off the router.
|
||||
useDocumentTitle(title);
|
||||
return (
|
||||
<header className="page-head">
|
||||
{crumbs ? <nav className="crumbs">{crumbs}</nav> : null}
|
||||
@@ -480,6 +484,18 @@ export function KeyValue({ rows }: { rows: { label: string; value: ReactNode }[]
|
||||
);
|
||||
}
|
||||
|
||||
/** Subhead titles a section inside a card. Two pages had already hand-rolled an h4 with
|
||||
* their own rules; a screen that needs a look of its own is a missing component here, not
|
||||
* a licence for a style attribute. */
|
||||
export function Subhead({ children, aside }: { children: ReactNode; aside?: ReactNode }) {
|
||||
return (
|
||||
<h4 className="subhead">
|
||||
<span>{children}</span>
|
||||
{aside ? <small>{aside}</small> : null}
|
||||
</h4>
|
||||
);
|
||||
}
|
||||
|
||||
/** PlainTiles is a tile strip with no card around it, for a group of numbers inside one. */
|
||||
export function PlainTiles({ tiles }: { tiles: TileSpec[] }) {
|
||||
return (
|
||||
|
||||
@@ -95,6 +95,27 @@ export function ago(value: string | undefined | null): string {
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
|
||||
/** until words a moment that has not happened yet, where `ago` words one that has.
|
||||
*
|
||||
* Two formatters rather than one because the tense is not a suffix: the schedule page used
|
||||
* to print the next run with `ago(next).replace(' ago', '')`, which reads "5 min" for a run
|
||||
* five minutes away and "just now" for one that is overdue — and overdue is exactly the
|
||||
* state an operator opens the page to find. A moment already past is named as such rather
|
||||
* than rounded to zero, and a task with no next run at all is a dash. */
|
||||
export function until(value: string | undefined | null): string {
|
||||
if (!value) return '—';
|
||||
const remaining = new Date(value).getTime() - Date.now();
|
||||
if (!Number.isFinite(remaining)) return '—';
|
||||
if (remaining <= 0) return 'due now';
|
||||
const seconds = Math.round(remaining / 1000);
|
||||
if (seconds < 60) return `in ${seconds}s`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `in ${minutes} min`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `in ${hours}h`;
|
||||
return `in ${Math.round(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/* "Seen in the last quarter of an hour" is what the console means by active: a television
|
||||
checks in every few seconds while somebody is using it. */
|
||||
const ACTIVE_MS = 15 * 60 * 1000;
|
||||
|
||||
@@ -154,3 +154,28 @@ export function useSorted<T>(
|
||||
});
|
||||
}, [rows, key, direction]);
|
||||
}
|
||||
|
||||
/* The product name every tab is suffixed with. It is deliberately not "Memby admin", which
|
||||
* is what the pre-mount title in index.html says: that one is a placeholder for the moment
|
||||
* before the console knows which page it is on, and these are real page names that already
|
||||
* say the console is what they belong to. */
|
||||
const TITLE_SUFFIX = 'Memby';
|
||||
|
||||
/** useDocumentTitle names the browser tab after the page.
|
||||
*
|
||||
* It is called from PageHead rather than from each page, because PageHead is already the
|
||||
* one component that states a page's name and every route renders exactly one of them —
|
||||
* so the tab and the heading cannot come apart, and a page added tomorrow is titled
|
||||
* without anybody remembering to do it. That also means the dynamic pages are named after
|
||||
* the thing they are about ("Kitchen TV | Memby") rather than after their route.
|
||||
*
|
||||
* Nothing is restored on unmount: the next page's PageHead sets the title in the same
|
||||
* commit, and clearing it first would blink the product name into the tab between every
|
||||
* navigation. A blank name falls back to the suffix alone rather than printing a bare
|
||||
* separator. */
|
||||
export function useDocumentTitle(title: string): void {
|
||||
useEffect(() => {
|
||||
const name = title.trim();
|
||||
document.title = name ? `${name} | ${TITLE_SUFFIX}` : TITLE_SUFFIX;
|
||||
}, [title]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { IntegrationService, IntegrationStatus, RunCounts, TaskRun } from '../api/types';
|
||||
import type { Tone } from './format';
|
||||
|
||||
/* The vocabulary the two integration pages share.
|
||||
*
|
||||
* It lives here rather than in either page for the reason lib/tasks.ts exists: the
|
||||
* overview and one service's own page make exactly the same judgements — what tone a
|
||||
* status wears, what a run's figures read as, whether a run is worth expanding — and two
|
||||
* copies of those is how a table comes to say a service is fine while the page it links to
|
||||
* says it failed. Everything here is pure.
|
||||
*
|
||||
* What is deliberately *not* here is the status word itself. That is the gateway's
|
||||
* (`statusLabel` on the wire), the stance every label Memby prints takes: the threshold and
|
||||
* the sentence explaining it belong together, and an older console must not be the thing
|
||||
* deciding what "healthy" means about a server it is only reporting on. */
|
||||
|
||||
/** The tone one status wears. Six statuses, four tones: idle and unconfigured are neither
|
||||
* good nor bad, and giving them a colour of their own would make every row on the page
|
||||
* look like it carried a verdict. */
|
||||
export function integrationTone(status: IntegrationStatus): Tone | undefined {
|
||||
switch (status) {
|
||||
case 'error':
|
||||
return 'bad';
|
||||
case 'healthy':
|
||||
return 'ok';
|
||||
case 'running':
|
||||
return 'info';
|
||||
case 'disabled':
|
||||
return 'warn';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The dot beside the word, which is what makes the column scannable without reading it.
|
||||
* A dot the tag does not also carry a word for would be a colour nobody can interpret. */
|
||||
export function integrationDot(status: IntegrationStatus): Tone | undefined {
|
||||
return integrationTone(status);
|
||||
}
|
||||
|
||||
/** Anything wrong sorts to the top, the order the tasks table takes. The server already
|
||||
* sends the list in this order; the console re-applies it because it also sorts lists the
|
||||
* server never ordered — a single service's task list, for one. */
|
||||
const STATUS_RANK: Record<IntegrationStatus, number> = {
|
||||
error: 0,
|
||||
running: 1,
|
||||
idle: 2,
|
||||
healthy: 3,
|
||||
disabled: 4,
|
||||
unconfigured: 5,
|
||||
};
|
||||
|
||||
export function compareIntegrations(a: IntegrationService, b: IntegrationService): number {
|
||||
const byStatus = STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
||||
if (byStatus !== 0) return byStatus;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
/** Whether a run counted anything worth printing. A run of four zeroes counted nothing —
|
||||
* most housekeeping does — and printing them would make every row look like a failure. */
|
||||
export const counted = (counts: RunCounts | undefined): boolean =>
|
||||
Boolean(counts && (counts.processed || counts.changed || counts.skipped || counts.failed));
|
||||
|
||||
/** One run's figures as a phrase: "412 checked · 7 changed · 2 skipped".
|
||||
*
|
||||
* Only non-zero figures appear. A run that skipped nothing is not making a claim about
|
||||
* skipping, and a row of "0 skipped, 0 failed" reads as a report about failure rather
|
||||
* than as one about work done. */
|
||||
export function countSummary(counts: RunCounts | undefined): string {
|
||||
if (!counted(counts) || !counts) return '';
|
||||
const parts: string[] = [];
|
||||
if (counts.processed) parts.push(`${counts.processed.toLocaleString()} checked`);
|
||||
if (counts.changed) parts.push(`${counts.changed.toLocaleString()} changed`);
|
||||
if (counts.skipped) parts.push(`${counts.skipped.toLocaleString()} skipped`);
|
||||
if (counts.failed) parts.push(`${counts.failed.toLocaleString()} failed`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** What one run's outcome column says.
|
||||
*
|
||||
* The error wins where there is one, then whatever the task said about itself, then its
|
||||
* figures. A run with none of the three did nothing worth reporting, which is the common
|
||||
* case for a job that checks whether anything is due — and "no changes" is the honest
|
||||
* reading of it, rather than an em dash that reads as missing data. */
|
||||
export function runOutcome(run: TaskRun): string {
|
||||
if (run.status === 'running') return 'Running…';
|
||||
if (run.error) return run.error;
|
||||
if (run.detail) return run.detail;
|
||||
const summary = countSummary(run.counts);
|
||||
if (summary) return summary;
|
||||
return 'No changes';
|
||||
}
|
||||
|
||||
/* runTone is the scheduled-task pages' own rule, re-exported rather than copied: an
|
||||
integration run is a task run, and two definitions of what colour "skipped" wears is
|
||||
exactly how the same row comes to be amber on one page and green on another. */
|
||||
export { runTone } from './tasks';
|
||||
|
||||
/** The sentence under a disabled switch.
|
||||
*
|
||||
* It names what goes with the service rather than repeating the switch, because "Sonarr
|
||||
* is off" says nothing an operator who just pressed it does not know — what they may not
|
||||
* have thought about is the television calendar going with it. That is the whole of
|
||||
* "make the dependency clear rather than silently failing" on this side of the wire. */
|
||||
export function dependencyWarning(service: IntegrationService): string {
|
||||
if (!service.powers.length) return '';
|
||||
return `Switching ${service.name} off also stops: ${service.powers.join('; ')}.`;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { ScheduledTask, TaskRun } from '../api/types';
|
||||
import { interval } from './format';
|
||||
import type { Tone } from './format';
|
||||
|
||||
/* The vocabulary the two scheduled-task pages share.
|
||||
*
|
||||
* It lives here rather than in either page because the list and one task's own page make
|
||||
* exactly the same judgements — what state a task is in, what cadences it may be given,
|
||||
* whether it has been retimed — and two copies of those is how a table comes to say a task
|
||||
* is fine while the page it links to says it failed. Everything in this file is pure, so
|
||||
* it is the half that can be reasoned about without a server. */
|
||||
|
||||
/* The cadences an operator may choose from.
|
||||
*
|
||||
* A fixed list rather than a free-text duration, because the useful range here spans three
|
||||
* orders of magnitude and the two ways to get it wrong are both silent: a number typed in
|
||||
* the wrong unit, and a cadence so tight the job never finishes before it is due again. The
|
||||
* floor matches the scheduler's own — it clamps anything under a minute — so the console
|
||||
* cannot offer a value the server would quietly change underneath it.
|
||||
*
|
||||
* Zero is absent on purpose. The API reads it as "restore the declared cadence" rather than
|
||||
* as "never", so a "run by hand only" entry here would appear to do nothing on any task that
|
||||
* declares an interval. That is a server-side limitation and it belongs in the server, not
|
||||
* in a control that lies about it. */
|
||||
const CADENCE_CHOICES: { value: number; label: string }[] = [
|
||||
{ value: 60, label: 'Every minute' },
|
||||
{ value: 300, label: 'Every 5 minutes' },
|
||||
{ value: 600, label: 'Every 10 minutes' },
|
||||
{ value: 900, label: 'Every 15 minutes' },
|
||||
{ value: 1_800, label: 'Every 30 minutes' },
|
||||
{ value: 3_600, label: 'Hourly' },
|
||||
{ value: 10_800, label: 'Every 3 hours' },
|
||||
{ value: 21_600, label: 'Every 6 hours' },
|
||||
{ value: 43_200, label: 'Every 12 hours' },
|
||||
{ value: 86_400, label: 'Daily' },
|
||||
{ value: 604_800, label: 'Weekly' },
|
||||
];
|
||||
|
||||
/** The choices for one task: the presets, plus its own declared cadence and whatever it is
|
||||
* currently set to if either falls outside the list.
|
||||
*
|
||||
* Adding them rather than snapping to the nearest preset is what stops the control being
|
||||
* destructive to look at — a task declaring 45 minutes must not silently become hourly
|
||||
* because somebody opened the page and the select had to show *something*. */
|
||||
export function cadenceChoices(task: ScheduledTask): { value: number; label: string }[] {
|
||||
const choices = [...CADENCE_CHOICES];
|
||||
for (const seconds of [task.defaultIntervalSeconds, task.intervalSeconds]) {
|
||||
if (seconds > 0 && !choices.some((choice) => choice.value === seconds)) {
|
||||
choices.push({ value: seconds, label: interval(seconds).replace(/^every /, 'Every ') });
|
||||
}
|
||||
}
|
||||
return choices.sort((a, b) => a.value - b.value);
|
||||
}
|
||||
|
||||
/** A task is retimed when an operator has moved it off the cadence its code declares. It is
|
||||
* the most likely explanation for "why has this not run", and it is invisible on a page
|
||||
* that only prints the cadence currently in force. */
|
||||
export const retimed = (task: ScheduledTask): boolean =>
|
||||
task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds;
|
||||
|
||||
/** The tone of one run's outcome. Skipped is amber rather than green: the run was declined,
|
||||
* which is a fine thing to have happened and a poor thing to read as success. */
|
||||
export function runTone(status: TaskRun['status']): Tone {
|
||||
if (status === 'failed') return 'bad';
|
||||
if (status === 'running') return 'info';
|
||||
if (status === 'skipped') return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export type TaskState = 'running' | 'disabled' | 'failed' | 'skipped' | 'successful' | 'never';
|
||||
|
||||
export interface TaskStatus {
|
||||
state: TaskState;
|
||||
label: string;
|
||||
tone: Tone | undefined;
|
||||
/** The sentence behind the word, shown on hover — a tag is two syllables and an operator
|
||||
* reading a column of them deserves to be told what each one is claiming. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
/* One word per task, resolved in priority order, because a status column with two answers
|
||||
* in it is one nobody can scan.
|
||||
*
|
||||
* Switched off outranks everything: a task nobody is running has no meaningful last result
|
||||
* and drawing yesterday's success beside it would read as one that is still working. A run
|
||||
* in flight outranks its own history for the same reason in the other direction — what it
|
||||
* did an hour ago is not what an operator watching it now is asking about. Below those, the
|
||||
* task is described by its last run, and a task that has never run says exactly that rather
|
||||
* than borrowing either verdict. */
|
||||
export function taskStatus(task: ScheduledTask): TaskStatus {
|
||||
if (task.running) {
|
||||
return { state: 'running', label: 'Running', tone: 'info', title: 'Running right now' };
|
||||
}
|
||||
if (!task.enabled) {
|
||||
return {
|
||||
state: 'disabled',
|
||||
label: 'Disabled',
|
||||
tone: 'warn',
|
||||
title: 'Switched off — it will not run on its schedule',
|
||||
};
|
||||
}
|
||||
const last = task.lastRun;
|
||||
if (!last) {
|
||||
return {
|
||||
state: 'never',
|
||||
label: 'Never run',
|
||||
tone: undefined,
|
||||
title: 'This task has not run since the gateway started recording',
|
||||
};
|
||||
}
|
||||
if (last.status === 'failed') {
|
||||
return { state: 'failed', label: 'Failed', tone: 'bad', title: last.error || 'The last run failed' };
|
||||
}
|
||||
if (last.status === 'skipped') {
|
||||
return {
|
||||
state: 'skipped',
|
||||
label: 'Skipped',
|
||||
tone: 'warn',
|
||||
title: last.detail || 'The last run declined to do anything',
|
||||
};
|
||||
}
|
||||
return { state: 'successful', label: 'Successful', tone: 'ok', title: 'The last run finished cleanly' };
|
||||
}
|
||||
|
||||
/* The dot beside the word, which is what makes the column scannable at a glance without
|
||||
* reading it. Only four tones exist for it, so the two states that are neither good nor bad
|
||||
* — never run, and skipped — share the quiet one rather than inventing a fifth. */
|
||||
export function statusDot(status: TaskStatus): Tone | undefined {
|
||||
if (status.state === 'running' || status.state === 'successful') return 'ok';
|
||||
if (status.state === 'failed') return 'bad';
|
||||
if (status.state === 'disabled' || status.state === 'skipped') return 'warn';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The list's order, and it is not alphabetical: the rows worth an operator's attention go
|
||||
* to the top, because a page read from the top down should not require a sort to find the
|
||||
* one thing that is wrong. Within a band the group holds the rows together and the name
|
||||
* settles it, so the table is stable between polls. */
|
||||
const STATE_RANK: Record<TaskState, number> = {
|
||||
failed: 0,
|
||||
running: 1,
|
||||
skipped: 2,
|
||||
disabled: 3,
|
||||
never: 4,
|
||||
successful: 5,
|
||||
};
|
||||
|
||||
export function compareTasks(a: ScheduledTask, b: ScheduledTask): number {
|
||||
const byState = STATE_RANK[taskStatus(a).state] - STATE_RANK[taskStatus(b).state];
|
||||
if (byState !== 0) return byState;
|
||||
// A task belonging to no service sorts after every named one, rather than to the top
|
||||
// where an empty string would otherwise put it.
|
||||
if (Boolean(a.group) !== Boolean(b.group)) return a.group ? -1 : 1;
|
||||
const byGroup = a.group.localeCompare(b.group);
|
||||
if (byGroup !== 0) return byGroup;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
+58
-10
@@ -193,14 +193,6 @@ export const nav: NavGroup[] = [
|
||||
intro: 'The prepared pools personalised rows are drawn from.',
|
||||
icon: 'sparkle',
|
||||
},
|
||||
{
|
||||
id: 'ratings',
|
||||
path: '/admin/ratings',
|
||||
label: 'Movie ratings',
|
||||
title: 'Movie ratings',
|
||||
intro: 'Optional MDBList scores on films and shows.',
|
||||
icon: 'star',
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
path: '/admin/inspector',
|
||||
@@ -263,6 +255,17 @@ export const nav: NavGroup[] = [
|
||||
intro: 'What the gateway does in the background, when it last ran and whether it worked.',
|
||||
icon: 'clock',
|
||||
},
|
||||
{
|
||||
/* One task, addressed by its id — a page about a single job belongs to the job
|
||||
rather than to the rail, the stance the user and device pages take. */
|
||||
id: 'task',
|
||||
path: '/admin/tasks/:taskId',
|
||||
label: 'Task',
|
||||
title: 'Task',
|
||||
intro: 'One scheduled task: its cadence, its switch and its own run history.',
|
||||
icon: 'clock',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
id: 'imports',
|
||||
path: '/admin/imports',
|
||||
@@ -271,6 +274,17 @@ export const nav: NavGroup[] = [
|
||||
intro: 'Catalogue synchronisation history.',
|
||||
icon: 'database',
|
||||
},
|
||||
{
|
||||
/* The process, rather than the household. It sits beside Logs and Maintenance
|
||||
because the question it answers — is the container healthy — is the one an
|
||||
operator arrives with when something is slow rather than wrong. */
|
||||
id: 'runtime',
|
||||
path: '/admin/runtime',
|
||||
label: 'Runtime',
|
||||
title: 'Runtime',
|
||||
intro: 'Goroutines, memory and the background workers inside the gateway process.',
|
||||
icon: 'chip',
|
||||
},
|
||||
{
|
||||
id: 'maintenance',
|
||||
path: '/admin/maintenance',
|
||||
@@ -292,14 +306,48 @@ export const nav: NavGroup[] = [
|
||||
icon: 'sliders',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
/* External services, and the one place they are configured.
|
||||
*
|
||||
* Its own section rather than an entry under Operations, because the question it
|
||||
* answers — is everything Memby depends on working — is not the same as "what is the
|
||||
* container doing", and because four services' settings previously lived on four
|
||||
* unrelated pages: MDBList under Movie ratings, the *arr switches and request
|
||||
* policies on a page about Discord, and Tracearr nowhere at all. */
|
||||
id: 'integrations',
|
||||
label: 'Integrations',
|
||||
defaultCollapsed: true,
|
||||
items: [
|
||||
{
|
||||
id: 'integrations',
|
||||
path: '/admin/integrations',
|
||||
label: 'Integrations',
|
||||
label: 'Overview',
|
||||
title: 'Integrations',
|
||||
intro: 'Send administrative events to Discord and, in time, elsewhere.',
|
||||
intro: 'Every external service Memby depends on: configured, working, and what it last did.',
|
||||
icon: 'plug',
|
||||
},
|
||||
{
|
||||
/* One service, addressed by its id — a page about a single integration belongs to
|
||||
the integration rather than to the rail, the stance the user, device and task
|
||||
pages take. */
|
||||
id: 'integration',
|
||||
path: '/admin/integrations/:integrationId',
|
||||
label: 'Integration',
|
||||
title: 'Integration',
|
||||
intro: 'One external service: its switch, its settings, its jobs and its run history.',
|
||||
icon: 'plug',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
id: 'webhooks',
|
||||
path: '/admin/integrations/webhooks',
|
||||
label: 'Event webhooks',
|
||||
title: 'Event webhooks',
|
||||
intro: 'Send administrative events to Discord and, in time, elsewhere.',
|
||||
icon: 'send',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -89,7 +89,7 @@ export function AccountsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Memby users" intro="Who uses Memby, and the devices they are signed in on." />
|
||||
<PageHead title="Users" intro="Who uses Memby, and which devices they are signed in to." />
|
||||
<Banner message={error} />
|
||||
|
||||
{loading ? (
|
||||
@@ -121,12 +121,12 @@ export function AccountsPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card title="People" icon="people" tone="note">
|
||||
<Card title="Users" icon="people" tone="note">
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Person</th>
|
||||
<th>User</th>
|
||||
<th>Short name</th>
|
||||
<th className="num">Devices</th>
|
||||
<th className="num">This week</th>
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { ago, duration, interval, num, until, when } from '../lib/format';
|
||||
import {
|
||||
countSummary,
|
||||
dependencyWarning,
|
||||
integrationDot,
|
||||
integrationTone,
|
||||
runOutcome,
|
||||
runTone,
|
||||
} from '../lib/integrations';
|
||||
import { Glyph } from '../components/Icon';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
EmptyRow,
|
||||
KeyValue,
|
||||
Loading,
|
||||
Note,
|
||||
PageHead,
|
||||
TableWrap,
|
||||
Tag,
|
||||
Tiles,
|
||||
Toggle,
|
||||
} from '../components/ui';
|
||||
import { RatingsSettingsCard } from './Ratings';
|
||||
import { RadarrRequestCard, SonarrRequestCard } from './Webhooks';
|
||||
import type { IntegrationService, IntegrationServiceResponse, TaskRun } from '../api/types';
|
||||
|
||||
/* One integration: its switch, its settings, its jobs and everything it has done.
|
||||
*
|
||||
* A page about one service belongs to the service rather than to the rail, the stance the
|
||||
* user, device and task pages take — so it is a hidden destination addressed by an id in
|
||||
* the path, and the overview is the way in.
|
||||
*
|
||||
* It is deliberately the only place a service can be configured. Before it, MDBList lived
|
||||
* on a Movie ratings page, the Sonarr and Radarr switches lived on a page about Discord,
|
||||
* their request policies lived beside those, and Tracearr could not be reached at all —
|
||||
* so "is this integration set up correctly" was four pages and one impossible question.
|
||||
*
|
||||
* The run history here is not a log. Logs are the technical events behind a failure and
|
||||
* they have their own page, which the failure notice links to; this is the operational
|
||||
* record of what Memby attempted on this service's behalf and what came of it. They come
|
||||
* from the same place — the scheduler's run table, read along the integration axis —
|
||||
* which is what keeps them from disagreeing. */
|
||||
|
||||
const IDLE_POLL_MS = 20_000;
|
||||
const BUSY_POLL_MS = 3_000;
|
||||
|
||||
export function IntegrationPage() {
|
||||
const { integrationId = '' } = useParams();
|
||||
const { wrap, show } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
const [fast, setFast] = useState(false);
|
||||
const { data, error, loading, reload } = useQuery<IntegrationServiceResponse>(
|
||||
`/admin/api/integrations/services/${encodeURIComponent(integrationId)}?limit=80`,
|
||||
{ pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS, enabled: Boolean(integrationId) },
|
||||
);
|
||||
|
||||
const service = data?.service;
|
||||
const runs = data?.runs ?? [];
|
||||
const running = Boolean(service?.running);
|
||||
if (running !== fast) setFast(running);
|
||||
|
||||
const setEnabled = (enabled: boolean) =>
|
||||
run('enabled', async () => {
|
||||
await wrap(
|
||||
() =>
|
||||
api.post(
|
||||
`/admin/api/integrations/services/${encodeURIComponent(integrationId)}/enabled`,
|
||||
{ enabled },
|
||||
),
|
||||
`${service?.name ?? 'Integration'} ${enabled ? 'enabled' : 'disabled'}.`,
|
||||
);
|
||||
await reload();
|
||||
});
|
||||
|
||||
const test = () =>
|
||||
run('test', async () => {
|
||||
const result = await wrap(() =>
|
||||
api.post<{ reachable: boolean; error?: string; latencyMs: number }>(
|
||||
`/admin/api/integrations/services/${encodeURIComponent(integrationId)}/test`,
|
||||
),
|
||||
);
|
||||
// The probe answers 200 whether or not the service replied, because the *request*
|
||||
// succeeded — so the verdict is in the body and reporting it is this page's job.
|
||||
if (result) {
|
||||
show(
|
||||
result.reachable
|
||||
? `${service?.name} answered in ${duration(result.latencyMs)}.`
|
||||
: result.error || `${service?.name} did not answer.`,
|
||||
result.reachable ? 'ok' : 'bad',
|
||||
);
|
||||
}
|
||||
await reload();
|
||||
});
|
||||
|
||||
const runTask = (taskId: string, name: string) =>
|
||||
run(taskId, async () => {
|
||||
await wrap(
|
||||
() => api.post(`/admin/api/tasks/${encodeURIComponent(taskId)}/run`),
|
||||
`${name} started.`,
|
||||
);
|
||||
await reload();
|
||||
});
|
||||
|
||||
if (loading || !service) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Integration" intro="One external service and everything it has done." />
|
||||
<Banner message={error} />
|
||||
{loading ? <Loading /> : <Note tone="warn">No such integration.</Note>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const failures = runs.filter((entry) => entry.status === 'failed').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={service.name}
|
||||
intro={service.summary}
|
||||
actions={
|
||||
<Link className="table-row-link" to="/admin/integrations">
|
||||
All integrations
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<Banner message={error} />
|
||||
|
||||
<Tiles
|
||||
tiles={[
|
||||
{
|
||||
label: 'Status',
|
||||
value: service.statusLabel,
|
||||
small: true,
|
||||
icon: 'pulse',
|
||||
tone: integrationTone(service.status),
|
||||
},
|
||||
{
|
||||
label: 'Runs recorded',
|
||||
value: num(service.runs),
|
||||
icon: 'history',
|
||||
tone: 'data',
|
||||
},
|
||||
{
|
||||
label: 'Failed runs',
|
||||
value: num(service.failures),
|
||||
icon: 'alert',
|
||||
tone: service.failures > 0 ? 'bad' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Last worked',
|
||||
value: service.lastSuccessAt ? ago(service.lastSuccessAt) : 'never',
|
||||
small: true,
|
||||
icon: 'check',
|
||||
tone: service.lastSuccessAt ? 'ok' : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card
|
||||
title="Connection"
|
||||
intro="Address and credentials come from this gateway's environment; the switch is stored on the server and applies to the whole household."
|
||||
icon="plug"
|
||||
tone={integrationTone(service.status) ?? 'info'}
|
||||
actions={
|
||||
<span className="row tight">
|
||||
<span className="dot-state" data-tone={integrationDot(service.status)} />
|
||||
<Tag tone={integrationTone(service.status)}>{service.statusLabel}</Tag>
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
service.configured && service.probed ? (
|
||||
<Button icon="sync" busy={busy === 'test'} onClick={() => void test()}>
|
||||
Test connection
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{service.detail ? <Note tone={integrationTone(service.status)}>{service.detail}</Note> : null}
|
||||
|
||||
{!service.configured ? (
|
||||
// Stated rather than offered. There is nothing on this page that could fix it:
|
||||
// the address and key are environment variables on the container, so a switch
|
||||
// here would be one that records a decision nothing ever reads.
|
||||
<Note tone="warn">
|
||||
This gateway has no address or credential for {service.name}. Set them in the
|
||||
deployment's environment and restart the container; there is nothing to switch until
|
||||
then.
|
||||
</Note>
|
||||
) : (
|
||||
<>
|
||||
<Toggle
|
||||
label={`${service.name} enabled`}
|
||||
hint={
|
||||
service.enabled
|
||||
? dependencyWarning(service)
|
||||
: `Memby is not calling ${service.name} or scheduling any of its work.`
|
||||
}
|
||||
checked={service.enabled}
|
||||
disabled={busy === 'enabled'}
|
||||
onChange={(enabled) => void setEnabled(enabled)}
|
||||
/>
|
||||
<KeyValue
|
||||
rows={[
|
||||
{ label: 'Address', value: service.address || '—' },
|
||||
...(service.facts ?? []).map((fact) => ({
|
||||
label: fact.label,
|
||||
value: fact.tone ? (
|
||||
<Tag tone={fact.tone === 'warn' ? 'warn' : 'ok'}>{fact.value}</Tag>
|
||||
) : (
|
||||
fact.value
|
||||
),
|
||||
})),
|
||||
{
|
||||
label: 'Last checked',
|
||||
// "Never checked" and "cannot be checked" are different answers, and a
|
||||
// service that is deliberately not probed must not read as one nobody has
|
||||
// got round to looking at.
|
||||
value: !service.probed
|
||||
? 'Not probed — see below'
|
||||
: service.health
|
||||
? `${ago(service.health.checkedAt)} · ${duration(service.health.latencyMs)}`
|
||||
: 'not yet',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{!service.probed ? (
|
||||
<Note>
|
||||
{service.name} is not probed for reachability: its allowance is bought by the day,
|
||||
and spending a request of it to draw a status would compete with the televisions
|
||||
for the thing being reported on. Its health comes from the run history below.
|
||||
</Note>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{service.powers.length > 0 ? (
|
||||
<Card
|
||||
title="What depends on this"
|
||||
intro="Switching the service off stops all of it. Nothing below fails quietly — it stops being offered."
|
||||
icon="journey"
|
||||
tone="note"
|
||||
>
|
||||
{/* The console's list vocabulary rather than a bare <ul>: this is the same
|
||||
shape every other enumeration on the console wears, and a page inventing its
|
||||
own is how twelve screens stop reading as one. */}
|
||||
<div className="list">
|
||||
{service.powers.map((power) => (
|
||||
<div className="list-item" key={power}>
|
||||
<Glyph name="check" tone="ok" />
|
||||
<span className="list-body">{power}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<IntegrationSettings id={service.id} />
|
||||
|
||||
<Card
|
||||
title="Scheduled work"
|
||||
intro="The background jobs belonging to this service. Cadence and the per-job switch live on each job's own page; this is where you start one by hand."
|
||||
icon="clock"
|
||||
tone="info"
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Schedule</th>
|
||||
<th>Last run</th>
|
||||
<th>Next run</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{service.tasks.length === 0 ? (
|
||||
<EmptyRow columns={5}>
|
||||
This service has no scheduled work: Memby calls it when a television asks for
|
||||
something rather than on a timer.
|
||||
</EmptyRow>
|
||||
) : (
|
||||
service.tasks.map((task) => (
|
||||
<tr key={task.id}>
|
||||
<td>
|
||||
<Link className="table-row-link" to={`/admin/tasks/${encodeURIComponent(task.id)}`}>
|
||||
{task.name}
|
||||
</Link>
|
||||
<span className="table-sub">{task.description}</span>
|
||||
</td>
|
||||
<td className="nowrap muted">{interval(task.intervalSeconds)}</td>
|
||||
<td className="nowrap muted" title={task.lastRun ? when(task.lastRun.startedAt) : undefined}>
|
||||
{task.lastRun ? ago(task.lastRun.startedAt) : 'never'}
|
||||
</td>
|
||||
<td className="nowrap muted">
|
||||
{!task.enabled || !service.enabled ? 'off' : task.running ? 'now' : until(task.nextRun)}
|
||||
</td>
|
||||
<td className="nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
icon="play"
|
||||
busy={busy === task.id}
|
||||
disabled={task.running}
|
||||
title={`Run ${task.name} now`}
|
||||
onClick={() => void runTask(task.id, task.name)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Run history"
|
||||
intro="What Memby attempted on this service's behalf and what came of it. This is not the application log — a failure here gives the reason, and Logs is where the technical detail behind it lives."
|
||||
icon="history"
|
||||
tone="note"
|
||||
actions={
|
||||
failures > 0 ? (
|
||||
/* Narrowed to this service by name, which is what makes the link worth
|
||||
following: the run history says a request failed and the log says what the
|
||||
request was. */
|
||||
<Link
|
||||
className="table-row-link"
|
||||
to={`/admin/logs?q=${encodeURIComponent(service.id)}`}
|
||||
>
|
||||
Open the logs
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="nowrap">Started</th>
|
||||
<th>Task</th>
|
||||
<th>Trigger</th>
|
||||
<th>Result</th>
|
||||
<th>Outcome</th>
|
||||
<th className="num">Checked</th>
|
||||
<th className="num">Changed</th>
|
||||
<th className="num">Skipped</th>
|
||||
<th className="num">Failed</th>
|
||||
<th className="num">Took</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.length === 0 ? (
|
||||
<EmptyRow columns={10}>
|
||||
Nothing has run for {service.name} yet.
|
||||
</EmptyRow>
|
||||
) : (
|
||||
runs.map((entry) => <RunRow key={entry.id} run={entry} service={service} />)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunRow({ run, service }: { run: TaskRun; service: IntegrationService }) {
|
||||
const task = service.tasks.find((entry) => entry.id === run.taskId);
|
||||
// A figure that is zero is drawn as an em dash rather than as 0: most runs count one or
|
||||
// two of the four, and a wall of zeroes reads as a table reporting nothing happened
|
||||
// rather than as one reporting what did.
|
||||
const figure = (value: number) => (value ? num(value) : '—');
|
||||
return (
|
||||
<tr>
|
||||
<td className="nowrap muted" title={when(run.startedAt)}>
|
||||
{ago(run.startedAt)}
|
||||
</td>
|
||||
<td className="muted">{task?.name ?? run.taskId}</td>
|
||||
<td className="muted">{run.trigger}</td>
|
||||
<td>
|
||||
<Tag tone={runTone(run.status)}>{run.status}</Tag>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{runOutcome(run)}
|
||||
{run.error && countSummary(run.counts) ? (
|
||||
<span className="table-sub">{countSummary(run.counts)}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="num muted">{figure(run.counts?.processed ?? 0)}</td>
|
||||
<td className="num muted">{figure(run.counts?.changed ?? 0)}</td>
|
||||
<td className="num muted">{figure(run.counts?.skipped ?? 0)}</td>
|
||||
<td className="num muted">{figure(run.counts?.failed ?? 0)}</td>
|
||||
<td className="num muted">{duration(run.durationMs)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/* The settings that belong to one service and nowhere else.
|
||||
*
|
||||
* A lookup rather than a field on the wire: what a service's settings *are* is markup, and
|
||||
* the gateway has no business describing a React component. A service with nothing here
|
||||
* renders nothing, which is the Tracearr case — everything about it is environment
|
||||
* configuration, and the page says so above. */
|
||||
function IntegrationSettings({ id }: { id: string }) {
|
||||
switch (id) {
|
||||
case 'mdblist':
|
||||
return <RatingsSettingsCard />;
|
||||
case 'sonarr':
|
||||
return <SonarrRequestCard />;
|
||||
case 'radarr':
|
||||
return <RadarrRequestCard />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+218
-471
@@ -1,510 +1,257 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { ago, duration, num, when } from '../lib/format';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '../lib/hooks';
|
||||
import { ago, duration, num, until, when } from '../lib/format';
|
||||
import {
|
||||
compareIntegrations,
|
||||
countSummary,
|
||||
integrationDot,
|
||||
integrationTone,
|
||||
runOutcome,
|
||||
runTone,
|
||||
} from '../lib/integrations';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Confirm,
|
||||
Empty,
|
||||
EmptyRow,
|
||||
Field,
|
||||
Loading,
|
||||
Note,
|
||||
PageHead,
|
||||
TableWrap,
|
||||
Tag,
|
||||
Toggle,
|
||||
Tiles,
|
||||
} from '../components/ui';
|
||||
import type { ArrIntegrationStatus, Integration, IntegrationEventOption, IntegrationsResponse, RadarrRequestPolicy, SonarrRequestPolicy } from '../api/types';
|
||||
import type { IntegrationService, IntegrationServicesResponse } from '../api/types';
|
||||
|
||||
/* Integrations: administrative events going out to somewhere else.
|
||||
/* Integrations: the external services Memby depends on.
|
||||
*
|
||||
* The whole page is written around one property of the backend, and it is worth stating
|
||||
* because the form would otherwise look careless: the webhook address is never returned.
|
||||
* It is the credential — anybody holding it can post into the channel — so the gateway
|
||||
* sends back only whether one is set and the channel id from the middle of it. That is why
|
||||
* the address field on an existing integration is blank with a placeholder saying so, and
|
||||
* why saving with it blank leaves the stored one alone. */
|
||||
* One row per service and very nearly only a directory, the shape the Users and Scheduled
|
||||
* tasks pages take — because the question this page is opened with is "is anything
|
||||
* broken", and that is answered by scanning a column rather than by reading four cards in
|
||||
* turn. Everything you can *do* to a service beyond seeing its state lives on its own
|
||||
* page, which is also where the settings that used to be scattered across Ratings, the old
|
||||
* Integrations page and nowhere at all now live.
|
||||
*
|
||||
* The status word and the sentence under it are the gateway's, not this page's. See
|
||||
* lib/integrations.ts: a console that decided for itself what "healthy" meant would have
|
||||
* an older build disagreeing with a newer one about the same server.
|
||||
*
|
||||
* Polled faster while something is running, for the reason the tasks page is: having
|
||||
* pressed Run now, the next thing an operator does is watch for the outcome, and a
|
||||
* thirty-second poll makes a two-second job look like one that did nothing. */
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
const NEW_DRAFT: Draft = { id: '', name: 'Discord', url: '', enabled: true, events: [] };
|
||||
const IDLE_POLL_MS = 20_000;
|
||||
const BUSY_POLL_MS = 3_000;
|
||||
|
||||
export function IntegrationsPage() {
|
||||
const { wrap, show } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
const { data, error, loading, reload } = useQuery<IntegrationsResponse>('/admin/api/integrations', {
|
||||
pollMs: 60_000,
|
||||
});
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [confirming, setConfirming] = useState<Integration | null>(null);
|
||||
const [fast, setFast] = useState(false);
|
||||
const { data, error, loading } = useQuery<IntegrationServicesResponse>(
|
||||
'/admin/api/integrations/services?limit=40',
|
||||
{ pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS },
|
||||
);
|
||||
|
||||
const catalogue = data?.catalogue ?? [];
|
||||
const integrations = data?.integrations ?? [];
|
||||
const anyRunning = (data?.services ?? []).some((service) => service.running);
|
||||
if (anyRunning !== fast) setFast(anyRunning);
|
||||
|
||||
const edit = (integration: Integration) =>
|
||||
setDraft({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
url: '',
|
||||
enabled: integration.enabled,
|
||||
events: integration.events ?? [],
|
||||
});
|
||||
const services = [...(data?.services ?? [])].sort(compareIntegrations);
|
||||
const runs = data?.runs ?? [];
|
||||
|
||||
const save = () =>
|
||||
run('save', async () => {
|
||||
if (!draft) return;
|
||||
const saved = await wrap(
|
||||
() => api.post<IntegrationsResponse>('/admin/api/integrations', draft),
|
||||
draft.id ? 'Integration saved.' : 'Integration added.',
|
||||
);
|
||||
if (saved) {
|
||||
setDraft(null);
|
||||
await reload();
|
||||
}
|
||||
});
|
||||
|
||||
const remove = (integration: Integration) =>
|
||||
run('remove', async () => {
|
||||
await wrap(
|
||||
() => api.del(`/admin/api/integrations/${encodeURIComponent(integration.id)}`),
|
||||
`${integration.name} removed.`,
|
||||
);
|
||||
setConfirming(null);
|
||||
await reload();
|
||||
});
|
||||
|
||||
const test = (integration: Integration) =>
|
||||
run(`test:${integration.id}`, async () => {
|
||||
const result = await wrap(() =>
|
||||
api.post<{ ok: boolean; message: string }>(
|
||||
`/admin/api/integrations/${encodeURIComponent(integration.id)}/test`,
|
||||
),
|
||||
);
|
||||
// The test route answers 200 whether or not the webhook accepted it, because the
|
||||
// *request* succeeded — so the verdict is in the body, and reporting it is this
|
||||
// page's job rather than the transport's.
|
||||
if (result) show(result.message, result.ok ? 'ok' : 'bad');
|
||||
await reload();
|
||||
});
|
||||
const broken = services.filter((service) => service.status === 'error');
|
||||
const running = services.filter((service) => service.running);
|
||||
const off = services.filter((service) => service.configured && !service.enabled);
|
||||
const missing = services.filter((service) => !service.configured);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Integrations"
|
||||
intro="Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere."
|
||||
actions={
|
||||
<Button variant="primary" icon="plus" onClick={() => setDraft(NEW_DRAFT)}>
|
||||
Add a webhook
|
||||
</Button>
|
||||
}
|
||||
intro="The services Memby depends on: whether each one is configured, whether it is working, what it is doing right now and what it last managed to do."
|
||||
/>
|
||||
|
||||
<Banner message={error} />
|
||||
|
||||
<ArrIntegrationCard />
|
||||
<SonarrRequestCard />
|
||||
<RadarrRequestCard />
|
||||
|
||||
{(data?.dropped ?? 0) > 0 ? (
|
||||
<Note tone="warn">
|
||||
{num(data?.dropped ?? 0)} events could not be queued for delivery. The queue is deliberately
|
||||
lossy — a slow endpoint must never hold up a television signing in — but a number growing here
|
||||
means a destination is not keeping up.
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : integrations.length === 0 && !draft ? (
|
||||
<Card title="Nothing configured" icon="plug" tone="note">
|
||||
<Empty>
|
||||
No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's
|
||||
settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here.
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
integrations.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
catalogue={catalogue}
|
||||
busy={busy}
|
||||
onEdit={() => edit(integration)}
|
||||
onTest={() => void test(integration)}
|
||||
onRemove={() => setConfirming(integration)}
|
||||
<>
|
||||
<Tiles
|
||||
tiles={[
|
||||
{ label: 'Integrations', value: num(services.length), icon: 'plug', tone: 'info' },
|
||||
{
|
||||
label: 'Not working',
|
||||
value: num(broken.length),
|
||||
icon: 'alert',
|
||||
tone: broken.length > 0 ? 'bad' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Running now',
|
||||
value: num(running.length),
|
||||
icon: 'pulse',
|
||||
tone: running.length > 0 ? 'ok' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Switched off',
|
||||
value: num(off.length),
|
||||
icon: 'power',
|
||||
tone: off.length > 0 ? 'warn' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Not configured',
|
||||
value: num(missing.length),
|
||||
icon: 'sliders',
|
||||
tone: missing.length > 0 ? 'note' : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))
|
||||
|
||||
{broken.length > 0 ? (
|
||||
<Note tone="bad">
|
||||
{broken.map((service) => service.name).join(', ')}{' '}
|
||||
{broken.length === 1 ? 'is not answering' : 'are not answering'}. A failed run also
|
||||
publishes an administrative event, so it is in the activity feed and wherever your
|
||||
webhooks send it — you did not have to be looking at this page.
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
title="Services"
|
||||
intro="Anything wrong sorts to the top. Open a service to switch it off, read its settings, or see everything it has done."
|
||||
icon="plug"
|
||||
tone="info"
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Integration</th>
|
||||
<th>Status</th>
|
||||
<th>Last run</th>
|
||||
<th>Result</th>
|
||||
<th>Next run</th>
|
||||
<th>Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.length === 0 ? (
|
||||
<EmptyRow columns={6}>This gateway has no integrations.</EmptyRow>
|
||||
) : (
|
||||
services.map((service) => <ServiceRow key={service.id} service={service} />)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Recent activity"
|
||||
intro="Every integration's work together and in order, which is what shows two of them getting in each other's way. This is what Memby attempted and what came of it — the Logs page is where the technical detail behind a failure lives."
|
||||
icon="history"
|
||||
tone="note"
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="nowrap">Started</th>
|
||||
<th>Integration</th>
|
||||
<th>Task</th>
|
||||
<th>Result</th>
|
||||
<th>Outcome</th>
|
||||
<th className="num">Took</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.length === 0 ? (
|
||||
<EmptyRow columns={6}>No integration has run yet.</EmptyRow>
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
const service = services.find((entry) => entry.id === run.integrationId);
|
||||
const task = service?.tasks.find((entry) => entry.id === run.taskId);
|
||||
return (
|
||||
<tr key={run.id}>
|
||||
<td className="nowrap muted" title={when(run.startedAt)}>
|
||||
{ago(run.startedAt)}
|
||||
</td>
|
||||
<td className="nowrap">
|
||||
<Link
|
||||
className="table-row-link"
|
||||
to={`/admin/integrations/${encodeURIComponent(run.integrationId ?? '')}`}
|
||||
>
|
||||
{service?.name ?? run.integrationId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="muted">{task?.name ?? run.taskId}</td>
|
||||
<td>
|
||||
<Tag tone={runTone(run.status)}>{run.status}</Tag>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{runOutcome(run)}
|
||||
{/* The figures go under the sentence rather than replacing it:
|
||||
the sentence says what happened and the numbers say how
|
||||
much, and a run that failed needs its reason above both. */}
|
||||
{run.error && countSummary(run.counts) ? (
|
||||
<span className="table-sub">{countSummary(run.counts)}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="num muted">{duration(run.durationMs)}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{draft ? (
|
||||
<DraftCard
|
||||
draft={draft}
|
||||
catalogue={catalogue}
|
||||
busy={busy === 'save'}
|
||||
onChange={setDraft}
|
||||
onSave={() => void save()}
|
||||
onCancel={() => setDraft(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{confirming ? (
|
||||
<Confirm
|
||||
title={`Remove ${confirming.name}?`}
|
||||
body="The webhook address and its delivery history go with it. Events already published stay in the activity feed."
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
busy={busy === 'remove'}
|
||||
onConfirm={() => void remove(confirming)}
|
||||
onCancel={() => setConfirming(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
const { data, error, loading, reload } = useQuery<SonarrRequestPolicy>('/admin/api/sonarr-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('sonarr-request-policy', async () => {
|
||||
await wrap(
|
||||
() => api.post('/admin/api/sonarr-request-policy', { qualityProfileId: profileId, searchImmediately }),
|
||||
'Sonarr TV request policy saved.',
|
||||
);
|
||||
await reload();
|
||||
});
|
||||
const selected = data?.profiles.find((profile) => profile.id === profileId);
|
||||
|
||||
function ServiceRow({ service }: { service: IntegrationService }) {
|
||||
const href = `/admin/integrations/${encodeURIComponent(service.id)}`;
|
||||
return (
|
||||
<Card
|
||||
title="Sonarr TV requests"
|
||||
intro="The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes 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 === 'sonarr-request-policy'} disabled={loading || profileId <= 0} onClick={() => void save()}>Save Sonarr policy</Button>}
|
||||
>
|
||||
<Banner message={error ?? data?.error ?? ''} />
|
||||
{loading ? <Loading rows={2} /> : (
|
||||
<>
|
||||
<div className="fields">
|
||||
<Field label="Request quality profile" hint="Memby stores this Sonarr 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 episodes immediately after request" hint="Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search." checked={searchImmediately} onChange={setSearchImmediately} />
|
||||
{selected ? <Note tone="info">Requested series will use <b>{selected.name}</b> (profile ID {selected.id}), be monitored using Memby’s existing all-episodes strategy, and {searchImmediately ? 'start an immediate search.' : 'not start an immediate search.'}</Note> : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
busy,
|
||||
onEdit,
|
||||
onTest,
|
||||
onRemove,
|
||||
}: {
|
||||
integration: Integration;
|
||||
catalogue: IntegrationEventOption[];
|
||||
busy: string | null;
|
||||
onEdit: () => void;
|
||||
onTest: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const health = integration.health;
|
||||
// "Is it working" is answered by the *last* attempt, not by a failure count: a webhook
|
||||
// that failed once an hour ago and has worked since is healthy.
|
||||
const healthy =
|
||||
!health.lastFailure || (health.lastSuccess && health.lastSuccess > health.lastFailure);
|
||||
const selected = integration.events ?? [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={integration.name}
|
||||
intro={integration.hint ? `Discord webhook ${integration.hint}` : 'Discord webhook'}
|
||||
icon="plug"
|
||||
tone={integration.enabled ? 'ok' : 'warn'}
|
||||
actions={
|
||||
<>
|
||||
{integration.enabled ? <Tag tone="ok">on</Tag> : <Tag tone="warn">off</Tag>}
|
||||
{health.deliveries > 0 ? (
|
||||
<Tag tone={healthy ? 'ok' : 'bad'}>{healthy ? 'delivering' : 'failing'}</Tag>
|
||||
) : (
|
||||
<Tag>never used</Tag>
|
||||
)}
|
||||
<Button size="sm" icon="pulse" busy={busy === `test:${integration.id}`} onClick={onTest}>
|
||||
Test
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" icon="trash" onClick={onRemove} title="Remove" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="list">
|
||||
<div className="list-item">
|
||||
<div className="list-body">
|
||||
<b>Events sent</b>
|
||||
<p>
|
||||
{selected.length === 0
|
||||
? 'None selected — this destination is configured but will never post anything.'
|
||||
: selected
|
||||
.map((type) => catalogue.find((entry) => entry.type === type)?.label ?? type)
|
||||
.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-item">
|
||||
<div className="list-body">
|
||||
<b>Last delivered</b>
|
||||
<p>{health.lastSuccess ? when(health.lastSuccess) : 'never'}</p>
|
||||
</div>
|
||||
<div className="list-actions">
|
||||
{health.deliveries > 0 ? (
|
||||
<span className="quiet">
|
||||
{num(health.deliveries)} attempts, {num(health.failures)} failed
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{health.lastFailure ? (
|
||||
<div className="list-item">
|
||||
<div className="list-body">
|
||||
<b>Last failure</b>
|
||||
<p>
|
||||
{when(health.lastFailure)}
|
||||
{health.lastError ? ` — ${health.lastError}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{integration.deliveries.length > 0 ? (
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="nowrap">Attempted</th>
|
||||
<th>Event</th>
|
||||
<th>Result</th>
|
||||
<th className="num">Took</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{integration.deliveries.map((delivery) => (
|
||||
<tr key={delivery.id}>
|
||||
<td className="nowrap muted" title={when(delivery.attemptedAt)}>
|
||||
{ago(delivery.attemptedAt)}
|
||||
</td>
|
||||
<td className="muted">{delivery.eventType}</td>
|
||||
<td>
|
||||
{delivery.success ? (
|
||||
<Tag tone="ok">{delivery.statusCode || 'ok'}</Tag>
|
||||
) : (
|
||||
<Tag tone="bad">{delivery.error || delivery.statusCode || 'failed'}</Tag>
|
||||
)}
|
||||
</td>
|
||||
<td className="num muted">{duration(delivery.durationMs)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
) : (
|
||||
<TableWrap>
|
||||
<table>
|
||||
<tbody>
|
||||
<EmptyRow columns={4}>Nothing has been delivered through this webhook yet.</EmptyRow>
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({
|
||||
draft,
|
||||
catalogue,
|
||||
busy,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
draft: Draft;
|
||||
catalogue: IntegrationEventOption[];
|
||||
busy: boolean;
|
||||
onChange: (next: Draft) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const groups = [...new Set(catalogue.map((entry) => entry.group))];
|
||||
const toggleEvent = (type: string, on: boolean) =>
|
||||
onChange({
|
||||
...draft,
|
||||
events: on ? [...draft.events, type] : draft.events.filter((entry) => entry !== type),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={draft.id ? `Edit ${draft.name}` : 'New Discord webhook'}
|
||||
icon="plug"
|
||||
tone="info"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="primary" busy={busy} onClick={onSave}>
|
||||
{draft.id ? 'Save' : 'Add'}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<span className="spacer" />
|
||||
{draft.events.length === 0 ? (
|
||||
<span className="quiet">Nothing selected — this destination would never post.</span>
|
||||
) : (
|
||||
<span className="quiet">{draft.events.length} events selected</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="fields">
|
||||
<Field label="Name" hint="What this destination is called in the console.">
|
||||
<input
|
||||
type="text"
|
||||
value={draft.name}
|
||||
onChange={(event) => onChange({ ...draft, name: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Webhook address"
|
||||
hint={
|
||||
draft.id
|
||||
? 'Leave blank to keep the address already saved — it is a credential and is never sent back to this page.'
|
||||
: 'Discord → channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.'
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={draft.url}
|
||||
placeholder={draft.id ? 'unchanged' : 'https://discord.com/api/webhooks/…'}
|
||||
onChange={(event) => onChange({ ...draft, url: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="Enabled"
|
||||
hint="Off keeps the configuration and stops the posts."
|
||||
checked={draft.enabled}
|
||||
onChange={(next) => onChange({ ...draft, enabled: next })}
|
||||
/>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group}>
|
||||
<div className="card-head" style={undefined}>
|
||||
<div className="card-head-text">
|
||||
<h2>{group}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{catalogue
|
||||
.filter((entry) => entry.group === group)
|
||||
.map((entry) => (
|
||||
<Toggle
|
||||
key={entry.type}
|
||||
label={entry.label}
|
||||
hint={entry.description}
|
||||
checked={draft.events.includes(entry.type)}
|
||||
onChange={(on) => toggleEvent(entry.type, on)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
<tr>
|
||||
{/* The name is the row's primary element and everything under it is quieter, so a
|
||||
column of services reads as a list of names rather than as paragraphs. */}
|
||||
<td>
|
||||
<Link className="table-row-link" to={href}>
|
||||
{service.name}
|
||||
</Link>
|
||||
<span className="table-sub">{service.address || service.summary}</span>
|
||||
</td>
|
||||
<td className="nowrap">
|
||||
<span className="row tight">
|
||||
<span className="dot-state" data-tone={integrationDot(service.status)} />
|
||||
<Tag tone={integrationTone(service.status)}>
|
||||
<span title={service.detail || service.statusLabel}>{service.statusLabel}</span>
|
||||
</Tag>
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="nowrap muted"
|
||||
title={service.lastRun ? when(service.lastRun.startedAt) : undefined}
|
||||
>
|
||||
{service.lastRun ? ago(service.lastRun.startedAt) : 'never'}
|
||||
</td>
|
||||
<td className="muted">
|
||||
{service.lastRun ? runOutcome(service.lastRun) : service.detail || '—'}
|
||||
</td>
|
||||
{/* A switched-off service still has a next run in the scheduler's records and it is
|
||||
not going to do anything, so the column says so rather than printing a time that
|
||||
will pass with nothing at the end of it. */}
|
||||
<td className="nowrap muted">
|
||||
{!service.configured ? '—' : !service.enabled ? 'off' : service.running ? 'now' : until(service.nextRun)}
|
||||
</td>
|
||||
<td className="nowrap">
|
||||
{!service.configured ? (
|
||||
<Tag>not configured</Tag>
|
||||
) : service.enabled ? (
|
||||
<Tag tone="ok">On</Tag>
|
||||
) : (
|
||||
<Tag tone="warn">Off</Tag>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -375,7 +375,16 @@ export function LogsPage() {
|
||||
const [dropped, setDropped] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [held, setHeld] = useState(0);
|
||||
const [filters, setFilters] = useState<LogFilters>(EMPTY_FILTERS);
|
||||
/* A ?q= in the address seeds the text filter once, so a page that has diagnosed
|
||||
something can hand the operator the logs already narrowed to it — which is what the
|
||||
Integrations area's "open the logs" link does with a failing service's name. Seeded
|
||||
on the initial state rather than in an effect: applying it later would fight whatever
|
||||
the operator had already typed, and the whole point of a deep link is that it is where
|
||||
they arrive rather than something that happens to them. */
|
||||
const [filters, setFilters] = useState<LogFilters>(() => {
|
||||
const seed = new URLSearchParams(window.location.search).get('q') ?? '';
|
||||
return seed ? { ...EMPTY_FILTERS, text: seed } : EMPTY_FILTERS;
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [viewport, setViewport] = useState({ top: 0, height: 600 });
|
||||
const [atTail, setAtTail] = useState(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useGateway } from '../lib/gateway';
|
||||
import { bytes, num, recent, when } from '../lib/format';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
EmptyRow,
|
||||
Grid,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Tag,
|
||||
Tiles,
|
||||
} from '../components/ui';
|
||||
import { RuntimeVerdict, trendWord, uptime } from '../components/runtime';
|
||||
import type { RuntimeStatus, ViewsReport } from '../api/types';
|
||||
|
||||
/* The page an operator lands on. It answers one question — is anything wrong — and hands
|
||||
@@ -48,7 +50,10 @@ export function OverviewPage() {
|
||||
const mdblist = status.mdblist;
|
||||
const forYou = status.forYou;
|
||||
const runs = (status.runs ?? []).slice(0, 5);
|
||||
const memory = runtime.data;
|
||||
const stats = runtime.data;
|
||||
const limited = stats
|
||||
? stats.memory.memoryLimit > 0 && stats.memory.memoryLimit < Number.MAX_SAFE_INTEGER
|
||||
: false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -158,6 +163,10 @@ export function OverviewPage() {
|
||||
intro="The services this gateway leans on, and whether they answered."
|
||||
icon="wrench"
|
||||
tone="note"
|
||||
/* Summarised here, managed there — the rule this whole page follows. Whether a
|
||||
service is switched on, what it last did and why it failed all live on the
|
||||
Integrations page, which is also the only place any of it can be changed. */
|
||||
actions={<Link to="/admin/integrations">Integrations</Link>}
|
||||
>
|
||||
<KeyValue
|
||||
rows={[
|
||||
@@ -241,23 +250,49 @@ export function OverviewPage() {
|
||||
intro="The container the gateway is served from."
|
||||
icon="chip"
|
||||
tone="info"
|
||||
actions={
|
||||
<Link to="/admin/runtime">
|
||||
<Button size="sm" variant="quiet" icon="external">
|
||||
Runtime details
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{memory ? (
|
||||
{/* This card used to print "25 goroutines" and leave it there, which is a number
|
||||
nobody can act on: it says nothing about what those goroutines are doing,
|
||||
which part of Memby they belong to, or whether it has been climbing all week.
|
||||
The verdict is the answer that count was standing in for, and it is the
|
||||
gateway's own — see server/internal/runtimestats. The detail behind it lives
|
||||
on the Runtime page rather than here, because this card is a signpost. */}
|
||||
{stats ? (
|
||||
<>
|
||||
<RuntimeVerdict health={stats.health} compact />
|
||||
<PlainTiles
|
||||
tiles={[
|
||||
{ label: 'goroutines', value: num(memory.goroutines) },
|
||||
{ label: 'heap in use', value: bytes(memory.heapInuse) },
|
||||
{ label: 'reserved', value: bytes(memory.sys) },
|
||||
{ label: 'collections', value: num(memory.numGc) },
|
||||
{
|
||||
label: `heap used · ${trendWord(stats.heapTrend, bytes)}`,
|
||||
value: bytes(stats.memory.heapInuse),
|
||||
},
|
||||
{
|
||||
label: 'of the process limit',
|
||||
value: limited
|
||||
? `${(stats.memory.heapShare * 100).toFixed(1)}%`
|
||||
: 'no limit',
|
||||
},
|
||||
{
|
||||
label: `goroutines · ${trendWord(stats.goroutineTrend, (value) => num(Math.round(value)))}`,
|
||||
value: num(stats.goroutines),
|
||||
},
|
||||
{ label: 'running for', value: uptime(stats.uptimeSeconds) },
|
||||
]}
|
||||
/>
|
||||
<p className="hint">
|
||||
Next collection at {bytes(memory.nextGc)} · memory limit{' '}
|
||||
{memory.memoryLimit > 0 && memory.memoryLimit < Number.MAX_SAFE_INTEGER
|
||||
? `${bytes(memory.memoryLimit)}${memory.configuredLimit ? ' (GOMEMLIMIT)' : ''}`
|
||||
: 'no limit set'}{' '}
|
||||
· {memory.gomaxprocs} processors available.
|
||||
Heap in use is what the gateway is holding;{' '}
|
||||
{bytes(stats.memory.sys)} is reserved from the operating system on its
|
||||
behalf, which is always larger and is not a leak.
|
||||
{limited
|
||||
? ` The limit is ${bytes(stats.memory.memoryLimit)}${stats.memory.configuredLimit ? ' (GOMEMLIMIT)' : ''}.`
|
||||
: ' No memory limit is set.'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,7 +3,6 @@ import { api } from '../api/client';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { num } from '../lib/format';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -12,9 +11,7 @@ import {
|
||||
Field,
|
||||
Grid,
|
||||
Loading,
|
||||
PageHead,
|
||||
Tag,
|
||||
Tiles,
|
||||
Toggle,
|
||||
} from '../components/ui';
|
||||
|
||||
@@ -38,7 +35,18 @@ const sourceNames: Record<string, string> = {
|
||||
score_average: 'MDBList Average',
|
||||
};
|
||||
|
||||
export function RatingsPage() {
|
||||
/* MDBList's settings.
|
||||
*
|
||||
* Exported as a card group rather than kept as a page: the movie-ratings integration now
|
||||
* lives in the Integrations area, beside its health, its switch and its run history, and
|
||||
* the old /admin/ratings address redirects there. Splitting it out is what let it move
|
||||
* without being rewritten — this is the same form it always was, in a different room.
|
||||
*
|
||||
* The whole thing is written around one property of the backend, and it is worth stating
|
||||
* because the form would otherwise look careless: the API key is never returned. What
|
||||
* comes back is whether one is saved, which is why the field is blank with a placeholder
|
||||
* saying so, and why saving it blank leaves the stored key alone. */
|
||||
export function RatingsSettingsCard() {
|
||||
const { status, error, loading, reload } = useGateway();
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
@@ -80,28 +88,16 @@ export function RatingsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Movie ratings" intro="Optional MDBList scores on films and shows." />
|
||||
<Banner message={error} />
|
||||
|
||||
{loading || !mdblist ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
<Tiles
|
||||
tiles={[
|
||||
{ label: 'titles stored', value: num(cached), icon: 'database', tone: 'data' },
|
||||
{ label: 'due to be re-checked', value: num(mdblist.staleTitles), icon: 'sync', tone: 'warn' },
|
||||
{ label: 'sources shown', value: num((mdblist.sources ?? []).length), icon: 'star', tone: 'note' },
|
||||
{
|
||||
label: 'API key',
|
||||
value: mdblist.apiKeyConfigured ? 'saved' : 'not set',
|
||||
small: true,
|
||||
icon: 'key',
|
||||
tone: mdblist.apiKeyConfigured ? 'ok' : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* The tiles that were here — titles stored, titles due, whether a key is
|
||||
saved — are on the integration page above this card now, where they sit
|
||||
beside the same facts for every other service. Repeating them would be the
|
||||
same four numbers twice on one screen. */}
|
||||
<Grid cols="2">
|
||||
<Card
|
||||
title="MDBList connection"
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useQuery } from '../lib/hooks';
|
||||
import { api } from '../api/client';
|
||||
import { bytes, num, when } from '../lib/format';
|
||||
import {
|
||||
Banner,
|
||||
Bars,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
EmptyRow,
|
||||
Grid,
|
||||
KeyValue,
|
||||
Loading,
|
||||
Meter,
|
||||
Note,
|
||||
PageHead,
|
||||
PlainTiles,
|
||||
Subhead,
|
||||
TableWrap,
|
||||
Tag,
|
||||
Tiles,
|
||||
} from '../components/ui';
|
||||
import { RuntimeVerdict, trendWord, uptime } from '../components/runtime';
|
||||
import type { GoroutineReport, RuntimeStatus, RuntimeWorker } from '../api/types';
|
||||
|
||||
/* Where the detail that would clutter the overview lives.
|
||||
*
|
||||
* The overview's Process card answers "is anything wrong". This page answers "what is it,
|
||||
* then" — and the split is by cost as much as by clutter. Everything above the breakdown
|
||||
* is the same cheap snapshot the overview polls; the breakdown underneath walks every
|
||||
* goroutine's stack, which stops the world, so it is a button rather than a poll. Nothing
|
||||
* on this page is editable, the stance every insights page in the console takes. */
|
||||
|
||||
const componentTone = (component: string): 'data' | 'note' | 'info' | 'warn' =>
|
||||
component === 'Unattributed' ? 'warn' : component === 'Go runtime' ? 'note' : 'info';
|
||||
|
||||
export function RuntimePage() {
|
||||
const runtime = useQuery<RuntimeStatus>('/admin/api/runtime', { pollMs: 30_000 });
|
||||
const [report, setReport] = useState<GoroutineReport>();
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
const [breakdownError, setBreakdownError] = useState('');
|
||||
|
||||
// Asked for, never polled. The result is kept on screen with the time it was taken
|
||||
// beside it, because a breakdown whose age is not stated is one an operator will read as
|
||||
// current an hour later.
|
||||
const collect = useCallback(async () => {
|
||||
setCollecting(true);
|
||||
setBreakdownError('');
|
||||
try {
|
||||
setReport(await api.get<GoroutineReport>('/admin/api/runtime/goroutines'));
|
||||
} catch (err) {
|
||||
setBreakdownError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCollecting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (runtime.loading || !runtime.data) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Runtime" intro="What the gateway process is doing." />
|
||||
<Banner message={runtime.error} />
|
||||
<Loading />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const data = runtime.data;
|
||||
const memory = data.memory;
|
||||
const samples = data.samples ?? [];
|
||||
const workers = data.workers ?? [];
|
||||
const running = workers.filter((worker) => worker.state === 'running').length;
|
||||
const limited = memory.memoryLimit > 0 && memory.memoryLimit < Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Runtime"
|
||||
intro="What the gateway process is doing: the work it is holding open, the memory it is using, and how both have moved."
|
||||
/>
|
||||
<Banner message={runtime.error} />
|
||||
|
||||
<RuntimeVerdict health={data.health} />
|
||||
|
||||
<Tiles
|
||||
tiles={[
|
||||
{
|
||||
label: `goroutines · ${trendWord(data.goroutineTrend, (value) => num(Math.round(value)))}`,
|
||||
value: num(data.goroutines),
|
||||
icon: 'pulse',
|
||||
tone: data.goroutineTrend.direction === 'rising' ? 'warn' : 'info',
|
||||
},
|
||||
{ label: 'named workers running', value: num(running), icon: 'clock', tone: 'note' },
|
||||
{
|
||||
label: `heap in use · ${trendWord(data.heapTrend, bytes)}`,
|
||||
value: bytes(memory.heapInuse),
|
||||
icon: 'chip',
|
||||
tone: memory.heapShare >= 0.75 ? 'warn' : 'data',
|
||||
},
|
||||
{ label: 'running for', value: uptime(data.uptimeSeconds), icon: 'history', tone: 'data' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid cols="2">
|
||||
<Card
|
||||
title="Memory"
|
||||
intro="Three different figures that are routinely confused. Heap in use is what the gateway is actually holding. Reserved is what the Go runtime has taken from the operating system on its behalf — always larger, and not a leak. The limit is the one the container is stopped at."
|
||||
icon="chip"
|
||||
tone="data"
|
||||
>
|
||||
{limited ? (
|
||||
<>
|
||||
<Meter
|
||||
value={memory.heapInuse}
|
||||
total={memory.memoryLimit}
|
||||
tone={memory.heapShare >= 0.9 ? 'bad' : memory.heapShare >= 0.75 ? 'warn' : 'ok'}
|
||||
/>
|
||||
<p className="hint">
|
||||
The heap is using {(memory.heapShare * 100).toFixed(1)}% of the{' '}
|
||||
{bytes(memory.memoryLimit)} limit
|
||||
{memory.configuredLimit ? ` set by GOMEMLIMIT=${memory.configuredLimit}` : ''}.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<Note tone="warn">
|
||||
No memory limit is set, so the Go runtime will grow until the container is
|
||||
killed by the host. Set GOMEMLIMIT to give it a ceiling to collect against.
|
||||
</Note>
|
||||
)}
|
||||
<KeyValue
|
||||
rows={[
|
||||
{ label: 'Heap currently used', value: bytes(memory.heapInuse) },
|
||||
{ label: 'Heap allocated to live objects', value: bytes(memory.heapAlloc) },
|
||||
{ label: 'Heap held but idle', value: bytes(memory.heapIdle) },
|
||||
{ label: 'Returned to the operating system', value: bytes(memory.heapReleased) },
|
||||
{ label: 'Goroutine stacks', value: bytes(memory.stackInuse) },
|
||||
{ label: 'Runtime / system reserved', value: bytes(memory.sys) },
|
||||
{
|
||||
label: 'Configured process limit',
|
||||
value: limited ? bytes(memory.memoryLimit) : 'none',
|
||||
},
|
||||
{ label: 'Next collection at', value: bytes(memory.nextGc) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Collection"
|
||||
intro="How often the garbage collector runs and how long it stops the gateway for. Everything the televisions ask for waits during a pause, so this is the figure that turns a memory problem into a slow one."
|
||||
icon="refresh"
|
||||
tone="note"
|
||||
>
|
||||
<PlainTiles
|
||||
tiles={[
|
||||
{ label: 'collections', value: num(memory.numGc) },
|
||||
{
|
||||
label: 'collections an hour',
|
||||
value: memory.gcPerHour > 0 ? memory.gcPerHour.toFixed(0) : '—',
|
||||
},
|
||||
{ label: 'recent pause', value: `${memory.pauseRecentMs.toFixed(1)} ms` },
|
||||
{
|
||||
label: 'paused in total',
|
||||
value: `${(memory.pauseTotalMs / 1000).toFixed(1)} s`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<KeyValue
|
||||
rows={[
|
||||
{ label: 'Processors available', value: num(data.gomaxprocs) },
|
||||
{
|
||||
label: 'Operating-system threads',
|
||||
value: num(data.threads),
|
||||
},
|
||||
{
|
||||
label: 'Processor use',
|
||||
value: data.process.cpuKnown
|
||||
? `${(data.process.cpuPercent ?? 0).toFixed(1)}% of one processor · ${(data.process.cpuSeconds ?? 0).toFixed(0)}s used in total`
|
||||
: 'not available on this host',
|
||||
},
|
||||
{
|
||||
label: 'Open connections',
|
||||
value: data.process.filesKnown
|
||||
? `${num(data.process.openSockets)} sockets of ${num(data.process.openFiles)} open files${
|
||||
data.process.fileLimit ? ` · limit ${num(data.process.fileLimit)}` : ''
|
||||
}`
|
||||
: 'not available on this host',
|
||||
},
|
||||
{ label: 'Go version', value: data.goVersion },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid cols="2">
|
||||
<Card
|
||||
title="Goroutines over time"
|
||||
intro="A single instantaneous count cannot show a leak. This can: a line that climbs and never comes back down is work the gateway is not letting go of."
|
||||
icon="chart"
|
||||
tone="info"
|
||||
>
|
||||
<TrendChart
|
||||
samples={samples}
|
||||
valueOf={(sample) => sample.goroutines}
|
||||
format={(value) => num(value)}
|
||||
everySeconds={data.sampleEverySeconds}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Memory over time"
|
||||
intro="Heap in use, sampled on the same tick. A saw-tooth that returns to roughly the same floor after each collection is healthy; a floor that keeps rising is not."
|
||||
icon="chart"
|
||||
tone="data"
|
||||
>
|
||||
<TrendChart
|
||||
samples={samples}
|
||||
valueOf={(sample) => sample.heapInuse}
|
||||
format={bytes}
|
||||
everySeconds={data.sampleEverySeconds}
|
||||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Card
|
||||
title="Background workers"
|
||||
intro="Memby's own long-running work, named where it is started rather than guessed at from a stack. A worker that has finished is not necessarily a fault — several are one-shot startup jobs — but one that keeps being started again is failing at something."
|
||||
icon="clock"
|
||||
tone="note"
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Area</th>
|
||||
<th>State</th>
|
||||
<th>Since</th>
|
||||
<th className="num">Starts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.length === 0 ? (
|
||||
<EmptyRow columns={5}>
|
||||
Nothing has registered. This gateway predates named workers.
|
||||
</EmptyRow>
|
||||
) : (
|
||||
workers.map((worker) => <WorkerRow key={worker.name} worker={worker} />)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Goroutine breakdown"
|
||||
intro="What those goroutines are actually doing, and which part of Memby they belong to. Collecting it means walking every stack, which stops the gateway for a few milliseconds — so it is taken when you ask rather than continuously."
|
||||
icon="search"
|
||||
tone="info"
|
||||
>
|
||||
<div className="row">
|
||||
<Button onClick={collect} busy={collecting} icon="refresh" variant="primary">
|
||||
{report ? 'Collect again' : 'Collect breakdown'}
|
||||
</Button>
|
||||
<a href="/admin/api/runtime/goroutines?format=text" download>
|
||||
<Button icon="download" variant="quiet">
|
||||
Download full stack dump
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
<Banner message={breakdownError} />
|
||||
{report ? (
|
||||
<Breakdown report={report} />
|
||||
) : (
|
||||
<Empty>
|
||||
Nothing collected yet. The snapshot is a moment in time, so it is taken on
|
||||
request and stamped with when it was taken.
|
||||
</Empty>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkerRow({ worker }: { worker: RuntimeWorker }) {
|
||||
const finished = worker.state === 'finished';
|
||||
return (
|
||||
<tr>
|
||||
<td>{worker.name}</td>
|
||||
<td>
|
||||
<Tag tone="info">{worker.component}</Tag>
|
||||
</td>
|
||||
<td>
|
||||
<Tag tone={finished ? 'note' : 'ok'}>{finished ? 'finished' : 'running'}</Tag>
|
||||
</td>
|
||||
<td>{when(finished ? worker.stopped : worker.started)}</td>
|
||||
<td className="num">{worker.starts > 1 ? <Tag tone="warn">{worker.starts}</Tag> : worker.starts}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** TrendChart is the console's existing bar vocabulary rather than a chart library: the
|
||||
* question is only ever "is this line going up", which bars answer, and the console's
|
||||
* no-dependency rule is worth more here than a smooth curve. */
|
||||
function TrendChart<T extends { at: string }>({
|
||||
samples,
|
||||
valueOf,
|
||||
format,
|
||||
everySeconds,
|
||||
}: {
|
||||
samples: T[];
|
||||
valueOf: (sample: T) => number;
|
||||
format: (value: number) => string;
|
||||
everySeconds: number;
|
||||
}) {
|
||||
if (samples.length < 2) {
|
||||
return (
|
||||
<Empty>
|
||||
Not enough history yet. Readings are taken every{' '}
|
||||
{everySeconds >= 60 ? `${Math.round(everySeconds / 60)} minutes` : `${everySeconds}s`},
|
||||
and a trend needs about fifteen minutes of them.
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
const label = (sample: T) => new Date(sample.at).toLocaleTimeString();
|
||||
return (
|
||||
<Bars
|
||||
data={samples}
|
||||
labelOf={(sample: never) => label(sample)}
|
||||
valueOf={(sample: never) => valueOf(sample)}
|
||||
title={(sample: never) => `${label(sample)}: ${format(valueOf(sample))}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({ report }: { report: GoroutineReport }) {
|
||||
const categories = report.categories ?? [];
|
||||
const components = report.components ?? [];
|
||||
const groups = report.groups ?? [];
|
||||
return (
|
||||
<>
|
||||
<p className="hint">
|
||||
{num(report.total)} goroutines, collected {when(report.at)} in{' '}
|
||||
{report.collectedInMs.toFixed(1)} ms.
|
||||
</p>
|
||||
|
||||
<Grid cols="2">
|
||||
<div>
|
||||
<Subhead>By what they are doing</Subhead>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>State</th>
|
||||
<th className="num">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((category) => (
|
||||
<tr key={category.category}>
|
||||
<td>
|
||||
<b>{category.label}</b>
|
||||
<p className="hint">{category.description}</p>
|
||||
{/* The runtime's own state names are kept, quietly, underneath the
|
||||
readable label: they are meaningless to most operators and are
|
||||
the exact term to search for when one is not. */}
|
||||
<p className="hint">
|
||||
{(category.states ?? [])
|
||||
.map((state) => `${state.state} (${state.count})`)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</td>
|
||||
<td className="num">{num(category.count)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Subhead>By which part of Memby</Subhead>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Area</th>
|
||||
<th className="num">Count</th>
|
||||
<th className="num">Oldest</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{components.map((component) => (
|
||||
<tr key={component.component}>
|
||||
<td>
|
||||
<Tag tone={componentTone(component.component)}>{component.component}</Tag>
|
||||
</td>
|
||||
<td className="num">{num(component.count)}</td>
|
||||
<td className="num">
|
||||
{component.longestWaitMinutes > 0
|
||||
? `${num(component.longestWaitMinutes)} min`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
<Subhead
|
||||
aside={
|
||||
report.groupsTotal > groups.length
|
||||
? `the ${groups.length} largest of ${num(report.groupsTotal)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Where they are waiting
|
||||
</Subhead>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="num">Count</th>
|
||||
<th>Area</th>
|
||||
<th>State</th>
|
||||
<th>Function</th>
|
||||
<th className="num">Oldest</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.length === 0 ? (
|
||||
<EmptyRow columns={5}>Nothing to group.</EmptyRow>
|
||||
) : (
|
||||
groups.map((group, index) => (
|
||||
<tr key={`${group.component}-${group.function}-${group.state}-${index}`}>
|
||||
<td className="num">{num(group.count)}</td>
|
||||
<td>
|
||||
<Tag tone={componentTone(group.component)}>{group.component}</Tag>
|
||||
</td>
|
||||
<td>{group.state}</td>
|
||||
<td>
|
||||
<span className="mono">{group.function}</span>
|
||||
<p className="hint">{group.file}</p>
|
||||
</td>
|
||||
<td className="num">
|
||||
{group.longestWaitMinutes > 0 ? `${num(group.longestWaitMinutes)} min` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { ago, duration, interval, num, until, when } from '../lib/format';
|
||||
import { cadenceChoices, retimed, runTone, taskStatus } from '../lib/tasks';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
EmptyRow,
|
||||
Field,
|
||||
Loading,
|
||||
Note,
|
||||
PageHead,
|
||||
TableWrap,
|
||||
Tag,
|
||||
Tiles,
|
||||
Toggle,
|
||||
} from '../components/ui';
|
||||
import type { ScheduledTask, TasksResponse } from '../api/types';
|
||||
|
||||
/* One scheduled task.
|
||||
*
|
||||
* The list is a directory and this is where a task is actually operated on: its cadence,
|
||||
* its switch, and its own run history rather than the whole gateway's. That split is the
|
||||
* Users/User one, and it is what let the list become a table an operator can scan — a
|
||||
* select and two switches per row is exactly the clutter that made forty tasks unreadable.
|
||||
*
|
||||
* It reads the same /admin/api/tasks the list does, with `task=` set, which is why there
|
||||
* is no second endpoint behind this page: the response already carries every task (so the
|
||||
* page can find the one it is about, and print it with the same fields the list used) and
|
||||
* the `task` parameter narrows only the run history. The limit is higher than the list's
|
||||
* because history is the whole reason somebody comes here. */
|
||||
|
||||
const IDLE_POLL_MS = 20_000;
|
||||
const BUSY_POLL_MS = 3_000;
|
||||
const HISTORY_LIMIT = 100;
|
||||
|
||||
/** What the history says about the task, as opposed to what its last run says.
|
||||
*
|
||||
* A task that fails one run in twenty and a task that has failed every run since Tuesday
|
||||
* look identical from a status column, and the second is the one worth being told about.
|
||||
* Averaged over the window the page holds rather than over all time, because that is the
|
||||
* only thing it has — and it says how many runs it is averaging, so a figure drawn from
|
||||
* three runs cannot be mistaken for a settled one. */
|
||||
function summarise(runs: { status: string; durationMs: number }[]) {
|
||||
const finished = runs.filter((run) => run.status !== 'running');
|
||||
const failed = finished.filter((run) => run.status === 'failed').length;
|
||||
const timed = finished.filter((run) => run.durationMs > 0);
|
||||
const averageMs = timed.length
|
||||
? timed.reduce((total, run) => total + run.durationMs, 0) / timed.length
|
||||
: 0;
|
||||
return { runs: finished.length, failed, averageMs, timed: timed.length };
|
||||
}
|
||||
|
||||
export function TaskPage() {
|
||||
const { taskId = '' } = useParams();
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
const [fast, setFast] = useState(false);
|
||||
|
||||
const { data, error, loading, reload } = useQuery<TasksResponse>(
|
||||
`/admin/api/tasks?task=${encodeURIComponent(taskId)}&limit=${HISTORY_LIMIT}`,
|
||||
{ pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS, enabled: Boolean(taskId) },
|
||||
);
|
||||
|
||||
const task = data?.tasks.find((entry) => entry.id === taskId);
|
||||
const running = Boolean(task?.running);
|
||||
if (running !== fast) setFast(running);
|
||||
|
||||
const runs = data?.runs ?? [];
|
||||
const status = task ? taskStatus(task) : undefined;
|
||||
const summary = summarise(runs);
|
||||
|
||||
const act = (key: string, message: string, body: Record<string, unknown>) =>
|
||||
run(key, async () => {
|
||||
await wrap(() => api.put(`/admin/api/tasks/${encodeURIComponent(taskId)}`, body), message);
|
||||
await reload();
|
||||
});
|
||||
|
||||
const runNow = (subject: ScheduledTask) =>
|
||||
run('run', async () => {
|
||||
await wrap(
|
||||
() => api.post(`/admin/api/tasks/${encodeURIComponent(taskId)}/run`),
|
||||
`${subject.name} started.`,
|
||||
);
|
||||
await reload();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={task?.name || taskId}
|
||||
intro={task?.description}
|
||||
icon="clock"
|
||||
crumbs={
|
||||
<>
|
||||
<Link to="/admin/tasks">Scheduled tasks</Link>
|
||||
<span>/</span>
|
||||
<span>{task?.name || taskId}</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
task ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="play"
|
||||
busy={busy === 'run'}
|
||||
disabled={task.running}
|
||||
onClick={() => void runNow(task)}
|
||||
>
|
||||
{task.running ? 'Running' : 'Run now'}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Banner message={error} />
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : !task ? (
|
||||
/* A task id that no longer exists is an ordinary thing to arrive at — a bookmark,
|
||||
or a job removed in a deployment — so it is stated rather than left as an empty
|
||||
page, and the way back is named. */
|
||||
<Card title="No such task" icon="alert" tone="bad">
|
||||
<Empty>
|
||||
This gateway has no task called <code>{taskId}</code>. It may have been renamed or removed.{' '}
|
||||
<Link to="/admin/tasks">Back to scheduled tasks</Link>.
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Tiles
|
||||
tiles={[
|
||||
{
|
||||
label: 'Status',
|
||||
value: status?.label ?? '—',
|
||||
small: true,
|
||||
icon: 'pulse',
|
||||
tone: status?.tone,
|
||||
},
|
||||
{ label: 'Service', value: task.group || 'Other', small: true, icon: 'chip', tone: 'info' },
|
||||
{
|
||||
label: 'Runs every',
|
||||
value: interval(task.intervalSeconds),
|
||||
small: true,
|
||||
icon: 'clock',
|
||||
tone: retimed(task) ? 'note' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Next run',
|
||||
value: task.enabled ? (task.running ? 'now' : until(task.nextRun)) : 'not scheduled',
|
||||
small: true,
|
||||
icon: 'history',
|
||||
tone: task.enabled ? undefined : 'warn',
|
||||
},
|
||||
{
|
||||
label: `Failed of the last ${num(summary.runs)}`,
|
||||
value: num(summary.failed),
|
||||
icon: 'alert',
|
||||
tone: summary.failed > 0 ? 'bad' : undefined,
|
||||
},
|
||||
// Averaged only over runs that recorded a duration, and the label says so:
|
||||
// a mean that quietly counted skipped runs as instant would understate
|
||||
// every job whose ordinary answer is "nothing to do".
|
||||
{
|
||||
label: `Average of ${num(summary.timed)} timed runs`,
|
||||
value: summary.timed ? duration(Math.round(summary.averageMs)) : '—',
|
||||
small: true,
|
||||
icon: 'chart',
|
||||
tone: 'data',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{task.lastRun?.error ? (
|
||||
<Note tone="bad">
|
||||
The last run failed: {task.lastRun.error}
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
title="Schedule"
|
||||
intro="How often the gateway runs this on its own, and whether it runs it at all."
|
||||
icon="sliders"
|
||||
tone="info"
|
||||
>
|
||||
<div className="row">
|
||||
{/* Disabled while a run is in flight: changing the cadence reschedules from
|
||||
now, and doing that underneath a running job is how one run silently
|
||||
becomes two. */}
|
||||
<Field label="How often it runs">
|
||||
<select
|
||||
aria-label={`How often ${task.name} runs`}
|
||||
value={task.intervalSeconds}
|
||||
disabled={busy === 'interval' || task.running}
|
||||
onChange={(event) =>
|
||||
void act(
|
||||
'interval',
|
||||
`${task.name} now runs ${interval(Number(event.target.value))}.`,
|
||||
{ intervalSeconds: Number(event.target.value) },
|
||||
)
|
||||
}
|
||||
>
|
||||
{cadenceChoices(task).map((choice) => (
|
||||
<option key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
{choice.value === task.defaultIntervalSeconds ? ' (default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{/* Sending zero is how the API is told to forget an override, so this is a
|
||||
separate call from the select rather than an option inside it — see
|
||||
cadenceChoices. */}
|
||||
{retimed(task) ? (
|
||||
<Button
|
||||
icon="refresh"
|
||||
busy={busy === 'interval'}
|
||||
disabled={task.running}
|
||||
onClick={() =>
|
||||
void act('interval', `${task.name} back to its default cadence.`, {
|
||||
intervalSeconds: 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
Back to {interval(task.defaultIntervalSeconds)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="Run this on its schedule"
|
||||
hint="Switched off, the gateway leaves it alone. You can still start it by hand."
|
||||
checked={task.enabled}
|
||||
disabled={busy === 'enabled'}
|
||||
onChange={(next) =>
|
||||
void act(
|
||||
'enabled',
|
||||
next ? `${task.name} switched on.` : `${task.name} switched off.`,
|
||||
{ enabled: next },
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{retimed(task) ? (
|
||||
<Note tone="note">
|
||||
This task is retimed: its code asks for {interval(task.defaultIntervalSeconds)} and it is
|
||||
set to {interval(task.intervalSeconds)}.
|
||||
</Note>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Run history"
|
||||
intro="This task alone, newest first — which is what separates a job that fails occasionally from one that has stopped working."
|
||||
icon="history"
|
||||
tone="note"
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="nowrap">Started</th>
|
||||
<th>Trigger</th>
|
||||
<th>Result</th>
|
||||
<th className="num">Took</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.length === 0 ? (
|
||||
<EmptyRow columns={5}>This task has not run yet.</EmptyRow>
|
||||
) : (
|
||||
runs.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="nowrap muted" title={when(entry.startedAt)}>
|
||||
{ago(entry.startedAt)}
|
||||
</td>
|
||||
<td className="muted">{entry.trigger}</td>
|
||||
{/* The tag alone here, with no dot beside it. The status column on
|
||||
the list is scanned across forty unrelated rows and earns the
|
||||
second signal; this is one task's own history, where every row
|
||||
is already about the same thing. */}
|
||||
<td>
|
||||
<Tag tone={runTone(entry.status)}>{entry.status}</Tag>
|
||||
</td>
|
||||
<td className="num muted">{duration(entry.durationMs)}</td>
|
||||
<td className="muted">{entry.error || entry.detail || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+128
-195
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { ago, duration, interval, num, when } from '../lib/format';
|
||||
import { ago, duration, interval, num, until, when } from '../lib/format';
|
||||
import { compareTasks, retimed, runTone, statusDot, taskStatus } from '../lib/tasks';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -14,69 +16,29 @@ import {
|
||||
TableWrap,
|
||||
Tag,
|
||||
Tiles,
|
||||
Toggle,
|
||||
} from '../components/ui';
|
||||
import type { ScheduledTask, TaskRun, TasksResponse } from '../api/types';
|
||||
import type { Tone } from '../lib/format';
|
||||
import type { ScheduledTask, TasksResponse } from '../api/types';
|
||||
|
||||
/* Scheduled tasks: what the gateway does when nobody is watching.
|
||||
*
|
||||
* Polled faster than the console's own heartbeat while a task is running, because the one
|
||||
* thing an operator does here is press Run now and then watch for the outcome — and a
|
||||
* thirty-second poll makes a two-second job look like one that did nothing. */
|
||||
* A directory, and — following the Users page it is now shaped like — very nearly only a
|
||||
* directory. It was a stack of cards holding a paragraph, a select and two switches per
|
||||
* task, which meant the one question this page is opened with, "is anything wrong", had to
|
||||
* be answered by reading every entry in turn. One row per task answers it by scanning a
|
||||
* column, and everything you can *do* to a task beyond starting it lives on the task's own
|
||||
* page.
|
||||
*
|
||||
* Run now is the exception that stays in the row. It is the only action here that is not a
|
||||
* change of configuration — it asks for something to happen once, and it is what an
|
||||
* operator comes to this page to press.
|
||||
*
|
||||
* Polled faster than the console's own heartbeat while a task is running, because having
|
||||
* pressed it the next thing they do is watch for the outcome, and a thirty-second poll
|
||||
* makes a two-second job look like one that did nothing. */
|
||||
|
||||
const IDLE_POLL_MS = 20_000;
|
||||
const BUSY_POLL_MS = 3_000;
|
||||
|
||||
/* The cadences an operator may choose from.
|
||||
*
|
||||
* A fixed list rather than a free-text duration, because the useful range here spans three
|
||||
* orders of magnitude and the two ways to get it wrong are both silent: a number typed in
|
||||
* the wrong unit, and a cadence so tight the job never finishes before it is due again. The
|
||||
* floor matches the scheduler's own — it clamps anything under a minute — so the console
|
||||
* cannot offer a value the server would quietly change underneath it.
|
||||
*
|
||||
* Zero is absent on purpose. The API reads it as "restore the declared cadence" rather than
|
||||
* as "never", so a "run by hand only" entry here would appear to do nothing on any task that
|
||||
* declares an interval. That is a server-side limitation and it belongs in the server, not
|
||||
* in a control that lies about it. */
|
||||
const CADENCE_CHOICES: { value: number; label: string }[] = [
|
||||
{ value: 60, label: 'Every minute' },
|
||||
{ value: 300, label: 'Every 5 minutes' },
|
||||
{ value: 600, label: 'Every 10 minutes' },
|
||||
{ value: 900, label: 'Every 15 minutes' },
|
||||
{ value: 1_800, label: 'Every 30 minutes' },
|
||||
{ value: 3_600, label: 'Hourly' },
|
||||
{ value: 10_800, label: 'Every 3 hours' },
|
||||
{ value: 21_600, label: 'Every 6 hours' },
|
||||
{ value: 43_200, label: 'Every 12 hours' },
|
||||
{ value: 86_400, label: 'Daily' },
|
||||
{ value: 604_800, label: 'Weekly' },
|
||||
];
|
||||
|
||||
/* The choices for one task: the presets, plus its own declared cadence and whatever it is
|
||||
* currently set to if either falls outside the list.
|
||||
*
|
||||
* Adding them rather than snapping to the nearest preset is what stops the control being
|
||||
* destructive to look at — a task declaring 45 minutes must not silently become hourly
|
||||
* because somebody opened the page and the select had to show *something*. */
|
||||
function cadenceChoices(task: ScheduledTask): { value: number; label: string }[] {
|
||||
const choices = [...CADENCE_CHOICES];
|
||||
for (const seconds of [task.defaultIntervalSeconds, task.intervalSeconds]) {
|
||||
if (seconds > 0 && !choices.some((choice) => choice.value === seconds)) {
|
||||
choices.push({ value: seconds, label: interval(seconds).replace(/^every /, 'Every ') });
|
||||
}
|
||||
}
|
||||
return choices.sort((a, b) => a.value - b.value);
|
||||
}
|
||||
|
||||
function statusTone(status: TaskRun['status']): Tone {
|
||||
if (status === 'failed') return 'bad';
|
||||
if (status === 'running') return 'info';
|
||||
if (status === 'skipped') return 'warn';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function TasksPage() {
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
@@ -98,45 +60,11 @@ export function TasksPage() {
|
||||
await reload();
|
||||
});
|
||||
|
||||
const setEnabled = (task: ScheduledTask, enabled: boolean) =>
|
||||
run(`${task.id}:enabled`, async () => {
|
||||
await wrap(
|
||||
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { enabled }),
|
||||
enabled ? `${task.name} switched on.` : `${task.name} switched off.`,
|
||||
);
|
||||
await reload();
|
||||
});
|
||||
|
||||
// Named setCadence rather than setInterval so it cannot shadow the global of that
|
||||
// name inside this component, which is a trap for anything added here later.
|
||||
const setCadence = (task: ScheduledTask, intervalSeconds: number) =>
|
||||
run(`${task.id}:interval`, async () => {
|
||||
await wrap(
|
||||
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds }),
|
||||
`${task.name} now runs ${interval(intervalSeconds)}.`,
|
||||
);
|
||||
await reload();
|
||||
});
|
||||
|
||||
// Sending zero is how the API is told to forget an override, so this is a separate call
|
||||
// from the select rather than an option inside it — see CADENCE_CHOICES.
|
||||
const resetCadence = (task: ScheduledTask) =>
|
||||
run(`${task.id}:interval`, async () => {
|
||||
await wrap(
|
||||
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds: 0 }),
|
||||
`${task.name} back to its default cadence.`,
|
||||
);
|
||||
await reload();
|
||||
});
|
||||
|
||||
const failures = tasks.filter((task) => task.lastRun?.status === 'failed').length;
|
||||
const retimed = tasks.filter(
|
||||
(task) => task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds,
|
||||
).length;
|
||||
const offSchedule = tasks.filter(retimed).length;
|
||||
const disabled = tasks.filter((task) => !task.enabled).length;
|
||||
|
||||
const groups = data?.groups ?? [];
|
||||
const ungrouped = tasks.filter((task) => !task.group);
|
||||
const rows = [...tasks].sort(compareTasks);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -177,9 +105,9 @@ export function TasksPage() {
|
||||
// cadence currently in force.
|
||||
{
|
||||
label: 'Retimed',
|
||||
value: num(retimed),
|
||||
value: num(offSchedule),
|
||||
icon: 'clock',
|
||||
tone: retimed > 0 ? 'note' : undefined,
|
||||
tone: offSchedule > 0 ? 'note' : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -191,105 +119,103 @@ export function TasksPage() {
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
{[...groups, ...(ungrouped.length > 0 ? [''] : [])].map((group) => {
|
||||
const inGroup = tasks.filter((task) => task.group === group);
|
||||
if (inGroup.length === 0) return null;
|
||||
return (
|
||||
<Card
|
||||
key={group || 'other'}
|
||||
title={group || 'Other'}
|
||||
icon={group === 'System' ? 'chip' : group === 'Analytics' ? 'chart' : 'wrench'}
|
||||
tone={group === 'System' ? 'info' : group === 'Analytics' ? 'data' : 'note'}
|
||||
>
|
||||
<div className="list">
|
||||
{inGroup.map((task) => (
|
||||
<div className="list-item" key={task.id}>
|
||||
<div className="list-body">
|
||||
<b>
|
||||
{task.name}{' '}
|
||||
{task.running ? <Tag tone="info">running</Tag> : null}
|
||||
{!task.enabled ? <Tag tone="warn">off</Tag> : null}
|
||||
{task.defaultIntervalSeconds > 0 &&
|
||||
task.intervalSeconds !== task.defaultIntervalSeconds ? (
|
||||
<Tag tone="note">retimed</Tag>
|
||||
) : null}
|
||||
</b>
|
||||
<p>{task.description}</p>
|
||||
<p className="quiet">
|
||||
{interval(task.intervalSeconds)}
|
||||
{task.enabled && task.nextRun ? ` · next ${ago(task.nextRun).replace(' ago', '')}` : ''}
|
||||
{task.lastRun ? (
|
||||
<>
|
||||
{' · last '}
|
||||
<span title={when(task.lastRun.startedAt)}>{ago(task.lastRun.startedAt)}</span>
|
||||
{` in ${duration(task.lastRun.durationMs)}`}
|
||||
{task.lastRun.detail ? ` — ${task.lastRun.detail}` : ''}
|
||||
</>
|
||||
) : (
|
||||
' · never run'
|
||||
)}
|
||||
</p>
|
||||
{task.lastRun?.error ? (
|
||||
<p className="mono" style={undefined}>
|
||||
<Tag tone="bad">{task.lastRun.error}</Tag>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="list-actions">
|
||||
{task.lastRun ? (
|
||||
<Tag tone={statusTone(task.lastRun.status)}>{task.lastRun.status}</Tag>
|
||||
) : (
|
||||
<Tag>never run</Tag>
|
||||
)}
|
||||
{/* Disabled while a run is in flight: changing the cadence
|
||||
reschedules from now, and doing that underneath a running job
|
||||
is how one run silently becomes two. */}
|
||||
<select
|
||||
aria-label={`How often ${task.name} runs`}
|
||||
value={task.intervalSeconds}
|
||||
disabled={busy === `${task.id}:interval` || task.running}
|
||||
onChange={(event) => void setCadence(task, Number(event.target.value))}
|
||||
>
|
||||
{cadenceChoices(task).map((choice) => (
|
||||
<option key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
{choice.value === task.defaultIntervalSeconds ? ' (default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{task.defaultIntervalSeconds > 0 &&
|
||||
task.intervalSeconds !== task.defaultIntervalSeconds ? (
|
||||
<Button
|
||||
size="sm"
|
||||
icon="refresh"
|
||||
busy={busy === `${task.id}:interval`}
|
||||
onClick={() => void resetCadence(task)}
|
||||
>
|
||||
Default
|
||||
</Button>
|
||||
) : null}
|
||||
<Toggle
|
||||
label=""
|
||||
checked={task.enabled}
|
||||
disabled={busy === `${task.id}:enabled`}
|
||||
onChange={(next) => void setEnabled(task, next)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="play"
|
||||
busy={busy === task.id}
|
||||
disabled={task.running}
|
||||
onClick={() => void runNow(task)}
|
||||
>
|
||||
Run now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<Card
|
||||
title="Tasks"
|
||||
intro="Anything wrong sorts to the top. Open a task to change its schedule, switch it off, or read its own run history."
|
||||
icon="clock"
|
||||
tone="info"
|
||||
>
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Service</th>
|
||||
<th>Schedule</th>
|
||||
<th>Last run</th>
|
||||
<th className="num">Took</th>
|
||||
<th>Next run</th>
|
||||
<th>Status</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyRow columns={8}>This gateway has no scheduled tasks registered.</EmptyRow>
|
||||
) : (
|
||||
rows.map((task) => {
|
||||
const status = taskStatus(task);
|
||||
const last = task.lastRun;
|
||||
const href = `/admin/tasks/${encodeURIComponent(task.id)}`;
|
||||
return (
|
||||
<tr key={task.id}>
|
||||
{/* The name is the row's primary element and everything under it
|
||||
is quieter, so a column of forty reads as a list of names
|
||||
rather than as forty paragraphs. */}
|
||||
<td>
|
||||
<Link className="table-row-link" to={href}>
|
||||
{task.name}
|
||||
</Link>
|
||||
<span className="table-sub">{task.description}</span>
|
||||
</td>
|
||||
<td className="muted nowrap">{task.group || 'Other'}</td>
|
||||
{/* The declared cadence sits under an overridden one rather than
|
||||
beside it: what happens next is the answer, and what the code
|
||||
asked for is the footnote explaining why the row is retimed. */}
|
||||
<td className="nowrap">
|
||||
{interval(task.intervalSeconds)}
|
||||
{retimed(task) ? (
|
||||
<span className="table-sub">
|
||||
default {interval(task.defaultIntervalSeconds)}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="nowrap muted" title={last ? when(last.startedAt) : undefined}>
|
||||
{last ? ago(last.startedAt) : 'never'}
|
||||
{/* Only a failure earns a sub-line. A detail under every
|
||||
successful row is a column of noise, and the detail is on
|
||||
the task's own page either way. */}
|
||||
{last?.error ? <span className="table-sub">{last.error}</span> : null}
|
||||
</td>
|
||||
<td className="num muted">{last ? duration(last.durationMs) : '—'}</td>
|
||||
{/* A switched-off task still has a next run in the scheduler's
|
||||
records and it is not going to happen, so the column says so
|
||||
rather than printing a time that will pass with nothing at
|
||||
the end of it. */}
|
||||
<td className="nowrap muted">
|
||||
{!task.enabled ? '—' : task.running ? 'now' : until(task.nextRun)}
|
||||
</td>
|
||||
<td className="nowrap">
|
||||
<span className="row tight">
|
||||
<span className="dot-state" data-tone={statusDot(status)} />
|
||||
<Tag tone={status.tone}>
|
||||
<span title={status.title}>{status.label}</span>
|
||||
</Tag>
|
||||
</span>
|
||||
</td>
|
||||
<td className="nowrap">
|
||||
<span className="row tight">
|
||||
<Button
|
||||
size="sm"
|
||||
icon="play"
|
||||
busy={busy === task.id}
|
||||
disabled={task.running}
|
||||
title={`Run ${task.name} now`}
|
||||
onClick={() => void runNow(task)}
|
||||
/>
|
||||
<Link className="table-row-link" to={href}>
|
||||
Details
|
||||
</Link>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Recent runs"
|
||||
@@ -318,10 +244,17 @@ export function TasksPage() {
|
||||
<td className="nowrap muted" title={when(entry.startedAt)}>
|
||||
{ago(entry.startedAt)}
|
||||
</td>
|
||||
<td>{tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId}</td>
|
||||
<td>
|
||||
<Link
|
||||
className="table-row-link"
|
||||
to={`/admin/tasks/${encodeURIComponent(entry.taskId)}`}
|
||||
>
|
||||
{tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="muted">{entry.trigger}</td>
|
||||
<td>
|
||||
<Tag tone={statusTone(entry.status)}>{entry.status}</Tag>
|
||||
<Tag tone={runTone(entry.status)}>{entry.status}</Tag>
|
||||
</td>
|
||||
<td className="num muted">{duration(entry.durationMs)}</td>
|
||||
<td className="muted">{entry.error || entry.detail || '—'}</td>
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useAction, useQuery } from '../lib/hooks';
|
||||
import { useToast } from '../lib/toast';
|
||||
import { ago, duration, num, when } from '../lib/format';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Confirm,
|
||||
Empty,
|
||||
EmptyRow,
|
||||
Field,
|
||||
Loading,
|
||||
Note,
|
||||
PageHead,
|
||||
TableWrap,
|
||||
Tag,
|
||||
Toggle,
|
||||
} from '../components/ui';
|
||||
import type { Integration, IntegrationEventOption, IntegrationsResponse, RadarrRequestPolicy, SonarrRequestPolicy } from '../api/types';
|
||||
|
||||
/* Event webhooks: administrative events going out to somewhere else.
|
||||
*
|
||||
* It lives inside the Integrations area but answers the opposite question from the pages
|
||||
* beside it: those are the services Memby *depends on*, this is the places Memby *posts
|
||||
* to*. Nothing here is a dependency — remove every webhook and the gateway is unchanged.
|
||||
*
|
||||
* Two cards that used to sit on this page have gone to where they belong. The Sonarr and
|
||||
* Radarr enable switches are on those services' own pages, beside their health and their
|
||||
* run history, because a switch away from the evidence for pressing it is one pressed
|
||||
* blind; and the request policies moved with them.
|
||||
*
|
||||
* The whole page is written around one property of the backend, and it is worth stating
|
||||
* because the form would otherwise look careless: the webhook address is never returned.
|
||||
* It is the credential — anybody holding it can post into the channel — so the gateway
|
||||
* sends back only whether one is set and the channel id from the middle of it. That is why
|
||||
* the address field on an existing integration is blank with a placeholder saying so, and
|
||||
* why saving with it blank leaves the stored one alone. */
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
const NEW_DRAFT: Draft = { id: '', name: 'Discord', url: '', enabled: true, events: [] };
|
||||
|
||||
export function WebhooksPage() {
|
||||
const { wrap, show } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
const { data, error, loading, reload } = useQuery<IntegrationsResponse>('/admin/api/integrations', {
|
||||
pollMs: 60_000,
|
||||
});
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [confirming, setConfirming] = useState<Integration | null>(null);
|
||||
|
||||
const catalogue = data?.catalogue ?? [];
|
||||
const integrations = data?.integrations ?? [];
|
||||
|
||||
const edit = (integration: Integration) =>
|
||||
setDraft({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
url: '',
|
||||
enabled: integration.enabled,
|
||||
events: integration.events ?? [],
|
||||
});
|
||||
|
||||
const save = () =>
|
||||
run('save', async () => {
|
||||
if (!draft) return;
|
||||
const saved = await wrap(
|
||||
() => api.post<IntegrationsResponse>('/admin/api/integrations', draft),
|
||||
draft.id ? 'Integration saved.' : 'Integration added.',
|
||||
);
|
||||
if (saved) {
|
||||
setDraft(null);
|
||||
await reload();
|
||||
}
|
||||
});
|
||||
|
||||
const remove = (integration: Integration) =>
|
||||
run('remove', async () => {
|
||||
await wrap(
|
||||
() => api.del(`/admin/api/integrations/${encodeURIComponent(integration.id)}`),
|
||||
`${integration.name} removed.`,
|
||||
);
|
||||
setConfirming(null);
|
||||
await reload();
|
||||
});
|
||||
|
||||
const test = (integration: Integration) =>
|
||||
run(`test:${integration.id}`, async () => {
|
||||
const result = await wrap(() =>
|
||||
api.post<{ ok: boolean; message: string }>(
|
||||
`/admin/api/integrations/${encodeURIComponent(integration.id)}/test`,
|
||||
),
|
||||
);
|
||||
// The test route answers 200 whether or not the webhook accepted it, because the
|
||||
// *request* succeeded — so the verdict is in the body, and reporting it is this
|
||||
// page's job rather than the transport's.
|
||||
if (result) show(result.message, result.ok ? 'ok' : 'bad');
|
||||
await reload();
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title="Event webhooks"
|
||||
intro="Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere."
|
||||
actions={
|
||||
<Button variant="primary" icon="plus" onClick={() => setDraft(NEW_DRAFT)}>
|
||||
Add a webhook
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Banner message={error} />
|
||||
|
||||
{(data?.dropped ?? 0) > 0 ? (
|
||||
<Note tone="warn">
|
||||
{num(data?.dropped ?? 0)} events could not be queued for delivery. The queue is deliberately
|
||||
lossy — a slow endpoint must never hold up a television signing in — but a number growing here
|
||||
means a destination is not keeping up.
|
||||
</Note>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : integrations.length === 0 && !draft ? (
|
||||
<Card title="Nothing configured" icon="plug" tone="note">
|
||||
<Empty>
|
||||
No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's
|
||||
settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here.
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
integrations.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
catalogue={catalogue}
|
||||
busy={busy}
|
||||
onEdit={() => edit(integration)}
|
||||
onTest={() => void test(integration)}
|
||||
onRemove={() => setConfirming(integration)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{draft ? (
|
||||
<DraftCard
|
||||
draft={draft}
|
||||
catalogue={catalogue}
|
||||
busy={busy === 'save'}
|
||||
onChange={setDraft}
|
||||
onSave={() => void save()}
|
||||
onCancel={() => setDraft(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{confirming ? (
|
||||
<Confirm
|
||||
title={`Remove ${confirming.name}?`}
|
||||
body="The webhook address and its delivery history go with it. Events already published stay in the activity feed."
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
busy={busy === 'remove'}
|
||||
onConfirm={() => void remove(confirming)}
|
||||
onCancel={() => setConfirming(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ArrIntegrationCard used to be here: two switches for Sonarr and Radarr on a page about
|
||||
Discord. They are on each service's own page now, where the health, the last run and the
|
||||
list of what goes off with them are — which is the whole difference between a switch and
|
||||
an informed one. The two request-policy cards below are exported for the same pages. */
|
||||
|
||||
export function SonarrRequestCard() {
|
||||
const { wrap } = useToast();
|
||||
const { busy, run } = useAction();
|
||||
const { data, error, loading, reload } = useQuery<SonarrRequestPolicy>('/admin/api/sonarr-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('sonarr-request-policy', async () => {
|
||||
await wrap(
|
||||
() => api.post('/admin/api/sonarr-request-policy', { qualityProfileId: profileId, searchImmediately }),
|
||||
'Sonarr TV request policy saved.',
|
||||
);
|
||||
await reload();
|
||||
});
|
||||
const selected = data?.profiles.find((profile) => profile.id === profileId);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Sonarr TV requests"
|
||||
intro="The policy Memby uses when a viewer requests a television series. The series remains monitored; searching its existing episodes 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 === 'sonarr-request-policy'} disabled={loading || profileId <= 0} onClick={() => void save()}>Save Sonarr policy</Button>}
|
||||
>
|
||||
<Banner message={error ?? data?.error ?? ''} />
|
||||
{loading ? <Loading rows={2} /> : (
|
||||
<>
|
||||
<div className="fields">
|
||||
<Field label="Request quality profile" hint="Memby stores this Sonarr 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 episodes immediately after request" hint="Off adds and monitors the series without searching its backlog. Enable only when requests should start an immediate episode search." checked={searchImmediately} onChange={setSearchImmediately} />
|
||||
{selected ? <Note tone="info">Requested series will use <b>{selected.name}</b> (profile ID {selected.id}), be monitored using Memby’s existing all-episodes strategy, and {searchImmediately ? 'start an immediate search.' : 'not start an immediate search.'}</Note> : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export 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,
|
||||
busy,
|
||||
onEdit,
|
||||
onTest,
|
||||
onRemove,
|
||||
}: {
|
||||
integration: Integration;
|
||||
catalogue: IntegrationEventOption[];
|
||||
busy: string | null;
|
||||
onEdit: () => void;
|
||||
onTest: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const health = integration.health;
|
||||
// "Is it working" is answered by the *last* attempt, not by a failure count: a webhook
|
||||
// that failed once an hour ago and has worked since is healthy.
|
||||
const healthy =
|
||||
!health.lastFailure || (health.lastSuccess && health.lastSuccess > health.lastFailure);
|
||||
const selected = integration.events ?? [];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={integration.name}
|
||||
intro={integration.hint ? `Discord webhook ${integration.hint}` : 'Discord webhook'}
|
||||
icon="plug"
|
||||
tone={integration.enabled ? 'ok' : 'warn'}
|
||||
actions={
|
||||
<>
|
||||
{integration.enabled ? <Tag tone="ok">on</Tag> : <Tag tone="warn">off</Tag>}
|
||||
{health.deliveries > 0 ? (
|
||||
<Tag tone={healthy ? 'ok' : 'bad'}>{healthy ? 'delivering' : 'failing'}</Tag>
|
||||
) : (
|
||||
<Tag>never used</Tag>
|
||||
)}
|
||||
<Button size="sm" icon="pulse" busy={busy === `test:${integration.id}`} onClick={onTest}>
|
||||
Test
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" icon="trash" onClick={onRemove} title="Remove" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="list">
|
||||
<div className="list-item">
|
||||
<div className="list-body">
|
||||
<b>Events sent</b>
|
||||
<p>
|
||||
{selected.length === 0
|
||||
? 'None selected — this destination is configured but will never post anything.'
|
||||
: selected
|
||||
.map((type) => catalogue.find((entry) => entry.type === type)?.label ?? type)
|
||||
.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-item">
|
||||
<div className="list-body">
|
||||
<b>Last delivered</b>
|
||||
<p>{health.lastSuccess ? when(health.lastSuccess) : 'never'}</p>
|
||||
</div>
|
||||
<div className="list-actions">
|
||||
{health.deliveries > 0 ? (
|
||||
<span className="quiet">
|
||||
{num(health.deliveries)} attempts, {num(health.failures)} failed
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{health.lastFailure ? (
|
||||
<div className="list-item">
|
||||
<div className="list-body">
|
||||
<b>Last failure</b>
|
||||
<p>
|
||||
{when(health.lastFailure)}
|
||||
{health.lastError ? ` — ${health.lastError}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{integration.deliveries.length > 0 ? (
|
||||
<TableWrap>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="nowrap">Attempted</th>
|
||||
<th>Event</th>
|
||||
<th>Result</th>
|
||||
<th className="num">Took</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{integration.deliveries.map((delivery) => (
|
||||
<tr key={delivery.id}>
|
||||
<td className="nowrap muted" title={when(delivery.attemptedAt)}>
|
||||
{ago(delivery.attemptedAt)}
|
||||
</td>
|
||||
<td className="muted">{delivery.eventType}</td>
|
||||
<td>
|
||||
{delivery.success ? (
|
||||
<Tag tone="ok">{delivery.statusCode || 'ok'}</Tag>
|
||||
) : (
|
||||
<Tag tone="bad">{delivery.error || delivery.statusCode || 'failed'}</Tag>
|
||||
)}
|
||||
</td>
|
||||
<td className="num muted">{duration(delivery.durationMs)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
) : (
|
||||
<TableWrap>
|
||||
<table>
|
||||
<tbody>
|
||||
<EmptyRow columns={4}>Nothing has been delivered through this webhook yet.</EmptyRow>
|
||||
</tbody>
|
||||
</table>
|
||||
</TableWrap>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({
|
||||
draft,
|
||||
catalogue,
|
||||
busy,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
draft: Draft;
|
||||
catalogue: IntegrationEventOption[];
|
||||
busy: boolean;
|
||||
onChange: (next: Draft) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const groups = [...new Set(catalogue.map((entry) => entry.group))];
|
||||
const toggleEvent = (type: string, on: boolean) =>
|
||||
onChange({
|
||||
...draft,
|
||||
events: on ? [...draft.events, type] : draft.events.filter((entry) => entry !== type),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={draft.id ? `Edit ${draft.name}` : 'New Discord webhook'}
|
||||
icon="plug"
|
||||
tone="info"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="primary" busy={busy} onClick={onSave}>
|
||||
{draft.id ? 'Save' : 'Add'}
|
||||
</Button>
|
||||
<Button variant="quiet" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<span className="spacer" />
|
||||
{draft.events.length === 0 ? (
|
||||
<span className="quiet">Nothing selected — this destination would never post.</span>
|
||||
) : (
|
||||
<span className="quiet">{draft.events.length} events selected</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="fields">
|
||||
<Field label="Name" hint="What this destination is called in the console.">
|
||||
<input
|
||||
type="text"
|
||||
value={draft.name}
|
||||
onChange={(event) => onChange({ ...draft, name: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Webhook address"
|
||||
hint={
|
||||
draft.id
|
||||
? 'Leave blank to keep the address already saved — it is a credential and is never sent back to this page.'
|
||||
: 'Discord → channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.'
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={draft.url}
|
||||
placeholder={draft.id ? 'unchanged' : 'https://discord.com/api/webhooks/…'}
|
||||
onChange={(event) => onChange({ ...draft, url: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="Enabled"
|
||||
hint="Off keeps the configuration and stops the posts."
|
||||
checked={draft.enabled}
|
||||
onChange={(next) => onChange({ ...draft, enabled: next })}
|
||||
/>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group}>
|
||||
<div className="card-head" style={undefined}>
|
||||
<div className="card-head-text">
|
||||
<h2>{group}</h2>
|
||||
</div>
|
||||
</div>
|
||||
{catalogue
|
||||
.filter((entry) => entry.group === group)
|
||||
.map((entry) => (
|
||||
<Toggle
|
||||
key={entry.type}
|
||||
label={entry.label}
|
||||
hint={entry.description}
|
||||
checked={draft.events.includes(entry.type)}
|
||||
onChange={(on) => toggleEvent(entry.type, on)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -3582,3 +3582,81 @@ details summary {
|
||||
font: 13px/1.6 var(--sans);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---------- runtime ----------
|
||||
|
||||
The verdict block and the section headings the runtime pages are built from. The verdict
|
||||
is deliberately not a `.banner`: a banner is something that has gone wrong and can be
|
||||
dismissed, and this is a standing answer that is usually good news. */
|
||||
|
||||
.subhead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 6px 0 0;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.subhead small {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--quiet);
|
||||
}
|
||||
|
||||
.verdict {
|
||||
margin: 0 0 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-lift);
|
||||
}
|
||||
.verdict[data-tone="warn"] {
|
||||
border-left-color: var(--warn);
|
||||
}
|
||||
.verdict[data-tone="bad"] {
|
||||
border-left-color: var(--danger);
|
||||
}
|
||||
.verdict-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.verdict-head svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.verdict[data-tone="warn"] .verdict-head svg {
|
||||
color: var(--warn);
|
||||
}
|
||||
.verdict[data-tone="bad"] .verdict-head svg {
|
||||
color: var(--danger);
|
||||
}
|
||||
.verdict-head b {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.verdict-notes {
|
||||
margin: 11px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.verdict-notes li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.77"
|
||||
val defaultVersionName = "0.2.78"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -1597,6 +1597,42 @@ class EmbyRepository internal constructor(
|
||||
return result.played
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a whole show watched, or not, and re-reads its episodes.
|
||||
*
|
||||
* This is deliberately not [setPlayed] with a series id, even though the call Emby
|
||||
* receives is the same one. Emby applies a series' played flag to **every episode
|
||||
* underneath it**, so what the call actually changes is the episode list — and the copy
|
||||
* this session is holding of that list would otherwise go on claiming the show was half
|
||||
* watched until its TTL ran out, on the very page the viewer just pressed the button on.
|
||||
*
|
||||
* The episodes' own resume ledger entries go with it: [localResume] takes the *greatest*
|
||||
* of the card's position and its own record, so a playhead left there would put somebody
|
||||
* back into the middle of an episode Emby has just been told they finished.
|
||||
*
|
||||
* Returns the re-read episodes, or null when they could not be re-read — the difference
|
||||
* matters to the caller, which has an optimistic list on screen and must keep it rather
|
||||
* than replace it with nothing.
|
||||
*/
|
||||
suspend fun setSeriesPlayed(seriesId: String, played: Boolean): List<BaseItem>? {
|
||||
setPlayed(seriesId, played)
|
||||
forgetSeriesEpisodes(seriesId)
|
||||
return runCatching { getSeriesEpisodes(seriesId) }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops one show's cached episodes, and the resume ledger entries belonging to them, so
|
||||
* the next read is the server's own answer.
|
||||
*/
|
||||
private suspend fun forgetSeriesEpisodes(seriesId: String) {
|
||||
val cached = seriesEpisodesMutex.withLock {
|
||||
val entry = seriesEpisodesCache.remove(seriesId)
|
||||
seriesEpisodesInFlight.remove(seriesId)?.cancel()
|
||||
entry?.episodes
|
||||
}
|
||||
cached?.forEach { forgetLocalResume(it.id) }
|
||||
}
|
||||
|
||||
/** Removes a title from Continue Watching without changing its watched state. */
|
||||
suspend fun removeFromContinueWatching(itemId: String) {
|
||||
// Taking a title off the shelf is a statement that its playhead no longer matters.
|
||||
|
||||
@@ -616,6 +616,23 @@ data class GatewayMediaRequestItem(
|
||||
val status: String = "",
|
||||
val statusLabel: String = "",
|
||||
val statusDetail: String = "",
|
||||
/**
|
||||
* Whole percent, and present only on a downloading card.
|
||||
*
|
||||
* The gateway omits it whenever nothing could measure one, so zero means "no figure"
|
||||
* rather than "no bytes yet" — see [requestProgressFraction], which reads it that way.
|
||||
* A build talking to a gateway that predates the download states never receives one.
|
||||
*/
|
||||
val progress: Int = 0,
|
||||
/**
|
||||
* How long the download client says the bytes will take, or zero when it would not say.
|
||||
*
|
||||
* That zero is load-bearing: it is the difference between the card promising a time and
|
||||
* the card saying plainly that we will let them know. The sentence is already composed
|
||||
* in [statusDetail] — this is here so a later screen can word it differently without
|
||||
* the two disagreeing about whether there is an estimate at all.
|
||||
*/
|
||||
val estimatedReadySeconds: Int = 0,
|
||||
/** Set once Emby has imported it, so an arrived request can open its own detail page. */
|
||||
val embyItemId: String = "",
|
||||
)
|
||||
|
||||
@@ -184,6 +184,12 @@ internal val MEMBY_CAPABILITIES = listOf(
|
||||
// whose audio it will bitstream. A gateway seeing this knows the audio tokens beside
|
||||
// it are a complete answer rather than an older app that simply never described itself.
|
||||
"audio_passthrough_v1",
|
||||
// Declares that this build understands the download states on a request card —
|
||||
// searching, found, downloading, processing, failed — and has somewhere to draw a
|
||||
// percentage and an estimate. A gateway seeing no such token narrows all four back to
|
||||
// "processing", the single word the whole span used to be, so an older television reads
|
||||
// its request page exactly as it always did.
|
||||
"request_progress_v1",
|
||||
)
|
||||
|
||||
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
|
||||
|
||||
@@ -916,7 +916,7 @@ private val SynopsisLineHeight = 20.sp
|
||||
* A single measure pass, in the layout phase. No subcomposition, no measuring twice, and
|
||||
* nothing read in composition: the same rule the collapsing hero band follows.
|
||||
*/
|
||||
private fun Modifier.wholeLines(lineHeight: TextUnit): Modifier = layout { measurable, constraints ->
|
||||
internal fun Modifier.wholeLines(lineHeight: TextUnit): Modifier = layout { measurable, constraints ->
|
||||
val line = lineHeight.roundToPx().coerceAtLeast(1)
|
||||
val placeable = measurable.measure(
|
||||
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity),
|
||||
|
||||
@@ -526,6 +526,48 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The launcher's half of marking a whole show watched, or not.
|
||||
*
|
||||
* The network call belongs to the series page — see [EmbyRepository.setSeriesPlayed],
|
||||
* which has to re-read the episode list the page is drawing. What is left here is the
|
||||
* launcher, and the launcher does not hold the show: Continue Watching and Next Up hold
|
||||
* its **episodes**, so flipping the series' own user data would leave the card the
|
||||
* viewer was trying to be rid of sitting exactly where it was until the next refresh.
|
||||
* The same call rolls the change back, which is why the pruning is not conditional on
|
||||
* anything but [played].
|
||||
*/
|
||||
fun applySeriesPlayed(series: BaseItem, played: Boolean) {
|
||||
_playedChanges.update { it + (series.id to played) }
|
||||
updateUserData(series.id) {
|
||||
it.copy(played = played, unplayedItemCount = if (played) 0 else it.unplayedItemCount)
|
||||
}
|
||||
if (!played) return
|
||||
_state.update { state ->
|
||||
fun List<BaseItem>.withoutSeries() = filterNot { it.seriesId == series.id }
|
||||
state.copy(
|
||||
continueWatching = state.continueWatching.withoutSeries(),
|
||||
rows = state.rows.map { row ->
|
||||
if (row.kind == "continue" || row.kind == "nextup") {
|
||||
row.copy(items = row.items.withoutSeries())
|
||||
} else {
|
||||
row
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads Continue Watching once the series page's own mutation has settled, so the
|
||||
* optimistic pruning above is replaced by whatever the server actually thinks. A show
|
||||
* with a new episode already imported comes straight back, which is the case that makes
|
||||
* the refresh worth making rather than trusting the optimism.
|
||||
*/
|
||||
fun refreshContinueWatching() {
|
||||
viewModelScope.launch { refreshWatching() }
|
||||
}
|
||||
|
||||
fun removeFromContinueWatching(item: BaseItem) {
|
||||
val previous = _state.value
|
||||
_state.update { state ->
|
||||
|
||||
@@ -146,7 +146,6 @@ import com.ponzischeme89.memby.ui.calendar.CalendarScreen
|
||||
import com.ponzischeme89.memby.ui.requests.RequestsScreen
|
||||
import com.ponzischeme89.memby.ui.requests.RequestsViewModel
|
||||
import com.ponzischeme89.memby.ui.requests.RequestsViewModelFactory
|
||||
import com.ponzischeme89.memby.ui.genre.GenreDiscoveryStrip
|
||||
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
|
||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||
import com.ponzischeme89.memby.ui.player.PrerollPreloader
|
||||
@@ -1917,8 +1916,6 @@ private fun HomeScreen(
|
||||
var switchingProfileId by remember { mutableStateOf<String?>(null) }
|
||||
var removingProfileId by remember { mutableStateOf<String?>(null) }
|
||||
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
||||
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
|
||||
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(null) }
|
||||
var navigationExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var restoreRailAfterSettings by remember { mutableStateOf(false) }
|
||||
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||
@@ -2050,14 +2047,9 @@ private fun HomeScreen(
|
||||
// moved to Home, the stance the calendar takes: the rail entry goes with the
|
||||
// feature, so leaving it there would strand the viewer on a page nothing can
|
||||
// navigate back to.
|
||||
val strandedOnDestination = selectedDestination == BrowseDestination.GENRES
|
||||
if (genreBrowseItemType != null || strandedOnDestination) {
|
||||
genreBrowseItemType = null
|
||||
genreBrowseInitialCategoryId = null
|
||||
if (strandedOnDestination) {
|
||||
selectedDestination = BrowseDestination.HOME
|
||||
railFocusDestination = BrowseDestination.HOME
|
||||
}
|
||||
if (selectedDestination == BrowseDestination.GENRES) {
|
||||
selectedDestination = BrowseDestination.HOME
|
||||
railFocusDestination = BrowseDestination.HOME
|
||||
kotlinx.coroutines.delay(16L)
|
||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||
}
|
||||
@@ -2470,7 +2462,6 @@ private fun HomeScreen(
|
||||
userSwitcherVisible || showProfiles -> "profiles"
|
||||
detailsItem != null -> "details"
|
||||
selectedMyShow != null -> "my_show_details"
|
||||
genreBrowseItemType != null -> "genre_browser"
|
||||
else -> selectedDestination.name.lowercase()
|
||||
}
|
||||
LaunchedEffect(journeyScreen) {
|
||||
@@ -2547,7 +2538,6 @@ private fun HomeScreen(
|
||||
screen = journeyScreen, feature = destination.name.lowercase(),
|
||||
source = journeyScreen, target = destination.name.lowercase(),
|
||||
)
|
||||
genreBrowseItemType = null
|
||||
when (destination) {
|
||||
BrowseDestination.SETTINGS -> {
|
||||
railFocusDestination = BrowseDestination.SETTINGS
|
||||
@@ -2737,9 +2727,9 @@ private fun HomeScreen(
|
||||
}
|
||||
|
||||
// The rail's own Genres destination browses the catalogue, films and shows
|
||||
// together — a household browses "Comedy", not "comedy films". The Movies
|
||||
// and TV Series pages open the same screen through genreBrowseItemType
|
||||
// below, naming a type so neither of those grids can cross media types.
|
||||
// together — a household browses "Comedy", not "comedy films". It is the
|
||||
// one way in: the Movies and TV Series pages carry no genre row of their
|
||||
// own, so neither of those grids can cross media types.
|
||||
if (selectedDestination == BrowseDestination.GENRES) {
|
||||
GenreBrowseScreen(
|
||||
itemType = com.ponzischeme89.memby.ui.genre.ALL_MEDIA_ITEM_TYPE,
|
||||
@@ -2779,44 +2769,6 @@ private fun HomeScreen(
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
genreBrowseItemType?.let { itemType ->
|
||||
GenreBrowseScreen(
|
||||
itemType = itemType,
|
||||
initialCategoryId = genreBrowseInitialCategoryId
|
||||
?: com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID,
|
||||
favouriteStates = favoriteChanges,
|
||||
playedStates = playedChanges,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID },
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
returnRowId = GENRE_BROWSER_ROW_ID
|
||||
returnRowKind = null
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "genre_browser",
|
||||
feature = "genre_browse", source = "genre_results", target = "details",
|
||||
itemName = item.name, itemType = item.type,
|
||||
)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
},
|
||||
onContentFocused = { navigationExpanded = false },
|
||||
onClose = {
|
||||
genreBrowseItemType = null
|
||||
genreBrowseInitialCategoryId = null
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
},
|
||||
)
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
FocusedHomeBackdrop(homeViewModel)
|
||||
val verticalState = verticalStates.getOrPut(selectedDestination.name) {
|
||||
LazyListState()
|
||||
@@ -2872,19 +2824,11 @@ private fun HomeScreen(
|
||||
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
|
||||
}
|
||||
// Must agree exactly with the items placed above the rows in the LazyColumn
|
||||
// below, because it is what turns a row index into a scroll target. The
|
||||
// genre strip is only one of them while the gateway has that feature on —
|
||||
// counting it regardless scrolled a row short of the destination on Movies
|
||||
// and TV Shows, which lands the requested card outside the composed window
|
||||
// and leaves the move with nothing to complete it.
|
||||
// below, because it is what turns a row index into a scroll target.
|
||||
// Counting one that is not placed scrolls a row short of the destination,
|
||||
// which lands the requested card outside the composed window and leaves
|
||||
// the move with nothing to complete it.
|
||||
val leadingItemCount =
|
||||
(
|
||||
if (
|
||||
genreBrowserEnabled &&
|
||||
(selectedDestination == BrowseDestination.MOVIES ||
|
||||
selectedDestination == BrowseDestination.SHOWS)
|
||||
) 1 else 0
|
||||
) +
|
||||
(if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
|
||||
if (
|
||||
selectedDestination == BrowseDestination.FAVORITES &&
|
||||
@@ -2985,30 +2929,6 @@ private fun HomeScreen(
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
if (
|
||||
genreBrowserEnabled &&
|
||||
(selectedDestination == BrowseDestination.MOVIES ||
|
||||
selectedDestination == BrowseDestination.SHOWS)
|
||||
) {
|
||||
item(key = "genre-browser", contentType = "genre-browser") {
|
||||
val itemType = if (selectedDestination == BrowseDestination.SHOWS) "Series" else "Movie"
|
||||
GenreDiscoveryStrip(
|
||||
itemType = itemType,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
entryFocusRequester = contentFocusRequester,
|
||||
onFocused = { navigationExpanded = false },
|
||||
onOpenCategory = { categoryId ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "navigation", action = "open",
|
||||
screen = selectedDestination.name.lowercase(), feature = "genre_browse",
|
||||
source = "genre_strip", target = "genre_browser",
|
||||
)
|
||||
genreBrowseInitialCategoryId = categoryId
|
||||
genreBrowseItemType = itemType
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (selectedDestination == BrowseDestination.SHOWS) {
|
||||
item(key = "my-shows", contentType = "my-shows") {
|
||||
MyShowsStrip(
|
||||
@@ -3029,12 +2949,8 @@ private fun HomeScreen(
|
||||
availableWidth = contentWidth,
|
||||
density = settings.homeCardDensity,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
// The genre launcher owns the entry target only while
|
||||
// the server has enabled it. When it is off, My Shows
|
||||
// becomes the first reachable content on this page.
|
||||
contentFocusRequester = contentFocusRequester.takeUnless {
|
||||
genreBrowserEnabled
|
||||
},
|
||||
// My Shows is the first reachable content on this page.
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
returnFocusItemId = myShowReturnItemId,
|
||||
returnFocusRequester = myShowReturnFocusRequester,
|
||||
onShowSelected = {
|
||||
@@ -3111,9 +3027,7 @@ private fun HomeScreen(
|
||||
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
||||
!hasContextualHero &&
|
||||
(selectedDestination != BrowseDestination.SHOWS ||
|
||||
(!genreBrowserEnabled && myShows.isEmpty())) &&
|
||||
(selectedDestination != BrowseDestination.MOVIES ||
|
||||
!genreBrowserEnabled) &&
|
||||
myShows.isEmpty()) &&
|
||||
row.id == firstPopulatedRowId
|
||||
},
|
||||
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
|
||||
@@ -3682,6 +3596,19 @@ private fun HomeScreen(
|
||||
factory = RequestsViewModelFactory(repo),
|
||||
)
|
||||
val requestsState by requestsViewModel.state.collectAsStateWithLifecycle()
|
||||
// The page keeps itself current while it is on screen — it is the one screen
|
||||
// whose cards move without anybody touching anything. The loop is hung off the
|
||||
// composition rather than off the view model, which is keyed on the profile and
|
||||
// outlives this block, and off STARTED rather than run unconditionally, so a
|
||||
// television left on the launcher or switched to another app stops asking
|
||||
// entirely. Whether there is anything worth asking about is the view model's
|
||||
// own judgement — see pollWhileVisible.
|
||||
val requestsLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||
LaunchedEffect(requestsViewModel, requestsLifecycleOwner) {
|
||||
requestsLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
requestsViewModel.pollWhileVisible()
|
||||
}
|
||||
}
|
||||
// The page covers the launcher, rail and all, so it carries its own rather than
|
||||
// leaving Left pointing at one nobody can see. Its own FocusRequester, because
|
||||
// the launcher's is still attached to the rail underneath and one requester on
|
||||
@@ -3710,7 +3637,6 @@ private fun HomeScreen(
|
||||
onDestinationSelected = { destination ->
|
||||
requestsRailExpanded = false
|
||||
showRequests = false
|
||||
genreBrowseItemType = null
|
||||
when (destination) {
|
||||
BrowseDestination.SETTINGS -> {
|
||||
railFocusDestination = BrowseDestination.SETTINGS
|
||||
@@ -4428,6 +4354,15 @@ private fun FocusedDetailsOverlay(
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
isMyShow = isMyShow,
|
||||
onToggleMyShow = onToggleMyShow,
|
||||
onSeriesPlayedChanged = { series, played ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "library", action = if (played) "mark_played" else "mark_unplayed",
|
||||
screen = "details", feature = "played_status", itemName = series.name,
|
||||
itemType = series.type, outcome = "success",
|
||||
)
|
||||
homeViewModel.applySeriesPlayed(series, played)
|
||||
},
|
||||
onSeriesPlayedSettled = homeViewModel::refreshContinueWatching,
|
||||
onClose = onClose,
|
||||
onOpenItem = onOpenItem,
|
||||
restorePosition = restorePosition,
|
||||
|
||||
@@ -23,12 +23,15 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
@@ -133,19 +136,28 @@ internal fun RadarrMovieDetailContent(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = RadarrPageGutter, vertical = 46.dp),
|
||||
.padding(horizontal = RadarrPageGutter, vertical = RadarrPageMargin),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadarrPoster(poster, title)
|
||||
Spacer(Modifier.width(38.dp))
|
||||
// Identity, then the supporting material, then the actions — and the actions
|
||||
// are measured *before* the supporting material rather than after it. A Column
|
||||
// hands each unweighted child only what the ones above it left over, so with
|
||||
// the button row last in the flow a long description simply took its height:
|
||||
// it rendered as a squeezed sliver rather than disappearing, which is why it
|
||||
// read as a clipped button rather than as a page that had run out of room.
|
||||
// Everything that can honestly give way is inside the one weighted child, so
|
||||
// the give is taken from prose and secondary facts instead. This is the same
|
||||
// inversion the home hero and the detail hero already make.
|
||||
Column(Modifier.weight(1f)) {
|
||||
RadarrStatusRow(detail)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontSize = 40.sp,
|
||||
lineHeight = 44.sp,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 40.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -158,63 +170,8 @@ internal fun RadarrMovieDetailContent(
|
||||
Spacer(Modifier.height(10.dp))
|
||||
DetailFactRow(facts)
|
||||
}
|
||||
detail?.genres?.filter(String::isNotBlank)?.takeIf(List<String>::isNotEmpty)
|
||||
?.let { genres ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = genres.take(4).joinToString(ValueSeparator),
|
||||
color = MembyMutedText,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
detail?.ratings?.takeIf(List<*>::isNotEmpty)?.let { ratings ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
RatingsStrip(ratings, visible = true, modifier = Modifier.fillMaxWidth(0.8f))
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
RadarrReleaseBand(detail)
|
||||
val overview = detail?.overview?.takeIf(String::isNotBlank)
|
||||
?: card.overview?.takeIf(String::isNotBlank)
|
||||
if (overview != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = overview,
|
||||
color = MembyMutedText,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(0.86f),
|
||||
)
|
||||
}
|
||||
detail?.releaseDates?.takeIf(List<*>::isNotEmpty)?.let { dates ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
|
||||
dates.forEach { date ->
|
||||
Column {
|
||||
Text(
|
||||
text = date.label.uppercase(),
|
||||
color = MembyQuietText,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.2.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
text = date.value,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(26.dp))
|
||||
RadarrSupporting(card, detail, Modifier.weight(1f, fill = false))
|
||||
Spacer(Modifier.height(22.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
@@ -241,11 +198,144 @@ internal fun RadarrMovieDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything between the fact line and the actions, in one block handed whatever the
|
||||
* identity above it and the buttons below it left over.
|
||||
*
|
||||
* The order the eye reads is the order these are declared; the order they *give way* is the
|
||||
* order they are measured, which is not the same thing. The single-purpose blocks are
|
||||
* unweighted, so each is measured first and offered what its predecessors left — and each
|
||||
* drops out whole ([dropIfCramped]) rather than being cut through the middle, because half
|
||||
* a ratings strip reads as a rendering fault where a missing one reads as a film with no
|
||||
* scores. The description is the one thing that can honestly be four lines, or two, or
|
||||
* none, so it is the weighted child: measured last, from what is left, a whole line at a
|
||||
* time. That is also what reserves the release dates, which are the answer to the question
|
||||
* this page exists for and must outlive the fourth line of a synopsis.
|
||||
*/
|
||||
@Composable
|
||||
private fun RadarrSupporting(
|
||||
card: BaseItem,
|
||||
detail: RadarrMovieDetail?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.clipToBounds()) {
|
||||
detail?.genres?.filter(String::isNotBlank)?.takeIf(List<String>::isNotEmpty)
|
||||
?.let { genres ->
|
||||
Column(Modifier.dropIfCramped()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = genres.take(4).joinToString(ValueSeparator),
|
||||
color = MembyMutedText,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
detail?.ratings?.takeIf(List<*>::isNotEmpty)?.let { ratings ->
|
||||
Column(Modifier.dropIfCramped()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
RatingsStrip(ratings, visible = true, modifier = Modifier.fillMaxWidth(0.8f))
|
||||
}
|
||||
}
|
||||
Column(Modifier.dropIfCramped()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
RadarrReleaseBand(detail)
|
||||
}
|
||||
val overview = detail?.overview?.takeIf(String::isNotBlank)
|
||||
?: card.overview?.takeIf(String::isNotBlank)
|
||||
if (overview != null) {
|
||||
Column(Modifier.weight(1f, fill = false)) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = overview,
|
||||
color = MembyMutedText,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = RadarrOverviewLineHeight,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.86f)
|
||||
// The snap is on the text itself, not on the column around it: a
|
||||
// wrapper carrying the gap above the prose is a whole number of
|
||||
// lines *plus 16dp*, which lands the clip through the middle of a
|
||||
// line — the fault this exists to prevent. The clip sits outside
|
||||
// it, so it cuts to the snapped height rather than to the text's
|
||||
// own; a Text handed less room still draws every line it was asked
|
||||
// for, and without this the dropped ones painted over the dates.
|
||||
.clipToBounds()
|
||||
.wholeLines(RadarrOverviewLineHeight),
|
||||
)
|
||||
}
|
||||
}
|
||||
detail?.releaseDates?.takeIf(List<*>::isNotEmpty)?.let { dates ->
|
||||
Column(Modifier.dropIfCramped()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
|
||||
dates.forEach { date ->
|
||||
Column {
|
||||
Text(
|
||||
text = date.label.uppercase(),
|
||||
color = MembyQuietText,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.2.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
text = date.value,
|
||||
color = MembyOnSurface,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the child's natural height when there is room for all of it, and no height at all
|
||||
* when there is not.
|
||||
*
|
||||
* A Column measures each unweighted child against what its predecessors left over, so this
|
||||
* is the difference between a block that is *omitted* under pressure and one drawn with its
|
||||
* bottom half missing. Omitting is the honest answer for a single-purpose block — the
|
||||
* genres, the scores, the release dates — where every line is either there or it is not.
|
||||
* Prose gives way by degrees instead, which is [wholeLines].
|
||||
*
|
||||
* One measure pass, in the layout phase, with nothing read in composition.
|
||||
*/
|
||||
private fun Modifier.dropIfCramped(): Modifier = layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(
|
||||
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity),
|
||||
)
|
||||
val fits = !constraints.hasBoundedHeight || placeable.height <= constraints.maxHeight
|
||||
if (fits) {
|
||||
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
|
||||
} else {
|
||||
layout(0, 0) {}
|
||||
}
|
||||
}
|
||||
|
||||
/** The gutter is the detail pages'; this page sits in the same column as the others. */
|
||||
private val RadarrPageGutter = DetailSideGutter
|
||||
|
||||
private val RadarrPosterWidth = 236.dp
|
||||
|
||||
/**
|
||||
* The page's top and bottom margin. A 1080p television is 540dp tall, so every dp spent
|
||||
* here is one the description or the release dates cannot have.
|
||||
*/
|
||||
private val RadarrPageMargin = 30.dp
|
||||
|
||||
/** The description's line box, shared by the text style and by [wholeLines]. */
|
||||
private val RadarrOverviewLineHeight = 21.sp
|
||||
|
||||
@Composable
|
||||
private fun RadarrPoster(url: String?, title: String) {
|
||||
val shape = RoundedCornerShape(MembyCardCorner)
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -61,6 +62,7 @@ import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.estimateSeriesPace
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.MediaRating
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import com.ponzischeme89.memby.data.seriesPaceLabel
|
||||
import com.ponzischeme89.memby.ui.detail.AiringNotice
|
||||
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||
@@ -89,6 +91,7 @@ import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
@@ -104,6 +107,14 @@ fun SeriesDetailsOverlay(
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
/**
|
||||
* The launcher's half of a watched change — see [HomeViewModel.applySeriesPlayed]. It is
|
||||
* called with the change and again with its opposite if the server refuses, because the
|
||||
* card this page is trying to be rid of is on the launcher rather than here.
|
||||
*/
|
||||
onSeriesPlayedChanged: (BaseItem, Boolean) -> Unit = { _, _ -> },
|
||||
/** Called once the change has settled, so Continue Watching can be re-read. */
|
||||
onSeriesPlayedSettled: () -> Unit = {},
|
||||
onClose: () -> Unit,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
restorePosition: Boolean = false,
|
||||
@@ -155,6 +166,35 @@ fun SeriesDetailsOverlay(
|
||||
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
|
||||
}
|
||||
|
||||
// The show's own watched button. The network call is the page's rather than the
|
||||
// launcher's because what it changes is the episode list on screen here: Emby applies a
|
||||
// series' played flag to every episode underneath it, so the list is mutated first and
|
||||
// replaced by the re-read the repository returns — or put back, if the call failed.
|
||||
val scope = rememberCoroutineScope()
|
||||
var markingWatched by remember(item.id) { mutableStateOf(false) }
|
||||
val setSeriesPlayed: (Boolean) -> Unit = { played ->
|
||||
if (!markingWatched) {
|
||||
markingWatched = true
|
||||
val previous = episodes
|
||||
episodes = previous?.map { it.withPlayed(played) }
|
||||
onSeriesPlayedChanged(item, played)
|
||||
scope.launch {
|
||||
runCatching { repository.setSeriesPlayed(item.id, played) }
|
||||
.onSuccess { reread ->
|
||||
// Null means the re-read itself failed while the change went
|
||||
// through: the optimistic list is still the better answer.
|
||||
reread?.let { episodes = it.sortedWith(seriesEpisodeComparator) }
|
||||
onSeriesPlayedSettled()
|
||||
}
|
||||
.onFailure {
|
||||
episodes = previous
|
||||
onSeriesPlayedChanged(item, !played)
|
||||
}
|
||||
markingWatched = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SeriesDetailContent(
|
||||
item = item,
|
||||
episodes = episodes,
|
||||
@@ -164,6 +204,7 @@ fun SeriesDetailsOverlay(
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
isMyShow = isMyShow,
|
||||
onToggleMyShow = onToggleMyShow,
|
||||
onSetSeriesPlayed = setSeriesPlayed,
|
||||
related = related,
|
||||
trailer = trailer,
|
||||
extras = extras,
|
||||
@@ -188,6 +229,8 @@ internal fun SeriesDetailContent(
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
/** Marks the whole show watched, or unwatched. Inert where nothing can apply it. */
|
||||
onSetSeriesPlayed: (Boolean) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
related: RelatedContent? = null,
|
||||
trailer: BaseItem? = null,
|
||||
@@ -212,6 +255,15 @@ internal fun SeriesDetailContent(
|
||||
}
|
||||
val nextEpisode = remember(episodes) { nextEpisodeToWatch(episodes.orEmpty()) }
|
||||
val remaining = remember(episodes) { unwatchedCount(episodes.orEmpty()) }
|
||||
// Two sources, and both are needed. The series' own flag is what Emby says and what the
|
||||
// launcher moves optimistically when the button is pressed, so it answers before the
|
||||
// episodes have landed and it is what a rollback speaks through. The episode list is the
|
||||
// page's own evidence, and it is the half that can be right when the flag is not — a
|
||||
// show whose last episode was watched elsewhere arrives here with the tick already
|
||||
// earned. Neither alone: a list holding episodes Emby will not mark (an unaired one it
|
||||
// still lists) would never reach zero, and an empty list would claim every show watched.
|
||||
val seriesWatched =
|
||||
item.userData?.played == true || (episodes?.isNotEmpty() == true && remaining == 0)
|
||||
// Recalculated from the episode list itself, so finishing an episode, marking one
|
||||
// watched and a newly imported episode all move it with no cache to invalidate. The
|
||||
// day is read once per composition of this page rather than on a timer: an estimate
|
||||
@@ -396,6 +448,30 @@ internal fun SeriesDetailContent(
|
||||
trailer?.let {
|
||||
add(DetailHeroAction(MembyIcon.Movie.mark, "Play trailer", onClick = { onPlayTrailer(item) }))
|
||||
}
|
||||
add(
|
||||
DetailHeroAction(
|
||||
// The same tick the movie page uses, in the same place, because it is
|
||||
// the same statement: a show marked watched is one the launcher stops
|
||||
// asking about. Emby carries it down to every season and episode, and a
|
||||
// newly imported episode brings the show back on its own.
|
||||
icon = MembyIcon.CheckAll.mark,
|
||||
description = if (seriesWatched) {
|
||||
"Mark this show unwatched"
|
||||
} else {
|
||||
"Mark this show watched"
|
||||
},
|
||||
active = seriesWatched,
|
||||
onClick = {
|
||||
val desired = !seriesWatched
|
||||
onSetSeriesPlayed(desired)
|
||||
confirmation = if (desired) {
|
||||
"Marked as watched"
|
||||
} else {
|
||||
"Marked as unwatched"
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
) { visibleTab ->
|
||||
when (visibleTab) {
|
||||
@@ -776,3 +852,17 @@ internal fun EpisodeCard(
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One episode as it reads after the show it belongs to was marked watched, or unwatched.
|
||||
*
|
||||
* The playhead goes with the flag rather than being left where it was: Emby resets a
|
||||
* finished title's position, and a card still carrying one would draw a progress bar under
|
||||
* an episode that has just been declared finished.
|
||||
*/
|
||||
private fun BaseItem.withPlayed(played: Boolean): BaseItem = copy(
|
||||
userData = (userData ?: UserItemData()).copy(
|
||||
played = played,
|
||||
playbackPositionTicks = if (played) 0L else userData?.playbackPositionTicks ?: 0L,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,13 +2,11 @@
|
||||
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
import com.ponzischeme89.memby.ui.theme.MembyIcon
|
||||
import com.ponzischeme89.memby.ui.theme.mark
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
@@ -54,7 +52,6 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
@@ -69,7 +66,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
@@ -87,111 +83,6 @@ import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun GenreDiscoveryStrip(
|
||||
itemType: String,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
entryFocusRequester: FocusRequester,
|
||||
onFocused: () -> Unit,
|
||||
onOpenCategory: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val categories = remember(itemType) { genreCategoryTabs(itemType) }
|
||||
Column(modifier.padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
"Browse genres",
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 36.dp, vertical = 6.dp),
|
||||
)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
|
||||
GenreDiscoveryCard(
|
||||
category = category,
|
||||
onFocused = onFocused,
|
||||
onClick = { onOpenCategory(category.id) },
|
||||
modifier = Modifier
|
||||
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
|
||||
.focusProperties { if (index == 0) left = navigationFocusRequester },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreDiscoveryCard(
|
||||
category: GenreCategory,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val visual = remember(category.icon) { genreVisual(category.icon) }
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = "Browse ${category.label}",
|
||||
modifier = modifier.width(142.dp),
|
||||
) { focused ->
|
||||
Column {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f / 3f)
|
||||
.background(visual.colour, RoundedCornerShape(MembyCardCorner))
|
||||
.border(
|
||||
2.dp,
|
||||
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
|
||||
RoundedCornerShape(MembyCardCorner),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
visual.icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.92f),
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
category.label,
|
||||
color = if (focused) Color.White else MembyMutedText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class GenreVisual(val icon: ImageVector, val colour: Color)
|
||||
|
||||
private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) {
|
||||
GenreCategoryIcon.ALL -> GenreVisual(MembyIcon.Category.mark, Color(0xFF4F46A5))
|
||||
GenreCategoryIcon.ACTION -> GenreVisual(MembyIcon.Bolt.mark, Color(0xFFB45309))
|
||||
GenreCategoryIcon.COMEDY -> GenreVisual(MembyIcon.Drama.mark, Color(0xFF15803D))
|
||||
GenreCategoryIcon.CRIME -> GenreVisual(MembyIcon.Gavel.mark, Color(0xFF475569))
|
||||
GenreCategoryIcon.DRAMA -> GenreVisual(MembyIcon.Drama.mark, Color(0xFF7E22CE))
|
||||
GenreCategoryIcon.HORROR -> GenreVisual(MembyIcon.Fire.mark, Color(0xFF991B1B))
|
||||
GenreCategoryIcon.MYSTERY -> GenreVisual(MembyIcon.Help.mark, Color(0xFF4338CA))
|
||||
GenreCategoryIcon.SCI_FI -> GenreVisual(MembyIcon.Sparkle.mark, Color(0xFF0369A1))
|
||||
GenreCategoryIcon.THRILLER -> GenreVisual(MembyIcon.HideWatched.mark, Color(0xFF0F766E))
|
||||
GenreCategoryIcon.WAR -> GenreVisual(MembyIcon.Trophy.mark, Color(0xFF57534E))
|
||||
GenreCategoryIcon.FAMILY -> GenreVisual(MembyIcon.Happy.mark, Color(0xFFDB2777))
|
||||
GenreCategoryIcon.DOCUMENTARY -> GenreVisual(MembyIcon.VideoLibrary.mark, Color(0xFF0E7490))
|
||||
GenreCategoryIcon.ROMANCE -> GenreVisual(MembyIcon.Favourite.mark, Color(0xFFBE185D))
|
||||
GenreCategoryIcon.WESTERN -> GenreVisual(MembyIcon.Landscape.mark, Color(0xFF92400E))
|
||||
GenreCategoryIcon.MUSIC -> GenreVisual(MembyIcon.Music.mark, Color(0xFF6D28D9))
|
||||
GenreCategoryIcon.SPORT -> GenreVisual(MembyIcon.Football.mark, Color(0xFF047857))
|
||||
GenreCategoryIcon.REALITY -> GenreVisual(MembyIcon.LiveTv.mark, Color(0xFFC2410C))
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a genre may sit under focus before it is asked for.
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -70,6 +71,15 @@ internal fun RequestCard(
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* Whole percent of the download, or zero when nothing could measure one.
|
||||
*
|
||||
* Zero means "no figure" rather than "no bytes yet", which is how the wire means it —
|
||||
* so a download nothing has measured draws the plain chip and no bar rather than an
|
||||
* empty bar claiming it has not started. Search candidates have no such figure and
|
||||
* never pass one.
|
||||
*/
|
||||
progress: Int = 0,
|
||||
/** Drawn in place of the trailing affordance while a request is in flight. */
|
||||
busy: Boolean = false,
|
||||
/** The trailing affordance, when pressing the card would do something. */
|
||||
@@ -79,7 +89,7 @@ internal fun RequestCard(
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = listOf(title, statusLabel, detail)
|
||||
contentDescription = listOf(title, requestStatusChipLabel(status, statusLabel, progress), detail)
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(", "),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
@@ -137,7 +147,7 @@ internal fun RequestCard(
|
||||
if (status != RequestStatus.REQUESTABLE) {
|
||||
RequestStatusBadge(
|
||||
status = status,
|
||||
label = statusLabel,
|
||||
label = requestStatusChipLabel(status, statusLabel, progress),
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(9.dp),
|
||||
)
|
||||
}
|
||||
@@ -172,6 +182,17 @@ internal fun RequestCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// The bar sits above the sentence rather than under it, so the quiet line
|
||||
// stays last on every card whether or not there is a bar — a card with no
|
||||
// measurable download and one with a finished one must read as the same
|
||||
// shape. requestProgressFraction is what decides there is one at all: only
|
||||
// moving bytes have a denominator, and a bar standing somewhere arbitrary
|
||||
// under a card that is merely being searched for would be the single most
|
||||
// misleading thing on the page.
|
||||
requestProgressFraction(status, progress)?.let { fraction ->
|
||||
Spacer(Modifier.height(7.dp))
|
||||
RequestProgressBar(fraction = fraction, colour = requestToneColour(status))
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
detail,
|
||||
@@ -198,6 +219,42 @@ internal val RequestActionRequest = RequestCardAction(MembyIcon.Add, "Request")
|
||||
internal val RequestActionPlay = RequestCardAction(MembyIcon.Play, "Watch")
|
||||
internal val RequestActionRemove = RequestCardAction(MembyIcon.CheckCircle, "Remove")
|
||||
|
||||
/**
|
||||
* How far along a download is.
|
||||
*
|
||||
* Deliberately not animated. The figure only ever moves when a poll answers, which is once
|
||||
* every several seconds, and an `animateFloatAsState` read in a composable body would
|
||||
* recompose this card sixty times a second for it — the rule every animation in this app is
|
||||
* held to. A bar that steps is also the more honest picture of a number that steps.
|
||||
*
|
||||
* The fill takes the state's own tone, so the bar and the chip above it are the same colour
|
||||
* and neither has to be read against the other.
|
||||
*/
|
||||
@Composable
|
||||
private fun RequestProgressBar(fraction: Float, colour: Color) {
|
||||
Box(
|
||||
Modifier
|
||||
// Capped rather than filling the column, which on a card this wide drew a rule
|
||||
// from the title to the trailing button and read as a separator. A gauge has to
|
||||
// be short enough to be seen as one object: at this width the filled part is
|
||||
// still judged against its own track from across a room.
|
||||
// widthIn *before* fillMaxWidth: the outer modifier is what constrains the
|
||||
// inner, so filling first hands the cap an exact width it can no longer narrow.
|
||||
.widthIn(max = 300.dp)
|
||||
.fillMaxWidth()
|
||||
.height(5.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(Color.White.copy(alpha = 0.14f)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(fraction.coerceIn(0f, 1f))
|
||||
.fillMaxHeight()
|
||||
.background(colour),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestTrailing(icon: ImageVector, label: String, focused: Boolean) {
|
||||
Column(
|
||||
|
||||
@@ -12,6 +12,21 @@ package com.ponzischeme89.memby.ui.requests
|
||||
*/
|
||||
object RequestStatus {
|
||||
const val AVAILABLE = "available"
|
||||
|
||||
/**
|
||||
* The four states a download client can answer for, which between them cover the span
|
||||
* that used to be [PROCESSING] alone.
|
||||
*
|
||||
* They arrive only on a build that declares `request_progress_v1`; the gateway narrows
|
||||
* all four back to [PROCESSING] for anything older, so this vocabulary is additive
|
||||
* rather than a change of meaning on the wire.
|
||||
*/
|
||||
const val SEARCHING = "searching"
|
||||
const val FOUND = "found"
|
||||
const val DOWNLOADING = "downloading"
|
||||
const val FAILED = "failed"
|
||||
|
||||
/** Now the last step only: the bytes have landed and are being filed away. */
|
||||
const val PROCESSING = "processing"
|
||||
const val PENDING = "pending"
|
||||
const val REQUESTED = "requested"
|
||||
@@ -30,9 +45,21 @@ enum class RequestTone { POSITIVE, ACTIVE, WAITING, NEUTRAL }
|
||||
|
||||
fun requestStatusTone(status: String): RequestTone = when (status) {
|
||||
RequestStatus.AVAILABLE -> RequestTone.POSITIVE
|
||||
RequestStatus.PROCESSING -> RequestTone.ACTIVE
|
||||
RequestStatus.PENDING -> RequestTone.WAITING
|
||||
RequestStatus.REQUESTED -> RequestTone.ACTIVE
|
||||
// Everything Memby is actively doing shares one colour, so a card moving from searching
|
||||
// to downloading to processing does not change colour three times on its way to green.
|
||||
// The chip's *wording* is what tracks the progress; the colour tracks whether anything
|
||||
// is happening at all.
|
||||
RequestStatus.SEARCHING,
|
||||
RequestStatus.FOUND,
|
||||
RequestStatus.DOWNLOADING,
|
||||
RequestStatus.PROCESSING,
|
||||
RequestStatus.REQUESTED,
|
||||
-> RequestTone.ACTIVE
|
||||
// Amber rather than red, and deliberately the same amber as PENDING: both mean "waiting
|
||||
// on something nobody in this house controls". A failed download is not a dead end —
|
||||
// the *arr goes back to looking — and a red chip would tell a viewer to do something
|
||||
// about it when there is nothing for them to do.
|
||||
RequestStatus.PENDING, RequestStatus.FAILED -> RequestTone.WAITING
|
||||
// Unavailable and anything this build has never heard of share the quiet treatment.
|
||||
// A state with no meaning here must not borrow a colour that claims one.
|
||||
else -> RequestTone.NEUTRAL
|
||||
@@ -47,15 +74,51 @@ fun requestStatusLabel(status: String, serverLabel: String): String {
|
||||
val supplied = serverLabel.trim()
|
||||
if (supplied.isNotEmpty()) return supplied
|
||||
return when (status) {
|
||||
RequestStatus.AVAILABLE -> "Available"
|
||||
RequestStatus.AVAILABLE -> "Ready to watch"
|
||||
RequestStatus.SEARCHING -> "Searching"
|
||||
RequestStatus.FOUND -> "Found"
|
||||
RequestStatus.DOWNLOADING -> "Downloading"
|
||||
RequestStatus.PROCESSING -> "Processing"
|
||||
RequestStatus.PENDING -> "Pending"
|
||||
RequestStatus.FAILED -> "Unable to download"
|
||||
RequestStatus.UNAVAILABLE -> "Unavailable"
|
||||
RequestStatus.REQUESTABLE -> "Request"
|
||||
else -> "Requested"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The badge's wording with the percentage folded in: `Downloading · 43%`.
|
||||
*
|
||||
* The number is composed here rather than sent as part of the label, because the two change
|
||||
* at completely different rates — the wording is fixed for the length of a download and the
|
||||
* figure moves every poll. A server sentence carrying the percentage would be a sentence
|
||||
* that is stale the moment it is drawn, and it would mean the gateway could not cache a
|
||||
* response for even a few seconds without lying about a number.
|
||||
*
|
||||
* Zero is treated as no figure, matching the wire: the gateway omits `progress` whenever
|
||||
* nothing could say, so a download that has genuinely not moved a byte and one nothing has
|
||||
* measured both read as plain "Downloading". Neither is a number worth printing.
|
||||
*/
|
||||
fun requestStatusChipLabel(status: String, serverLabel: String, progress: Int): String {
|
||||
val label = requestStatusLabel(status, serverLabel)
|
||||
if (status != RequestStatus.DOWNLOADING || progress !in 1..100) return label
|
||||
return "$label · $progress%"
|
||||
}
|
||||
|
||||
/**
|
||||
* How far along to draw the bar, or null when there is no bar to draw.
|
||||
*
|
||||
* A bar is only ever drawn against moving bytes. Every other state has no measurable
|
||||
* fraction — a search has no denominator, an import is over in seconds — and a progress bar
|
||||
* standing at some arbitrary place under a card that is being searched for would be the
|
||||
* single most misleading thing on the page.
|
||||
*/
|
||||
fun requestProgressFraction(status: String, progress: Int): Float? {
|
||||
if (status != RequestStatus.DOWNLOADING || progress !in 1..100) return null
|
||||
return progress / 100f
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether pressing a search result would do anything.
|
||||
*
|
||||
@@ -148,11 +211,46 @@ enum class RequestGroup(val heading: String) {
|
||||
|
||||
fun requestGroupFor(status: String): RequestGroup = when (status) {
|
||||
RequestStatus.AVAILABLE -> RequestGroup.READY
|
||||
RequestStatus.PROCESSING, RequestStatus.PENDING, RequestStatus.REQUESTED ->
|
||||
RequestGroup.IN_PROGRESS
|
||||
// Failed sits under "On the way" with the rest, which is not a euphemism: the *arr goes
|
||||
// straight back to looking for another release, so a viewer whose download failed is in
|
||||
// exactly the position of one whose search has not turned anything up yet. Filing it
|
||||
// under "Nothing happening" would say the opposite of what is true.
|
||||
RequestStatus.SEARCHING,
|
||||
RequestStatus.FOUND,
|
||||
RequestStatus.DOWNLOADING,
|
||||
RequestStatus.PROCESSING,
|
||||
RequestStatus.PENDING,
|
||||
RequestStatus.REQUESTED,
|
||||
RequestStatus.FAILED,
|
||||
-> RequestGroup.IN_PROGRESS
|
||||
else -> RequestGroup.CLOSED
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page has any reason to keep asking.
|
||||
*
|
||||
* The requests page is the one screen in the app whose content changes while somebody looks
|
||||
* at it without them touching anything — that is the whole point of showing a percentage —
|
||||
* so it polls. But it polls only while something is moving: a page of titles that all
|
||||
* arrived last week, or that are all waiting on a release date months out, has nothing to
|
||||
* refresh and must not spend a request every few seconds saying so on every television in
|
||||
* the house.
|
||||
*
|
||||
* [RequestStatus.PENDING] is deliberately excluded. A film waiting on its digital release is
|
||||
* not going to change in the next thirty seconds, and the ordinary refresh when somebody
|
||||
* comes back to the page is soon enough.
|
||||
*/
|
||||
fun requestsWorthPolling(statuses: List<String>): Boolean = statuses.any {
|
||||
it == RequestStatus.SEARCHING ||
|
||||
it == RequestStatus.FOUND ||
|
||||
it == RequestStatus.DOWNLOADING ||
|
||||
it == RequestStatus.PROCESSING ||
|
||||
it == RequestStatus.FAILED ||
|
||||
// A request the gateway could not establish a state for is one worth asking about
|
||||
// again: it usually means an *arr was restarting, which is a condition that clears.
|
||||
it == RequestStatus.REQUESTED
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's summary line. It counts what arrived rather than the total, because that is the
|
||||
* number somebody came to the page for; the total is already visible as the length of the
|
||||
|
||||
@@ -419,6 +419,7 @@ private fun MyRequestsPane(
|
||||
},
|
||||
status = request.status,
|
||||
statusLabel = requestStatusLabel(request.status, request.statusLabel),
|
||||
progress = request.progress,
|
||||
mediaType = request.mediaType,
|
||||
artworkUrl = posterUrlFor(request.posterUrl),
|
||||
// A request that has arrived opens the thing it became; anything else
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -83,6 +84,16 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
private var refreshJob: Job? = null
|
||||
private var searchJob: Job? = null
|
||||
|
||||
/**
|
||||
* How many of the viewer's own presses are still in flight.
|
||||
*
|
||||
* A poll is a second writer of the same list, and it holds the *server's* answer — so
|
||||
* one landing between an optimistic removal and the call that performs it would put the
|
||||
* row back under the thumb that had just dismissed it. Counting rather than flagging,
|
||||
* because a viewer can walk down the pane pressing several before the first answers.
|
||||
*/
|
||||
private var pendingMutations = 0
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
@@ -207,6 +218,7 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
if (key in _state.value.submitting) return
|
||||
if (!requestCandidateActionable(candidate.status)) return
|
||||
_state.update { it.copy(submitting = it.submitting + key, notice = null) }
|
||||
pendingMutations++
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.requestMedia(candidate) }
|
||||
.onSuccess { title ->
|
||||
@@ -240,6 +252,7 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingMutations--
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +274,7 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
notice = null,
|
||||
)
|
||||
}
|
||||
pendingMutations++
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.removeMediaRequest(request.mediaType, request.foreignId) }
|
||||
.onFailure { error ->
|
||||
@@ -276,9 +290,62 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingMutations--
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the list current while somebody is looking at it.
|
||||
*
|
||||
* This is the one page in the app whose content changes while it is on screen without
|
||||
* anybody touching anything — a percentage that never moves is not a percentage — so it
|
||||
* asks again on a timer. Three things bound what that costs, and all three matter:
|
||||
*
|
||||
* - **It is the caller's coroutine, not [viewModelScope].** The view model is keyed on
|
||||
* the profile and outlives the page, so a loop of its own would go on polling with the
|
||||
* page closed and the launcher in front of somebody. The host runs this inside
|
||||
* `repeatOnLifecycle(STARTED)`, which is also what stops a backgrounded television
|
||||
* asking every fifteen seconds at a set nobody is watching — the status loop's stance.
|
||||
* - **It asks only while something is moving** ([requestsWorthPolling]). A page of
|
||||
* titles that all arrived last week has nothing to refresh, and must not spend a
|
||||
* request saying so on every television in the house.
|
||||
* - **It asks only over the pane it would change.** With the search half open the list
|
||||
* is not on screen, and coming back to it refreshes anyway ([selectTab]).
|
||||
*/
|
||||
suspend fun pollWhileVisible() {
|
||||
while (true) {
|
||||
delay(POLL_INTERVAL_MS)
|
||||
val current = _state.value
|
||||
if (current.tab != RequestsTab.MINE) continue
|
||||
if (pendingMutations > 0) continue
|
||||
if (!requestsWorthPolling(current.requests.map(GatewayMediaRequestItem::status))) continue
|
||||
refreshQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A refresh nothing on screen reacts to except the cards themselves.
|
||||
*
|
||||
* It deliberately does not touch `loadingRequests` or `requestsError`: the summary line
|
||||
* stands down while that flag is up, so a poll would blink it away every fifteen
|
||||
* seconds, and a single failed poll must not replace a list somebody is reading with an
|
||||
* apology. A poll that fails is simply a poll that changed nothing — the next one is
|
||||
* fifteen seconds away, and the page's own Try again is still there for a list that
|
||||
* never loaded in the first place.
|
||||
*/
|
||||
private suspend fun refreshQuietly() {
|
||||
runCatching { repository.getMyRequests() }
|
||||
.onSuccess { list ->
|
||||
// The viewer may have pressed something while the answer was in flight, and
|
||||
// their press is the more recent truth.
|
||||
if (pendingMutations > 0) return@onSuccess
|
||||
_state.update { it.copy(requests = list.requests, allowed = list.allowed) }
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotice() = _state.update { it.copy(notice = null) }
|
||||
|
||||
companion object {
|
||||
@@ -292,6 +359,17 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
* genuinely be looking for.
|
||||
*/
|
||||
const val MIN_QUERY_LENGTH = 2
|
||||
|
||||
/**
|
||||
* Fifteen seconds, which is the gateway's own `requestQueueTTL`.
|
||||
*
|
||||
* The two are the same figure on purpose: the download queues behind this answer are
|
||||
* cached for that window precisely so a house full of televisions polling costs one
|
||||
* upstream read, and asking any faster would spend a round trip to be handed the
|
||||
* same cached answer back. Slower and the number on the card would visibly lag the
|
||||
* one the gateway already holds.
|
||||
*/
|
||||
const val POLL_INTERVAL_MS = 15_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,33 @@ class RadarrMovieDetailScreenshotTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The worst case the gateway can produce: a title that wraps onto two lines, an
|
||||
* original title under it, four facts, scores, three release dates, a long description
|
||||
* and a trailer to offer. The claim being checked is that the buttons are still whole
|
||||
* and still on the page — the supporting material is what gives way, not the one thing
|
||||
* anybody came here to press.
|
||||
*/
|
||||
@Test
|
||||
fun `a film carrying everything at once`() {
|
||||
capture("radarr-crowded") {
|
||||
RadarrMovieDetailContent(
|
||||
card = card,
|
||||
detail = full.copy(
|
||||
title = "The Quiet Coast and the Long Way Back to Whangaparāoa",
|
||||
originalTitle = "Te Takutai Marino",
|
||||
overview = "A harbour town in winter, and the constable who has stopped " +
|
||||
"pretending the tide brings anything back. Adapted from the novel, " +
|
||||
"and filmed over two winters on the coast it is named for, with a " +
|
||||
"cast drawn almost entirely from the towns along it. The first of " +
|
||||
"three the studio has announced.",
|
||||
),
|
||||
onPlayTrailer = {},
|
||||
onClose = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening frame, before the request answers. The page is drawn from the card that
|
||||
* was pressed, so what matters is that it is already recognisably this film rather than
|
||||
|
||||
@@ -46,6 +46,26 @@ class RequestsScreenshotTest {
|
||||
capture("requests-mine", RequestsUiState(requests = sampleRequests, loadingRequests = false))
|
||||
}
|
||||
|
||||
/**
|
||||
* The four states a download client can answer for, which is the whole reason the page
|
||||
* polls.
|
||||
*
|
||||
* It is its own capture rather than four more cards on the ordinary one because the
|
||||
* thing worth looking at here is a comparison a unit test cannot make: the bar under
|
||||
* "Downloading - 43%" against the identical card whose client would not say how far
|
||||
* along it is, which must draw no bar at all rather than an empty one. A card whose
|
||||
* figure is missing and a card that has genuinely not moved a byte are the same picture
|
||||
* on purpose — neither is a number worth printing — and the sentence underneath is what
|
||||
* carries the difference.
|
||||
*/
|
||||
@Test
|
||||
fun `a download in progress`() {
|
||||
capture(
|
||||
"requests-mine-downloading",
|
||||
RequestsUiState(requests = downloadingRequests, loadingRequests = false),
|
||||
)
|
||||
}
|
||||
|
||||
/** Nothing asked for yet. The empty state has a control, so focus has somewhere to go. */
|
||||
@Test
|
||||
fun `nothing requested yet`() {
|
||||
@@ -237,6 +257,37 @@ class RequestsScreenshotTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One card per download state, in the order a request travels through them.
|
||||
*
|
||||
* The wording is the gateway's own, copied from `requestStatusDetail`, so the capture cannot
|
||||
* quietly disagree with what a television is actually sent.
|
||||
*/
|
||||
private val downloadingRequests = listOf(
|
||||
GatewayMediaRequestItem(
|
||||
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,
|
||||
requestedAt = "2026-08-19T09:00:00Z", status = RequestStatus.SEARCHING,
|
||||
statusLabel = "Searching", statusDetail = "We haven't found a suitable release yet",
|
||||
),
|
||||
GatewayMediaRequestItem(
|
||||
mediaType = "series", foreignId = 121361, title = "Silo", year = 2023,
|
||||
requestedAt = "2026-08-19T08:40:00Z", status = RequestStatus.DOWNLOADING,
|
||||
statusLabel = "Downloading", statusDetail = "Estimated ready in ~14 minutes",
|
||||
progress = 43, estimatedReadySeconds = 840,
|
||||
),
|
||||
GatewayMediaRequestItem(
|
||||
mediaType = "movie", foreignId = 533535, title = "The Thursday Murder Club", year = 2026,
|
||||
requestedAt = "2026-08-19T08:20:00Z", status = RequestStatus.DOWNLOADING,
|
||||
statusLabel = "Downloading", statusDetail = "We'll let you know when it's ready",
|
||||
),
|
||||
GatewayMediaRequestItem(
|
||||
mediaType = "series", foreignId = 94997, title = "Slow Horses", year = 2022,
|
||||
requestedAt = "2026-08-19T07:55:00Z", status = RequestStatus.FAILED,
|
||||
statusLabel = "Unable to download",
|
||||
statusDetail = "Memby will keep looking for another release",
|
||||
),
|
||||
)
|
||||
|
||||
private val sampleRequests = listOf(
|
||||
GatewayMediaRequestItem(
|
||||
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/runtimestats"
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
@@ -299,11 +300,17 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
return err
|
||||
}
|
||||
sched.SetPaused(server.ActivityPaused)
|
||||
// The ranker calls Tracearr directly on the rebuild path, so it needs the operator's
|
||||
// switch too — read at call time, because an integration switched off in the console
|
||||
// must stop being called without a restart. It is wired here rather than where the
|
||||
// engine is built because the switch belongs to the server, which does not exist yet
|
||||
// at that point.
|
||||
recommender.TracearrAllowed = server.TracearrEnabled
|
||||
dispatcher.SetPaused(server.ActivityPaused)
|
||||
dispatcher.Start(ctx)
|
||||
if creditsService != nil {
|
||||
creditsService.SetPaused(server.ActivityPaused)
|
||||
go creditsService.Run(ctx)
|
||||
runtimestats.Go("Credits detection", "Credits detection", func() { creditsService.Run(ctx) })
|
||||
}
|
||||
if ingester != nil {
|
||||
// Quiet time is honoured here rather than at the hook: the webhook is recorded
|
||||
@@ -313,7 +320,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
// is there rather than that it is coming. Installed here for the same reason
|
||||
// SetAfterSync is: library stays ignorant of what an alert is.
|
||||
ingester.Announce = server.AnnounceLibraryIngest
|
||||
go ingester.Run(ctx)
|
||||
runtimestats.Go("Library ingest", "Library sync", func() { ingester.Run(ctx) })
|
||||
}
|
||||
|
||||
// Registration is separate from construction so the task list reads as a declaration
|
||||
@@ -321,6 +328,12 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
server.RegisterHousekeeping(sched)
|
||||
server.RegisterCreditsTasks(sched)
|
||||
server.RegisterWatchTimeTasks(sched)
|
||||
server.RegisterRequestTasks(sched)
|
||||
// Sonarr's daily catalogue reading, Radarr's film refresh, the Tracearr import and the
|
||||
// For You rebuild all used to be goroutines with tickers of their own here. They are
|
||||
// scheduler tasks now, which is what gives the console's integrations area a run
|
||||
// history without a second store — and what lets an operator run any of them by hand.
|
||||
server.RegisterIntegrationTasks(sched)
|
||||
sched.Start(ctx)
|
||||
|
||||
// Installed after the server exists, because both halves of a finished import are
|
||||
@@ -344,62 +357,60 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
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.WatchQuietTime(ctx, 30*time.Second)
|
||||
// Every long-running worker below is started through runtimestats.Go rather than with
|
||||
// a bare `go`, so the admin console can name what is running instead of reporting a
|
||||
// count of goroutines nobody can interpret. The name is the worker's identity across
|
||||
// restarts and appears verbatim in the console, so it is wording rather than a symbol.
|
||||
runtimestats.Go("Gateway settings watcher", "Gateway settings", func() {
|
||||
server.WatchGatewaySettings(ctx, 30*time.Second)
|
||||
})
|
||||
runtimestats.Go("Maintenance watcher", "Gateway settings", func() {
|
||||
server.WatchMaintenance(ctx, 30*time.Second)
|
||||
})
|
||||
runtimestats.Go("Quiet time watcher", "Gateway settings", func() {
|
||||
server.WatchQuietTime(ctx, 30*time.Second)
|
||||
})
|
||||
// One probe per gateway, not per TV: the answer is the same for the whole house.
|
||||
go server.WatchEmbyReachability(ctx)
|
||||
// One Sonarr catalogue reading per day records lifecycle changes for the household and
|
||||
// materialises cancellation notifications for every known viewer.
|
||||
go server.WatchSonarrLifecycle(ctx, 24*time.Hour)
|
||||
runtimestats.Go("Emby health probe", "Emby", func() { server.WatchEmbyReachability(ctx) })
|
||||
// The trend ring behind the console's runtime page. A single instantaneous goroutine
|
||||
// or heap figure cannot show a leak; this is what makes one visible.
|
||||
runtimestats.Go("Runtime sampler", "Gateway API", func() { runtimestats.StartSampling(ctx) })
|
||||
|
||||
if err := server.LoadUpdatePolicy(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
go server.WatchUpdatePolicy(ctx, 60*time.Second)
|
||||
runtimestats.Go("App update policy watcher", "Gateway settings", func() {
|
||||
server.WatchUpdatePolicy(ctx, 60*time.Second)
|
||||
})
|
||||
|
||||
go syncer.Schedule(ctx, server.LibrarySyncInterval, server.ActivityPaused)
|
||||
runtimestats.Go("Library sync schedule", "Library sync", func() {
|
||||
syncer.Schedule(ctx, server.LibrarySyncInterval, server.ActivityPaused)
|
||||
})
|
||||
if cfg.SyncOnStart {
|
||||
go func() {
|
||||
runtimestats.Go("Startup library sync", "Library sync", func() {
|
||||
if server.ActivityPaused() {
|
||||
return
|
||||
}
|
||||
if _, err := syncer.Sync(ctx, "incremental", "startup"); err != nil {
|
||||
log.Warn("startup sync failed", "error", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
if forYouService != nil {
|
||||
go forYouService.Schedule(
|
||||
ctx,
|
||||
cfg.TracearrSyncInterval,
|
||||
cfg.TracearrFullInterval,
|
||||
cfg.ForYouRebuildHour,
|
||||
server.ActivityPaused,
|
||||
)
|
||||
go func() {
|
||||
// The import and the daily rebuild are scheduler tasks now — see
|
||||
// RegisterIntegrationTasks — so what is left here is the one piece of start-up work
|
||||
// neither of them is: profiles produced by an older algorithm. Only a version change
|
||||
// warrants it, and normal dirty profiles still wait for the daily off-peak rebuild.
|
||||
runtimestats.Go("Startup For You migration", "Recommendations", func() {
|
||||
if server.ActivityPaused() {
|
||||
return
|
||||
}
|
||||
importCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout)
|
||||
// Only if one is actually owed. An unconditional startup import made every
|
||||
// redeploy or container bounce a fresh pass over Tracearr's history.
|
||||
if _, _, err := forYouService.ImportIfDue(
|
||||
importCtx, cfg.TracearrSyncInterval, cfg.TracearrFullInterval,
|
||||
); err != nil {
|
||||
cancel()
|
||||
log.Warn("startup Tracearr import failed", "error", err)
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
// Only an algorithm-version change warrants startup work. Normal dirty
|
||||
// profiles wait for the daily off-peak rebuild.
|
||||
rebuildCtx, rebuildCancel := context.WithTimeout(ctx, cfg.SyncTimeout)
|
||||
defer rebuildCancel()
|
||||
rebuildCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout)
|
||||
defer cancel()
|
||||
if err := forYouService.RebuildOutdated(rebuildCtx); err != nil {
|
||||
log.Warn("startup outdated For You rebuild failed", "error", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
@@ -410,7 +421,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
runtimestats.Go("HTTP listener", "HTTP server", func() {
|
||||
log.Info("gateway ready",
|
||||
"listen", cfg.ListenAddr,
|
||||
"emby", cfg.EmbyURL,
|
||||
@@ -423,7 +434,7 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
|
||||
@@ -5,10 +5,8 @@ import (
|
||||
"crypto/subtle"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +14,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/appupdate"
|
||||
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
||||
"github.com/ponzischeme89/memby/server/internal/library"
|
||||
"github.com/ponzischeme89/memby/server/internal/runtimestats"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -54,6 +53,9 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches))
|
||||
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
|
||||
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
||||
// The breakdown is its own route because it costs its own money: walking every
|
||||
// goroutine's stack stops the world, so it is asked for rather than polled.
|
||||
mux.Handle("GET /admin/api/runtime/goroutines", s.adminAuth(s.handleAdminGoroutines))
|
||||
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
||||
mux.Handle("GET /admin/api/ingest", s.adminAuth(s.handleAdminIngest))
|
||||
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
|
||||
@@ -101,6 +103,18 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
// abandoned tab's session alive.
|
||||
mux.Handle("GET /admin/api/notifications/stream", s.adminAuth(s.handleAdminNotificationStream))
|
||||
|
||||
// The Integrations area. Deliberately a longer path than /admin/api/integrations,
|
||||
// which is the older, narrower question — the webhook destinations administrative
|
||||
// events are posted to. These are the external services Memby depends on.
|
||||
mux.Handle("GET /admin/api/integrations/services",
|
||||
s.adminAuth(s.handleAdminIntegrationServices))
|
||||
mux.Handle("GET /admin/api/integrations/services/{integrationID}",
|
||||
s.adminAuth(s.handleAdminIntegrationService))
|
||||
mux.Handle("POST /admin/api/integrations/services/{integrationID}/enabled",
|
||||
s.adminAuth(s.handleAdminIntegrationEnabled))
|
||||
mux.Handle("POST /admin/api/integrations/services/{integrationID}/test",
|
||||
s.adminAuth(s.handleAdminIntegrationTest))
|
||||
|
||||
mux.Handle("GET /admin/api/integrations", s.adminAuth(s.handleAdminIntegrations))
|
||||
mux.Handle("POST /admin/api/integrations", s.adminAuth(s.handleAdminSaveIntegration))
|
||||
mux.Handle("DELETE /admin/api/integrations/{integrationID}", s.adminAuth(s.handleAdminDeleteIntegration))
|
||||
@@ -134,33 +148,34 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
type adminRuntimeStatus struct {
|
||||
Goroutines int `json:"goroutines"`
|
||||
GOMAXPROCS int `json:"gomaxprocs"`
|
||||
HeapAlloc uint64 `json:"heapAlloc"`
|
||||
HeapInuse uint64 `json:"heapInuse"`
|
||||
HeapIdle uint64 `json:"heapIdle"`
|
||||
HeapReleased uint64 `json:"heapReleased"`
|
||||
StackInuse uint64 `json:"stackInuse"`
|
||||
Sys uint64 `json:"sys"`
|
||||
NextGC uint64 `json:"nextGc"`
|
||||
NumGC uint32 `json:"numGc"`
|
||||
MemoryLimit int64 `json:"memoryLimit"`
|
||||
ConfiguredLim string `json:"configuredLimit,omitempty"`
|
||||
// The console's Process card used to be handed a bare goroutine count, which is a number
|
||||
// nobody can act on: it says nothing about what those goroutines are doing, which part of
|
||||
// Memby they belong to, or whether it has been climbing all week. Everything behind these
|
||||
// two routes lives in internal/runtimestats, including the health verdict, so the
|
||||
// thresholds and the sentences explaining them sit beside each other and can be tested.
|
||||
func (s *Server) handleAdminRuntime(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, runtimestats.Read())
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminRuntime(w http.ResponseWriter, _ *http.Request) {
|
||||
var memory runtime.MemStats
|
||||
runtime.ReadMemStats(&memory)
|
||||
// handleAdminGoroutines is the on-demand snapshot. `?format=text` returns the raw dump for
|
||||
// an operator who wants the whole thing — which is the alternative to leaving a profiling
|
||||
// endpoint permanently mounted, and unlike one it is behind the admin guard and produces
|
||||
// nothing until somebody asks.
|
||||
func (s *Server) handleAdminGoroutines(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, adminRuntimeStatus{
|
||||
Goroutines: runtime.NumGoroutine(), GOMAXPROCS: runtime.GOMAXPROCS(0),
|
||||
HeapAlloc: memory.HeapAlloc, HeapInuse: memory.HeapInuse,
|
||||
HeapIdle: memory.HeapIdle, HeapReleased: memory.HeapReleased,
|
||||
StackInuse: memory.StackInuse, Sys: memory.Sys,
|
||||
NextGC: memory.NextGC, NumGC: memory.NumGC,
|
||||
MemoryLimit: debug.SetMemoryLimit(-1), ConfiguredLim: os.Getenv("GOMEMLIMIT"),
|
||||
})
|
||||
log := s.loggerFor(r.Context())
|
||||
if r.URL.Query().Get("format") == "text" {
|
||||
log.Info("goroutine dump requested", "component", "admin")
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=memby-goroutines.txt")
|
||||
_, _ = io.WriteString(w, runtimestats.StackDump())
|
||||
return
|
||||
}
|
||||
report := runtimestats.CollectGoroutines()
|
||||
log.Info("goroutine breakdown collected",
|
||||
"component", "admin", "goroutines", report.Total, "took_ms", report.CollectedInMs)
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -686,7 +701,7 @@ func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.forYou.RebuildAll(ctx, true); err != nil {
|
||||
if _, err := s.forYou.RebuildAll(ctx, true); err != nil {
|
||||
s.log.Error("manual For You rebuild failed",
|
||||
"component", "admin", "action", req.Action, "error", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The Integrations area's API.
|
||||
//
|
||||
// One service is one row, and the row is assembled from three things the gateway already
|
||||
// holds: the catalogue (what this service is and where its switch lives), the scheduler
|
||||
// (what work belongs to it, when it last ran and when it is next due) and the run table
|
||||
// read along the integration axis (what that work actually did). Nothing here is stored
|
||||
// for the console's benefit.
|
||||
//
|
||||
// The status word is the gateway's, not the console's — the stance every label Memby
|
||||
// prints takes. A console that decided for itself what "unhealthy" meant would have an
|
||||
// older build disagreeing with a newer one about the same server, and the threshold and
|
||||
// the sentence explaining it belong together.
|
||||
|
||||
// Integration status words, in the order they outrank each other. See integrationStatus.
|
||||
const (
|
||||
integrationStatusUnconfigured = "unconfigured"
|
||||
integrationStatusDisabled = "disabled"
|
||||
integrationStatusRunning = "running"
|
||||
integrationStatusError = "error"
|
||||
integrationStatusHealthy = "healthy"
|
||||
integrationStatusIdle = "idle"
|
||||
)
|
||||
|
||||
// integrationView is one service as the console reads it.
|
||||
type integrationView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Configured bool `json:"configured"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// Status is one word and StatusLabel is what to print; Detail is the sentence behind
|
||||
// it — the failure reason, or what is running, or why there is nothing to say.
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"statusLabel"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
// Powers is what stops working when this is switched off, so a dependency is stated
|
||||
// rather than discovered by something silently not happening.
|
||||
Powers []string `json:"powers"`
|
||||
|
||||
LastRun *store.TaskRun `json:"lastRun,omitempty"`
|
||||
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
||||
LastFailureAt *time.Time `json:"lastFailureAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextRun *time.Time `json:"nextRun,omitempty"`
|
||||
Runs int `json:"runs"`
|
||||
Failures int `json:"failures"`
|
||||
|
||||
// Health is the last reachability probe, absent for a service that is not probed.
|
||||
Health *integrationHealth `json:"health,omitempty"`
|
||||
// Probed says whether this service can be probed at all, which is what stops the
|
||||
// console drawing "never checked" beside MDBList for ever.
|
||||
Probed bool `json:"probed"`
|
||||
|
||||
Tasks []scheduler.Status `json:"tasks"`
|
||||
Facts []integrationFact `json:"facts,omitempty"`
|
||||
}
|
||||
|
||||
// integrationStatus resolves one word per service, in priority order, because a status
|
||||
// column with two answers in it is one nobody can scan.
|
||||
//
|
||||
// Not configured outranks everything: a service with no address has no switch, no work and
|
||||
// no health, and drawing it as "off" would suggest turning it on is a thing an operator
|
||||
// could do from this page. Switched off comes next for the same reason a disabled task
|
||||
// does — yesterday's success beside a service nobody is running reads as one that is still
|
||||
// working. A run in flight outranks its own history, and below those the service is
|
||||
// described by whichever of its probe and its last run has something to say. Idle is the
|
||||
// honest answer for a configured, enabled service that has not yet done anything: it is
|
||||
// neither working nor broken, and claiming either would be a guess.
|
||||
func integrationStatus(view integrationView) (string, string, string) {
|
||||
switch {
|
||||
case !view.Configured:
|
||||
return integrationStatusUnconfigured, "Not configured",
|
||||
"No address or credential is set for this service."
|
||||
case !view.Enabled:
|
||||
return integrationStatusDisabled, "Disabled",
|
||||
"Switched off: Memby schedules no work for it and makes no requests to it."
|
||||
case view.Running:
|
||||
return integrationStatusRunning, "Running", runningDetail(view.Tasks)
|
||||
}
|
||||
if view.Health != nil && !view.Health.Reachable && !view.Health.CheckedAt.IsZero() {
|
||||
return integrationStatusError, "Error", view.Health.Error
|
||||
}
|
||||
// A failure that has been followed by a success is history, not a verdict. Without
|
||||
// that comparison one bad night would leave a service red until the retention job
|
||||
// eventually removed the row.
|
||||
if view.LastFailureAt != nil &&
|
||||
(view.LastSuccessAt == nil || view.LastSuccessAt.Before(*view.LastFailureAt)) {
|
||||
return integrationStatusError, "Error", view.LastError
|
||||
}
|
||||
if view.Health != nil && view.Health.Reachable {
|
||||
return integrationStatusHealthy, "Healthy", ""
|
||||
}
|
||||
if view.LastSuccessAt != nil {
|
||||
return integrationStatusHealthy, "Healthy", ""
|
||||
}
|
||||
return integrationStatusIdle, "Idle", "Nothing has run for this service yet."
|
||||
}
|
||||
|
||||
func runningDetail(tasks []scheduler.Status) string {
|
||||
for _, task := range tasks {
|
||||
if task.Running {
|
||||
return task.Name + " is running now."
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// integrationViews assembles every service. One pass over the catalogue, one scheduler
|
||||
// snapshot and one grouped query, whatever the catalogue's length.
|
||||
func (s *Server) integrationViews(ctx context.Context) []integrationView {
|
||||
summaries := map[string]store.IntegrationRunSummary{}
|
||||
if s.store != nil {
|
||||
if read, err := s.store.IntegrationRunSummaries(ctx); err == nil {
|
||||
summaries = read
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("integration run summaries unavailable", "error", err)
|
||||
}
|
||||
}
|
||||
tasksByIntegration := map[string][]scheduler.Status{}
|
||||
if s.scheduler != nil {
|
||||
for _, task := range s.scheduler.Snapshot() {
|
||||
if task.Integration == "" {
|
||||
continue
|
||||
}
|
||||
tasksByIntegration[task.Integration] = append(
|
||||
tasksByIntegration[task.Integration], task)
|
||||
}
|
||||
}
|
||||
|
||||
views := make([]integrationView, 0, len(integrationCatalogue()))
|
||||
for _, definition := range integrationCatalogue() {
|
||||
view := integrationView{
|
||||
ID: definition.ID,
|
||||
Name: definition.Name,
|
||||
Summary: definition.Summary,
|
||||
Powers: definition.Powers,
|
||||
Configured: definition.Configured(s),
|
||||
Probed: definition.Probe != nil,
|
||||
Tasks: tasksByIntegration[definition.ID],
|
||||
}
|
||||
if view.Tasks == nil {
|
||||
view.Tasks = []scheduler.Status{}
|
||||
}
|
||||
if view.Configured {
|
||||
view.Address = definition.Address(s)
|
||||
view.Enabled = definition.Enabled(s, ctx)
|
||||
}
|
||||
if summary, ok := summaries[definition.ID]; ok {
|
||||
view.LastSuccessAt = summary.LastSuccessAt
|
||||
view.LastFailureAt = summary.LastFailureAt
|
||||
view.LastError = summary.LastError
|
||||
view.Runs, view.Failures = summary.Runs, summary.Failures
|
||||
}
|
||||
// The most recent run and the next due one come from the scheduler rather than
|
||||
// from a query: it holds both, and the next run has no row anywhere to read.
|
||||
for _, task := range view.Tasks {
|
||||
if task.Running {
|
||||
view.Running = true
|
||||
}
|
||||
if task.LastRun != nil &&
|
||||
(view.LastRun == nil || task.LastRun.StartedAt.After(view.LastRun.StartedAt)) {
|
||||
last := *task.LastRun
|
||||
view.LastRun = &last
|
||||
}
|
||||
if task.NextRun != nil &&
|
||||
(view.NextRun == nil || task.NextRun.Before(*view.NextRun)) {
|
||||
next := *task.NextRun
|
||||
view.NextRun = &next
|
||||
}
|
||||
}
|
||||
// A switched-off service has no next run to promise, whatever the scheduler still
|
||||
// holds: its tasks stand down as soon as they start.
|
||||
if !view.Enabled {
|
||||
view.NextRun = nil
|
||||
}
|
||||
if state, ok := s.integrationHealth.get(definition.ID); ok {
|
||||
health := state
|
||||
view.Health = &health
|
||||
}
|
||||
view.Status, view.StatusLabel, view.Detail = integrationStatus(view)
|
||||
views = append(views, view)
|
||||
}
|
||||
// Anything wrong sorts to the top, the order the tasks table takes and for the same
|
||||
// reason: a page read from the top down should not need a sort to find the one thing
|
||||
// that is broken.
|
||||
sort.SliceStable(views, func(i, j int) bool {
|
||||
return integrationRank(views[i].Status) < integrationRank(views[j].Status)
|
||||
})
|
||||
return views
|
||||
}
|
||||
|
||||
func integrationRank(status string) int {
|
||||
switch status {
|
||||
case integrationStatusError:
|
||||
return 0
|
||||
case integrationStatusRunning:
|
||||
return 1
|
||||
case integrationStatusIdle:
|
||||
return 2
|
||||
case integrationStatusHealthy:
|
||||
return 3
|
||||
case integrationStatusDisabled:
|
||||
return 4
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminIntegrationServices(w http.ResponseWriter, r *http.Request) {
|
||||
views := s.integrationViews(r.Context())
|
||||
runs, err := s.store.IntegrationRuns(r.Context(), "", integrationRunLimit(r))
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Warn("integration runs unavailable", "error", err)
|
||||
runs = []store.TaskRun{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"services": views,
|
||||
// Every service's runs interleaved, which is the view that shows two integrations
|
||||
// getting in each other's way — the same reason the tasks page carries one.
|
||||
"runs": runs,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminIntegrationService(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("integrationID"))
|
||||
definition, ok := integrationDefinitionFor(id)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no such integration")
|
||||
return
|
||||
}
|
||||
var view integrationView
|
||||
for _, candidate := range s.integrationViews(r.Context()) {
|
||||
if candidate.ID == id {
|
||||
view = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
// Facts are read only on the detail page: several of them are database queries, and
|
||||
// the overview draws four services at a poll.
|
||||
if definition.Facts != nil && view.Configured {
|
||||
view.Facts = definition.Facts(s, r.Context())
|
||||
}
|
||||
runs, err := s.store.IntegrationRuns(r.Context(), id, integrationRunLimit(r))
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Warn("integration runs unavailable",
|
||||
"integration", id, "error", err)
|
||||
runs = []store.TaskRun{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"service": view, "runs": runs})
|
||||
}
|
||||
|
||||
func integrationRunLimit(r *http.Request) int {
|
||||
limit, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("limit")))
|
||||
if err != nil || limit <= 0 {
|
||||
return 50
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminIntegrationEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("integrationID"))
|
||||
definition, ok := integrationDefinitionFor(id)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no such integration")
|
||||
return
|
||||
}
|
||||
if !definition.Configured(s) {
|
||||
// A service with no address has nothing to switch, and recording a preference
|
||||
// about one would leave a stored decision nothing reads.
|
||||
writeError(w, http.StatusConflict,
|
||||
definition.Name+" is not configured on this gateway")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
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 := definition.SetEnabled(s, r.Context(), req.Enabled); err != nil {
|
||||
s.loggerFor(r.Context()).Error("could not change integration",
|
||||
"integration", id, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save the integration setting")
|
||||
return
|
||||
}
|
||||
if !req.Enabled {
|
||||
// Its last health reading described a service Memby has stopped calling. Left in
|
||||
// place it would sit under a disabled row claiming a verdict nothing is renewing.
|
||||
s.integrationHealth.forget(id)
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("integration switched",
|
||||
"integration", id, "enabled", req.Enabled)
|
||||
state := "disabled"
|
||||
if req.Enabled {
|
||||
state = "enabled"
|
||||
}
|
||||
s.publishAdmin(r.Context(), adminevents.Event{
|
||||
Type: adminevents.TypeSettingsChanged,
|
||||
Severity: adminevents.SeverityInfo,
|
||||
Title: definition.Name + " " + state,
|
||||
Summary: integrationSwitchSummary(definition, req.Enabled),
|
||||
Target: id,
|
||||
Link: "/admin/integrations/" + id,
|
||||
Metadata: adminevents.Meta(map[string]any{"integration": id, "enabled": req.Enabled}),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"services": s.integrationViews(r.Context())})
|
||||
}
|
||||
|
||||
// integrationSwitchSummary spells out the consequence rather than repeating the switch.
|
||||
// "Sonarr disabled" in an activity feed says nothing an operator did not just do; what
|
||||
// they may not have thought about is the calendar going with it.
|
||||
func integrationSwitchSummary(definition integrationDefinition, enabled bool) string {
|
||||
if enabled {
|
||||
return definition.Name + " is back on: its scheduled work resumes."
|
||||
}
|
||||
if len(definition.Powers) == 0 {
|
||||
return definition.Name + " will no longer be called."
|
||||
}
|
||||
return "Memby will stop calling " + definition.Name +
|
||||
". This also stops: " + strings.Join(definition.Powers, "; ") + "."
|
||||
}
|
||||
|
||||
// handleAdminIntegrationTest probes one service now.
|
||||
//
|
||||
// It takes the same path the scheduled probe does, deliberately: a test that asked a
|
||||
// different question could report a service as working while the row beside it stayed red.
|
||||
func (s *Server) handleAdminIntegrationTest(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("integrationID"))
|
||||
definition, ok := integrationDefinitionFor(id)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "no such integration")
|
||||
return
|
||||
}
|
||||
if !definition.Configured(s) {
|
||||
writeError(w, http.StatusConflict, definition.Name+" is not configured on this gateway")
|
||||
return
|
||||
}
|
||||
if definition.Probe == nil {
|
||||
// Stated rather than faked. MDBList is the case: a probe would spend a request of
|
||||
// an allowance bought by the day, and answering "healthy" without asking would be
|
||||
// the console making something up.
|
||||
writeError(w, http.StatusNotImplemented,
|
||||
definition.Name+" cannot be tested without spending part of its daily allowance")
|
||||
return
|
||||
}
|
||||
state := s.probeIntegration(r.Context(), definition)
|
||||
s.loggerFor(r.Context()).Info("integration probed",
|
||||
"integration", id, "reachable", state.Reachable, "duration_ms", state.LatencyMS)
|
||||
writeJSON(w, http.StatusOK, state)
|
||||
}
|
||||
@@ -116,9 +116,9 @@ func adminPreviewData() map[string]any {
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"/admin/api/status": status,
|
||||
"/admin/api/runtime": map[string]any{"goroutines": 84, "heapInuse": 41943040,
|
||||
"sys": 92274688, "numGc": 311, "nextGc": 62914560, "gomaxprocs": 4},
|
||||
"/admin/api/status": status,
|
||||
"/admin/api/runtime": adminPreviewRuntime(now),
|
||||
"/admin/api/runtime/goroutines": adminPreviewGoroutines(now),
|
||||
"/admin/api/accounts": map[string]any{
|
||||
"accounts": []any{
|
||||
map[string]any{"userId": "u-1", "username": "matt", "onboardingCompleted": true,
|
||||
@@ -191,3 +191,123 @@ func adminPreviewData() map[string]any {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A gateway with a mild, deliberate upward drift in both goroutines and heap, so the
|
||||
// preview shows what a trend that has earned a "watch" actually looks like — the flat case
|
||||
// is the one that draws itself.
|
||||
func adminPreviewRuntime(now time.Time) map[string]any {
|
||||
const points = 90
|
||||
samples := make([]any, 0, points)
|
||||
for index := 0; index < points; index++ {
|
||||
at := now.Add(-time.Duration(points-1-index) * time.Minute)
|
||||
samples = append(samples, map[string]any{
|
||||
"at": at.Format(time.RFC3339),
|
||||
"goroutines": 62 + index/4 + index%3,
|
||||
"heapInuse": 33_000_000 + index*90_000,
|
||||
"sys": 92_274_688,
|
||||
"numGc": 220 + index,
|
||||
})
|
||||
}
|
||||
trend := func(direction string, perHour, first, latest, low, high float64) map[string]any {
|
||||
return map[string]any{
|
||||
"direction": direction, "perHour": perHour, "first": first, "latest": latest,
|
||||
"min": low, "max": high, "spanSeconds": float64((points - 1) * 60), "points": points,
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"at": now.Format(time.RFC3339), "uptimeSeconds": 61_240.0,
|
||||
"goroutines": 84, "gomaxprocs": 4, "threads": 17, "goVersion": "go1.26",
|
||||
"memory": map[string]any{
|
||||
"heapAlloc": 39_000_000, "heapInuse": 41_943_040, "heapIdle": 24_000_000,
|
||||
"heapReleased": 8_000_000, "stackInuse": 1_800_000, "sys": 223_100_000,
|
||||
"nextGc": 62_914_560, "numGc": 311, "pauseTotalMs": 812.4, "pauseRecentMs": 1.9,
|
||||
"memoryLimit": 402_653_184, "configuredLimit": "384MiB",
|
||||
"heapShare": 0.104, "gcPerHour": 60.0,
|
||||
},
|
||||
"workers": []any{
|
||||
map[string]any{"name": "HTTP listener", "component": "HTTP server",
|
||||
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
|
||||
map[string]any{"name": "Emby health probe", "component": "Emby",
|
||||
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
|
||||
map[string]any{"name": "Library ingest", "component": "Library sync",
|
||||
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
|
||||
map[string]any{"name": "Task scheduler", "component": "Scheduled jobs",
|
||||
"state": "running", "started": now.Add(-17 * time.Hour).Format(time.RFC3339), "starts": 1},
|
||||
map[string]any{"name": "Startup library sync", "component": "Library sync",
|
||||
"state": "finished", "started": now.Add(-17 * time.Hour).Format(time.RFC3339),
|
||||
"stopped": now.Add(-17 * time.Hour).Add(4 * time.Minute).Format(time.RFC3339), "starts": 1},
|
||||
},
|
||||
"process": map[string]any{"pid": 1, "cpuSeconds": 431.8, "cpuPercent": 3.4,
|
||||
"cpuKnown": true, "openFiles": 41, "openSockets": 22, "fileLimit": 1048576,
|
||||
"filesKnown": true},
|
||||
"samples": samples,
|
||||
"goroutineTrend": trend("rising", 14, 62, 84, 62, 86),
|
||||
"heapTrend": trend("rising", 5_400_000, 33_000_000, 41_943_040, 33_000_000, 41_943_040),
|
||||
"reservedTrend": trend("steady", 0, 92_274_688, 92_274_688, 92_274_688, 92_274_688),
|
||||
"sampleEverySeconds": 60.0,
|
||||
"health": map[string]any{
|
||||
"level": "watch",
|
||||
"summary": "2 things to look at, in Goroutines and Memory.",
|
||||
"areas": []any{"Goroutines", "Memory"},
|
||||
"notes": []any{
|
||||
map[string]any{"level": "watch", "area": "Memory",
|
||||
"message": "Heap in use has risen by about 5.1 MB an hour over the last 1.5 hours. If it does not fall after a collection, something is holding on to it."},
|
||||
map[string]any{"level": "watch", "area": "Goroutines",
|
||||
"message": "Goroutines have risen by about 14 an hour over the last 1.5 hours, from 62 to 84. Open the breakdown to see which component they belong to."},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func adminPreviewGoroutines(now time.Time) map[string]any {
|
||||
category := func(key, label, description string, count int, states []any) map[string]any {
|
||||
return map[string]any{"category": key, "label": label, "description": description,
|
||||
"count": count, "states": states}
|
||||
}
|
||||
state := func(name string, count int) any {
|
||||
return map[string]any{"state": name, "count": count}
|
||||
}
|
||||
return map[string]any{
|
||||
"at": now.Format(time.RFC3339), "total": 84, "collectedInMs": 6.2, "dumpBytes": 98_304,
|
||||
"categories": []any{
|
||||
category("running", "Running", "On a processor now, or queued for one.", 3,
|
||||
[]any{state("running", 2), state("runnable", 1)}),
|
||||
category("io", "Network / I/O",
|
||||
"Waiting on a network read or write — Emby, Postgres, Redis, or a television.", 26,
|
||||
[]any{state("IO wait", 26)}),
|
||||
category("waiting", "Waiting / idle",
|
||||
"Parked waiting for work or for a lock. Idle, and costing almost nothing.", 41,
|
||||
[]any{state("select", 29), state("chan receive", 9), state("semacquire", 3)}),
|
||||
category("timers", "Timers / scheduled work", "Asleep until a scheduled time.", 8,
|
||||
[]any{state("sleep", 8)}),
|
||||
category("runtime", "Go runtime / collection",
|
||||
"The Go runtime's own housekeeping. This handful is always present.", 6,
|
||||
[]any{state("GC worker (idle)", 4), state("finalizer wait", 1), state("force gc (idle)", 1)}),
|
||||
},
|
||||
"components": []any{
|
||||
map[string]any{"component": "HTTP server", "count": 24, "longestWaitMinutes": 3},
|
||||
map[string]any{"component": "Database pool", "count": 18, "longestWaitMinutes": 940},
|
||||
map[string]any{"component": "Emby", "count": 11, "longestWaitMinutes": 2},
|
||||
map[string]any{"component": "Gateway API", "count": 9, "longestWaitMinutes": 0},
|
||||
map[string]any{"component": "Redis", "count": 8, "longestWaitMinutes": 940},
|
||||
map[string]any{"component": "Library sync", "count": 6, "longestWaitMinutes": 940},
|
||||
map[string]any{"component": "Go runtime", "count": 6, "longestWaitMinutes": 0},
|
||||
map[string]any{"component": "Scheduled jobs", "count": 2, "longestWaitMinutes": 940},
|
||||
},
|
||||
"groups": []any{
|
||||
map[string]any{"count": 24, "component": "HTTP server", "category": "io",
|
||||
"state": "IO wait", "function": "net/http.(*conn).serve",
|
||||
"file": "/usr/local/go/src/net/http/server.go:2092",
|
||||
"createdBy": "net/http.(*Server).Serve", "longestWaitMinutes": 3},
|
||||
map[string]any{"count": 18, "component": "Database pool", "category": "waiting",
|
||||
"state": "select", "function": "github.com/jackc/puddle/v2.(*Pool).acquire",
|
||||
"file": "/root/go/pkg/mod/github.com/jackc/puddle/v2/pool.go:481",
|
||||
"createdBy": "github.com/jackc/pgx/v5/pgxpool.NewWithConfig", "longestWaitMinutes": 940},
|
||||
map[string]any{"count": 6, "component": "Library sync", "category": "waiting",
|
||||
"state": "chan receive", "function": "memby/server/internal/library.(*Ingester).Run",
|
||||
"file": "/app/internal/library/ingest.go:118",
|
||||
"createdBy": "main.run", "longestWaitMinutes": 940},
|
||||
},
|
||||
"groupsTotal": 21,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/runtimestats"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -488,18 +489,57 @@ func TestAdminRuntimeMetricsAreProtectedAndReportHeap(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("runtime status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body adminRuntimeStatus
|
||||
var body runtimestats.Snapshot
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Goroutines < 1 || body.HeapInuse == 0 || body.MemoryLimit <= 0 {
|
||||
if body.Goroutines < 1 || body.Memory.HeapInuse == 0 || body.Memory.MemoryLimit <= 0 {
|
||||
t.Fatalf("runtime metrics = %+v", body)
|
||||
}
|
||||
// The health verdict is the reason this route exists: a count with no verdict beside
|
||||
// it is the number the console could not explain.
|
||||
if body.Health.Level == "" || body.Health.Summary == "" {
|
||||
t.Fatalf("runtime health = %+v", body.Health)
|
||||
}
|
||||
if rec.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("cache control = %q", rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
}
|
||||
|
||||
// The breakdown is the answer to "what are those goroutines doing", so the parts must add
|
||||
// up to the whole: a category table that quietly drops a state is worse than no table.
|
||||
func TestAdminGoroutineBreakdownPartitionsTheTotal(t *testing.T) {
|
||||
server := testServer(config.Config{AdminToken: "secret"})
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/api/runtime/goroutines", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
rec := httptest.NewRecorder()
|
||||
server.adminRoutes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("goroutine breakdown = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var report runtimestats.GoroutineReport
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Total < 1 {
|
||||
t.Fatalf("total = %d", report.Total)
|
||||
}
|
||||
counted := 0
|
||||
for _, category := range report.Categories {
|
||||
counted += category.Count
|
||||
}
|
||||
if counted != report.Total {
|
||||
t.Fatalf("categories total %d, report total %d", counted, report.Total)
|
||||
}
|
||||
components := 0
|
||||
for _, component := range report.Components {
|
||||
components += component.Count
|
||||
}
|
||||
if components != report.Total {
|
||||
t.Fatalf("components total %d, report total %d", components, report.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
|
||||
server := testServer(config.Config{
|
||||
AdminToken: "secret", ReleasePublishToken: "release-secret",
|
||||
|
||||
@@ -95,6 +95,10 @@ type Server struct {
|
||||
// ratingsWarm fills and renews the durable rating cache behind the viewer, so a row
|
||||
// never waits on MDBList and the operator's daily allowance is spent once per title.
|
||||
ratingsWarm ratingsWarmer
|
||||
// integrationHealth is what the last reachability probe found for each external
|
||||
// service. Cached rather than probed on request because the console polls: every open
|
||||
// tab would otherwise be its own request against somebody's Sonarr.
|
||||
integrationHealth integrationHealthCache
|
||||
// alertMu serialises the read-modify-write of the shared alert list. Its producers
|
||||
// are events — a webhook, a finished sync, a health probe — none of them paced by
|
||||
// this server, so two can land at once.
|
||||
|
||||
@@ -41,6 +41,15 @@ func (s *Server) handleSonarrWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid webhook token")
|
||||
return
|
||||
}
|
||||
// A switched-off integration records nothing. Answering 200 rather than refusing is
|
||||
// deliberate: neither *arr re-delivers a rejection, so a failure here would look to the
|
||||
// operator like Memby losing imports rather than like the switch they set. The library
|
||||
// sweep is what reconciles whatever arrives while it is off.
|
||||
if s.integrationSuppressed(r.Context(), integrationSonarr) {
|
||||
s.loggerFor(r.Context()).Debug("sonarr webhook ignored: integration switched off")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ignored": true})
|
||||
return
|
||||
}
|
||||
|
||||
var payload library.SonarrWebhook
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil {
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The work each external service does on a schedule.
|
||||
//
|
||||
// These are ordinary scheduler tasks carrying an Integration id, which is the whole of how
|
||||
// the integrations area gets a run history: a run is filed against the service as well as
|
||||
// against the job, and the console reads the scheduler's own table along the other axis.
|
||||
// There is no second store, no second retention job and no second place a piece of work
|
||||
// can be recorded as having failed — which was the point of asking whether the existing
|
||||
// infrastructure could answer before building anything.
|
||||
//
|
||||
// Every one of them begins by asking whether its integration is switched on, and reports
|
||||
// a *skipped* run rather than an error when it is not. That distinction is the feature:
|
||||
// "Memby did not run this" and "Memby ran this and it failed" look identical from a page
|
||||
// that only records failures, and an operator who has switched something off is entitled
|
||||
// to see the schedule quietly standing down rather than a red row every hour.
|
||||
//
|
||||
// The counters are the four in store.RunCounts and they mean the same thing everywhere:
|
||||
// processed is what the run looked at, changed is what it wrote, skipped is what it
|
||||
// deliberately passed over, failed is what went wrong without stopping the run.
|
||||
|
||||
// ratingsRefreshBatch bounds one MDBList refresh run.
|
||||
//
|
||||
// It is a cost decision rather than a throughput one: MDBList is bought by the day, this
|
||||
// runs hourly, and the batch times the cadence is what the household spends on renewals
|
||||
// before a single television has asked for anything. Forty an hour renews a thousand-title
|
||||
// library about once a day while leaving most of the allowance for titles somebody is
|
||||
// actually looking at.
|
||||
const ratingsRefreshBatch = 40
|
||||
|
||||
// RegisterIntegrationTasks declares the background work belonging to external services.
|
||||
func (s *Server) RegisterIntegrationTasks(sched *scheduler.Scheduler) {
|
||||
if sched == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "sonarr-lifecycle",
|
||||
Name: "Series lifecycle scan",
|
||||
Group: "Sonarr and Radarr",
|
||||
Integration: integrationSonarr,
|
||||
Description: "Reads Sonarr's catalogue and records which shows have been added, " +
|
||||
"have returned or have been cancelled since the last reading.",
|
||||
Interval: 24 * time.Hour,
|
||||
Timeout: 10 * time.Minute,
|
||||
// It ran on start-up before it was a task and still should: the history it keeps
|
||||
// is a record of transitions, and a gateway that was down for a week has a week of
|
||||
// catching up to do before it can tell one from a first sighting.
|
||||
RunOnStart: true,
|
||||
Work: s.runSonarrLifecycleScan,
|
||||
})
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "radarr-catalogue",
|
||||
Name: "Film catalogue refresh",
|
||||
Group: "Sonarr and Radarr",
|
||||
Integration: integrationRadarr,
|
||||
Description: "Re-reads Radarr's film catalogue, which is what the upcoming releases " +
|
||||
"row, the request pages and the Radarr-only film pages are all served from.",
|
||||
Interval: 6 * time.Hour,
|
||||
Timeout: 5 * time.Minute,
|
||||
Work: s.runRadarrCatalogueRefresh,
|
||||
})
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "tracearr-import",
|
||||
Name: "Watch history import",
|
||||
Group: "Recommendations",
|
||||
Integration: integrationTracearr,
|
||||
Description: "Brings the household's watch history across from Tracearr, which is " +
|
||||
"what For You rows and watch-time summaries are built from.",
|
||||
// The cadence here only decides how often the question is asked. What is actually
|
||||
// due is decided by the persisted import stamps inside ImportIfDue, so a container
|
||||
// restarted three times in an evening still imports on the configured schedule
|
||||
// rather than three times over.
|
||||
Interval: 15 * time.Minute,
|
||||
Timeout: 30 * time.Minute,
|
||||
Work: s.runTracearrImport,
|
||||
})
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "for-you-rebuild",
|
||||
Name: "Recommendation rebuild",
|
||||
Group: "Recommendations",
|
||||
Integration: integrationTracearr,
|
||||
Description: "Rebuilds every viewer's prepared For You rows from the imported " +
|
||||
"watch history. Runs once a day at the household's configured hour.",
|
||||
// Daily, and the hour is the operator's: the interval decides only how often the
|
||||
// question is asked, and the rebuild is skipped unless the household's chosen hour
|
||||
// has come round since the last one. Rebuilding at whatever time the container
|
||||
// happened to start is what it did before, which on a redeploy meant the heaviest
|
||||
// job in the gateway ran in the middle of the evening.
|
||||
Interval: time.Hour,
|
||||
Timeout: 30 * time.Minute,
|
||||
Work: s.runForYouRebuild,
|
||||
})
|
||||
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "mdblist-ratings-refresh",
|
||||
Name: "Ratings refresh",
|
||||
Group: "Ratings",
|
||||
Integration: integrationMDBList,
|
||||
Description: fmt.Sprintf(
|
||||
"Renews up to %d of the oldest stored review scores. Titles nobody has looked "+
|
||||
"at yet are fetched by the warmer on demand rather than here.",
|
||||
ratingsRefreshBatch),
|
||||
Interval: time.Hour,
|
||||
Timeout: 10 * time.Minute,
|
||||
Work: s.runRatingsRefresh,
|
||||
})
|
||||
|
||||
// Not filed against any integration, deliberately. It touches all of them, so a run
|
||||
// per probe cycle under each service's history would bury the runs that say what that
|
||||
// service actually did under twelve reachability checks an hour.
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "integration-health",
|
||||
Name: "Integration health check",
|
||||
Group: "System",
|
||||
Description: "Asks each configured external service whether it is answering.",
|
||||
Interval: integrationProbeInterval,
|
||||
Timeout: 2 * time.Minute,
|
||||
RunOnStart: true,
|
||||
Work: s.runIntegrationHealthCheck,
|
||||
})
|
||||
}
|
||||
|
||||
// integrationOff is the outcome every integration task returns when its switch is off.
|
||||
//
|
||||
// A skipped run rather than an error or silence: silence is indistinguishable from a
|
||||
// scheduler that has stopped, and an error would put a red row in the console for a state
|
||||
// the operator chose. The sentence names the service so the row reads correctly in the
|
||||
// all-tasks table, where the integration column is not necessarily beside it.
|
||||
func integrationOff(name string) (scheduler.Outcome, error) {
|
||||
return scheduler.Outcome{Detail: name + " is switched off"}, nil
|
||||
}
|
||||
|
||||
func (s *Server) runSonarrLifecycleScan(ctx context.Context) (scheduler.Outcome, error) {
|
||||
if !s.integrationEnabled(ctx, integrationSonarr) {
|
||||
return integrationOff("Sonarr")
|
||||
}
|
||||
result, err := s.scanSonarrLifecycle(ctx)
|
||||
if err != nil {
|
||||
return scheduler.Outcome{}, err
|
||||
}
|
||||
outcome := scheduler.Outcome{RunCounts: store.RunCounts{
|
||||
Processed: result.Series,
|
||||
Changed: result.Changes,
|
||||
}}
|
||||
// Silent when nothing moved, which on a settled household is most days. See
|
||||
// scheduler.announce: a job that reports itself every day is one nobody reads.
|
||||
if result.Changes == 0 {
|
||||
return outcome, nil
|
||||
}
|
||||
parts := []string{plural(result.Changes, "change", "changes")}
|
||||
if result.Added > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d added", result.Added))
|
||||
}
|
||||
if result.Cancelled > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d cancelled", result.Cancelled))
|
||||
}
|
||||
if result.Notifications > 0 {
|
||||
parts = append(parts, plural(result.Notifications, "notification", "notifications"))
|
||||
}
|
||||
outcome.Detail = fmt.Sprintf("%s checked · %s",
|
||||
plural(result.Series, "series", "series"), strings.Join(parts, ", "))
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func (s *Server) runRadarrCatalogueRefresh(ctx context.Context) (scheduler.Outcome, error) {
|
||||
if !s.integrationEnabled(ctx, integrationRadarr) {
|
||||
return integrationOff("Radarr")
|
||||
}
|
||||
// Straight to Radarr rather than through radarrMovieCatalogue: that reads the shared
|
||||
// cache first, and a refresh whose whole job is to replace the cache must not be
|
||||
// satisfied by it.
|
||||
movies, err := s.radarr.Movies(ctx)
|
||||
if err != nil {
|
||||
return scheduler.Outcome{}, fmt.Errorf("read Radarr catalogue: %w", err)
|
||||
}
|
||||
held, upcoming := 0, 0
|
||||
now := time.Now()
|
||||
for _, movie := range movies {
|
||||
if movie.HasFile {
|
||||
held++
|
||||
continue
|
||||
}
|
||||
// Upcoming is "monitored and not yet available", which is what the launcher's
|
||||
// releases row draws from — an unmonitored film Radarr is not chasing is not on
|
||||
// its way to anybody.
|
||||
if movie.Monitored {
|
||||
if movie.DigitalRelease == nil || movie.DigitalRelease.After(now) {
|
||||
upcoming++
|
||||
}
|
||||
}
|
||||
}
|
||||
s.cacheRadarrMovies(ctx, movies)
|
||||
return scheduler.Outcome{
|
||||
Detail: fmt.Sprintf("%s checked · %d in the library, %d still to come",
|
||||
plural(len(movies), "film", "films"), held, upcoming),
|
||||
RunCounts: store.RunCounts{Processed: len(movies), Changed: held, Skipped: upcoming},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) runTracearrImport(ctx context.Context) (scheduler.Outcome, error) {
|
||||
if !s.integrationEnabled(ctx, integrationTracearr) {
|
||||
return integrationOff("Tracearr")
|
||||
}
|
||||
if s.forYou.Running() {
|
||||
// A rebuild started by an operator is already using it. Skipping is the right
|
||||
// answer rather than queueing: the next tick is fifteen minutes away and the
|
||||
// import is idempotent.
|
||||
return scheduler.Outcome{Detail: "a For You rebuild is already running"}, nil
|
||||
}
|
||||
result, imported, err := s.forYou.ImportIfDue(
|
||||
ctx, s.cfg.TracearrSyncInterval, s.cfg.TracearrFullInterval)
|
||||
if err != nil {
|
||||
return scheduler.Outcome{}, fmt.Errorf("Tracearr import: %w", err)
|
||||
}
|
||||
if !imported {
|
||||
// Not due. Silent, because this is asked four times an hour and answered "no"
|
||||
// almost every time.
|
||||
return scheduler.Outcome{}, nil
|
||||
}
|
||||
outcome := scheduler.Outcome{RunCounts: store.RunCounts{
|
||||
Processed: result.Seen,
|
||||
Changed: result.Changed,
|
||||
}}
|
||||
if result.Seen == 0 && result.Changed == 0 {
|
||||
return outcome, nil
|
||||
}
|
||||
outcome.Detail = fmt.Sprintf("%s import · %s read, %d changed",
|
||||
result.Kind, plural(result.Seen, "session", "sessions"), result.Changed)
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// forYouRebuildDue reports whether the household's daily rebuild hour has come round
|
||||
// since the last one.
|
||||
//
|
||||
// Pure so the boundary can be tested: the two ways to get this wrong are both invisible
|
||||
// from a log — never rebuilding, and rebuilding on every tick — and both look like a
|
||||
// working scheduler from outside. A rebuild that has never happened is due, so a fresh
|
||||
// household does not wait until tomorrow evening for its first rows.
|
||||
func forYouRebuildDue(last *time.Time, now time.Time, hour int) bool {
|
||||
if hour < 0 || hour > 23 {
|
||||
hour = 0
|
||||
}
|
||||
if now.Hour() < hour {
|
||||
return false
|
||||
}
|
||||
if last == nil {
|
||||
return true
|
||||
}
|
||||
// The comparison is against the local calendar day rather than "24 hours ago", the
|
||||
// rule every household-local boundary in Memby follows: a day containing a
|
||||
// daylight-saving change is 23 or 25 hours long, and subtracting hours would skip or
|
||||
// repeat a rebuild twice a year.
|
||||
previous := last.In(now.Location())
|
||||
sameDay := previous.Year() == now.Year() && previous.YearDay() == now.YearDay()
|
||||
return !sameDay
|
||||
}
|
||||
|
||||
func (s *Server) runForYouRebuild(ctx context.Context) (scheduler.Outcome, error) {
|
||||
if !s.integrationEnabled(ctx, integrationTracearr) {
|
||||
return integrationOff("Tracearr")
|
||||
}
|
||||
if s.forYou.Running() {
|
||||
return scheduler.Outcome{Detail: "a For You rebuild is already running"}, nil
|
||||
}
|
||||
now := time.Now().In(s.householdLocation())
|
||||
state, err := s.store.TracearrImportState(ctx)
|
||||
if err != nil {
|
||||
return scheduler.Outcome{}, fmt.Errorf("read For You rebuild state: %w", err)
|
||||
}
|
||||
if !forYouRebuildDue(state.LastRebuildAt, now, s.cfg.ForYouRebuildHour) {
|
||||
return scheduler.Outcome{}, nil
|
||||
}
|
||||
result, err := s.forYou.RebuildAll(ctx, true)
|
||||
if err != nil {
|
||||
return scheduler.Outcome{}, fmt.Errorf("For You rebuild: %w", err)
|
||||
}
|
||||
if markErr := s.store.MarkForYouRebuild(ctx, now); markErr != nil {
|
||||
// The rebuild happened; failing the run over the stamp would have the console
|
||||
// report a failure for work that succeeded. It does mean the next tick tries
|
||||
// again, which is the safe direction to be wrong in.
|
||||
s.loggerFor(ctx).Warn("could not record For You rebuild", "error", markErr)
|
||||
}
|
||||
outcome := scheduler.Outcome{RunCounts: store.RunCounts{
|
||||
Processed: result.Users, Changed: result.Built, Failed: result.Failed,
|
||||
}}
|
||||
if result.Users == 0 {
|
||||
return outcome, nil
|
||||
}
|
||||
outcome.Detail = fmt.Sprintf("%s processed · %d rebuilt",
|
||||
plural(result.Users, "viewer", "viewers"), result.Built)
|
||||
if result.Failed > 0 {
|
||||
outcome.Detail += fmt.Sprintf(", %d failed", result.Failed)
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func (s *Server) runRatingsRefresh(ctx context.Context) (scheduler.Outcome, error) {
|
||||
settings, enabled := s.mdblistSettings(ctx)
|
||||
if !enabled || !s.integrationEnabled(ctx, integrationMDBList) {
|
||||
return integrationOff("MDBList")
|
||||
}
|
||||
keys, err := s.store.StaleRatingKeys(
|
||||
ctx, time.Now().Add(-ratingsRefreshInterval), ratingsRefreshBatch)
|
||||
if err != nil {
|
||||
return scheduler.Outcome{}, fmt.Errorf("read stale ratings: %w", err)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return scheduler.Outcome{}, nil
|
||||
}
|
||||
|
||||
counts := store.RunCounts{}
|
||||
var lastErr error
|
||||
for _, key := range keys {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
// The same daily allowance the on-demand warmer spends from, claimed the same way.
|
||||
// Two counters would let a quiet evening of browsing and a refresh run each spend a
|
||||
// full day's worth between them.
|
||||
if !s.claimRatingsBudget(time.Now()) {
|
||||
counts.Skipped++
|
||||
continue
|
||||
}
|
||||
counts.Processed++
|
||||
ratings, fetchErr := s.fetchAndStoreRatings(ctx, settings.APIKey, key)
|
||||
if fetchErr != nil {
|
||||
counts.Failed++
|
||||
lastErr = fetchErr
|
||||
var apiErr *mdblist.APIError
|
||||
if errors.As(fetchErr, &apiErr) &&
|
||||
(apiErr.StatusCode == 429 || apiErr.StatusCode == 402) {
|
||||
// The allowance is exhausted. Stopping the whole run is the point: the
|
||||
// remaining titles are still stale and will be the oldest next hour, and
|
||||
// hammering a provider that has just said no is how a key gets withdrawn.
|
||||
s.blockRatingsWarming(time.Now().Add(ratingsWarmBackoff))
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(ratings) == 0 {
|
||||
// A title MDBList has nothing for. Recorded rather than counted as changed:
|
||||
// the stamp moved so it is not re-fetched immediately, but nothing on a
|
||||
// television will look different.
|
||||
counts.Skipped++
|
||||
continue
|
||||
}
|
||||
counts.Changed++
|
||||
}
|
||||
|
||||
outcome := scheduler.Outcome{RunCounts: counts}
|
||||
if counts.Processed == 0 && counts.Skipped == 0 {
|
||||
return outcome, nil
|
||||
}
|
||||
outcome.Detail = fmt.Sprintf("%s requested · %d updated, %d unavailable",
|
||||
plural(counts.Processed, "title", "titles"), counts.Changed, counts.Skipped)
|
||||
// A run that failed every title it asked for is a failed run, not a quiet one: that is
|
||||
// a wrong key or a provider that is down, and it belongs in the notification feed. A
|
||||
// run that lost a few titles among many is ordinary and stays a detail.
|
||||
if counts.Failed > 0 && counts.Failed == counts.Processed && lastErr != nil {
|
||||
return outcome, fmt.Errorf("every MDBList request failed: %w", lastErr)
|
||||
}
|
||||
if counts.Failed > 0 {
|
||||
outcome.Detail += fmt.Sprintf(", %d failed", counts.Failed)
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// runIntegrationHealthCheck probes every configured service.
|
||||
//
|
||||
// A probe failure is not this task's failure: the whole job is to find out, and a red row
|
||||
// on the health check would say the gateway's own scheduler is broken when what is
|
||||
// actually broken is somebody's Sonarr. The verdict lives on the integration's row
|
||||
// instead, where it names the service.
|
||||
func (s *Server) runIntegrationHealthCheck(ctx context.Context) (scheduler.Outcome, error) {
|
||||
counts := store.RunCounts{}
|
||||
unreachable := []string{}
|
||||
for _, definition := range integrationCatalogue() {
|
||||
if definition.Probe == nil || !definition.Configured(s) {
|
||||
continue
|
||||
}
|
||||
if !definition.Enabled(s, ctx) {
|
||||
// A switched-off service is not probed at all. Probing one would be the
|
||||
// gateway continuing to call an API the operator has told it to stop calling,
|
||||
// which is exactly what the switch promises not to do.
|
||||
s.integrationHealth.forget(definition.ID)
|
||||
counts.Skipped++
|
||||
continue
|
||||
}
|
||||
counts.Processed++
|
||||
if state := s.probeIntegration(ctx, definition); !state.Reachable {
|
||||
counts.Failed++
|
||||
unreachable = append(unreachable, definition.Name)
|
||||
}
|
||||
}
|
||||
outcome := scheduler.Outcome{RunCounts: counts}
|
||||
if len(unreachable) > 0 {
|
||||
outcome.Detail = "not answering: " + strings.Join(unreachable, ", ")
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// plural is the "3 films" / "1 film" the run details are written in. The console prints
|
||||
// these sentences verbatim, so getting it wrong is visible on every row.
|
||||
func plural(count int, singular, many string) string {
|
||||
if count == 1 {
|
||||
return "1 " + singular
|
||||
}
|
||||
return fmt.Sprintf("%d %s", count, many)
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The gateway's external services, as the console's Integrations area reads them.
|
||||
//
|
||||
// One catalogue in Go, the shape featureCatalogue and preferenceCatalogue already take:
|
||||
// what an integration *is* — what it powers, whether it can be probed, where its switch is
|
||||
// stored — belongs to the code, and only the operator's choices belong in the database.
|
||||
// Adding a fifth service is an entry here plus its scheduler task, and the console page
|
||||
// needs no change at all.
|
||||
//
|
||||
// Two decisions in here are worth stating because they look like omissions otherwise.
|
||||
//
|
||||
// **The switch is stored where the integration's other configuration already lives.**
|
||||
// Sonarr and Radarr keep theirs in the arr integration policy, MDBList keeps its own in
|
||||
// the ratings settings, and Tracearr — which had nowhere — uses the integration policy
|
||||
// document. Consolidating them would mean a migration and, for the length of it, two
|
||||
// documents that could disagree about whether a service was on. What the console needs is
|
||||
// one reader and one writer, which is what integrationEnabled and setIntegrationEnabled
|
||||
// are; which document answers is nobody else's business.
|
||||
//
|
||||
// **MDBList has no live probe.** Every other service here is a machine in the house and
|
||||
// pinging it costs nothing. MDBList is an allowance bought by the day, and spending a
|
||||
// request of it to draw a green dot on a page that polls would be the console competing
|
||||
// with the televisions for the thing it is reporting on. Its health comes from its own run
|
||||
// history instead, which is the honest answer and the cheaper one.
|
||||
const (
|
||||
integrationSonarr = "sonarr"
|
||||
integrationRadarr = "radarr"
|
||||
integrationTracearr = "tracearr"
|
||||
integrationMDBList = "mdblist"
|
||||
)
|
||||
|
||||
// integrationDefinition is one external service.
|
||||
type integrationDefinition struct {
|
||||
ID string
|
||||
Name string
|
||||
// Summary is what the service is for, in one sentence, from Memby's point of view
|
||||
// rather than the vendor's.
|
||||
Summary string
|
||||
// Powers names the parts of Memby that stop working when this is switched off. It is
|
||||
// the "make the dependency clear rather than silently failing" half: an operator
|
||||
// turning Sonarr off is entitled to know the television calendar goes with it.
|
||||
Powers []string
|
||||
// Configured reports whether this deployment has an address and a credential for the
|
||||
// service at all. A service that is not configured has nothing to switch.
|
||||
Configured func(s *Server) bool
|
||||
// Address is the service's location, for the console to print. Never a credential —
|
||||
// the API key is part of neither the URL nor this string.
|
||||
Address func(s *Server) string
|
||||
// Enabled and SetEnabled are the operator's global switch. See the note above about
|
||||
// where each one is stored.
|
||||
Enabled func(s *Server, ctx context.Context) bool
|
||||
SetEnabled func(s *Server, ctx context.Context, enabled bool) error
|
||||
// Probe asks the service whether it is answering. Nil where asking costs something
|
||||
// that should not be spent on a status page.
|
||||
Probe func(s *Server, ctx context.Context) error
|
||||
// Facts are the configuration lines the detail page prints. Deliberately a function
|
||||
// of the live server rather than stored text, so a page cannot describe a deployment
|
||||
// that has since been reconfigured.
|
||||
Facts func(s *Server, ctx context.Context) []integrationFact
|
||||
}
|
||||
|
||||
// integrationFact is one label-and-value line of an integration's configuration.
|
||||
type integrationFact struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
// Tone lets a fact carry a verdict where it has one — an unset API key is not
|
||||
// neutral. Empty is the ordinary, judgement-free case.
|
||||
Tone string `json:"tone,omitempty"`
|
||||
}
|
||||
|
||||
func integrationCatalogue() []integrationDefinition {
|
||||
return []integrationDefinition{
|
||||
{
|
||||
ID: integrationSonarr,
|
||||
Name: "Sonarr",
|
||||
Summary: "Follows television: what has been imported, what is still to air, and which shows have ended.",
|
||||
Powers: []string{
|
||||
"The television calendar and the launcher's airing-soon row",
|
||||
"Series requests from a television",
|
||||
"Cancellation and returning-show notifications",
|
||||
"Import announcements for newly downloaded episodes",
|
||||
},
|
||||
Configured: func(s *Server) bool { return s.sonarr != nil },
|
||||
Address: func(s *Server) string { return serviceAddress(s.cfg.SonarrURL) },
|
||||
Enabled: func(s *Server, ctx context.Context) bool {
|
||||
policy, err := s.store.ArrIntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return policy.SonarrEnabled
|
||||
},
|
||||
SetEnabled: func(s *Server, ctx context.Context, enabled bool) error {
|
||||
policy, err := s.store.ArrIntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
policy = store.DefaultArrIntegrationPolicy()
|
||||
}
|
||||
policy.SonarrEnabled = enabled
|
||||
return s.store.SetArrIntegrationPolicy(ctx, policy)
|
||||
},
|
||||
Probe: func(s *Server, ctx context.Context) error {
|
||||
// Quality profiles rather than the series list: it is a handful of rows on
|
||||
// any household, where the catalogue is thousands and would make the
|
||||
// health check the most expensive thing on the page.
|
||||
_, err := s.sonarr.QualityProfiles(ctx)
|
||||
return err
|
||||
},
|
||||
Facts: func(s *Server, ctx context.Context) []integrationFact {
|
||||
return []integrationFact{
|
||||
{Label: "Address", Value: serviceAddress(s.cfg.SonarrURL)},
|
||||
{Label: "API key", Value: credentialState(s.cfg.SonarrAPIKey),
|
||||
Tone: credentialTone(s.cfg.SonarrAPIKey)},
|
||||
{Label: "Import webhook", Value: credentialState(s.cfg.SonarrWebhookToken),
|
||||
Tone: credentialTone(s.cfg.SonarrWebhookToken)},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: integrationRadarr,
|
||||
Name: "Radarr",
|
||||
Summary: "Follows films: what the household holds, what is on the way, and when a release becomes watchable.",
|
||||
Powers: []string{
|
||||
"The launcher's upcoming releases row and the Radarr-only film pages",
|
||||
"Film requests from a television",
|
||||
"Digital release dates, which the home hero is ranked by",
|
||||
"Import announcements for newly downloaded films",
|
||||
},
|
||||
Configured: func(s *Server) bool { return s.radarr != nil },
|
||||
Address: func(s *Server) string { return serviceAddress(s.cfg.RadarrURL) },
|
||||
Enabled: func(s *Server, ctx context.Context) bool {
|
||||
policy, err := s.store.ArrIntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return policy.RadarrEnabled
|
||||
},
|
||||
SetEnabled: func(s *Server, ctx context.Context, enabled bool) error {
|
||||
policy, err := s.store.ArrIntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
policy = store.DefaultArrIntegrationPolicy()
|
||||
}
|
||||
policy.RadarrEnabled = enabled
|
||||
return s.store.SetArrIntegrationPolicy(ctx, policy)
|
||||
},
|
||||
Probe: func(s *Server, ctx context.Context) error {
|
||||
_, err := s.radarr.QualityProfiles(ctx)
|
||||
return err
|
||||
},
|
||||
Facts: func(s *Server, ctx context.Context) []integrationFact {
|
||||
return []integrationFact{
|
||||
{Label: "Address", Value: serviceAddress(s.cfg.RadarrURL)},
|
||||
{Label: "API key", Value: credentialState(s.cfg.RadarrAPIKey),
|
||||
Tone: credentialTone(s.cfg.RadarrAPIKey)},
|
||||
{Label: "Import webhook", Value: credentialState(s.cfg.RadarrWebhookToken),
|
||||
Tone: credentialTone(s.cfg.RadarrWebhookToken)},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: integrationTracearr,
|
||||
Name: "Tracearr",
|
||||
Summary: "The household's real watch history, which is what personalised rows and watch-time summaries are built from.",
|
||||
Powers: []string{
|
||||
"For You rows and the recommendation profiles behind them",
|
||||
"Weekly and monthly watch-time summaries",
|
||||
"Watch time on the console's user pages",
|
||||
},
|
||||
Configured: func(s *Server) bool { return s.forYou != nil },
|
||||
Address: func(s *Server) string { return serviceAddress(s.cfg.TracearrURL) },
|
||||
Enabled: func(s *Server, ctx context.Context) bool {
|
||||
policy, err := s.store.IntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return policy.Enabled(integrationTracearr)
|
||||
},
|
||||
SetEnabled: func(s *Server, ctx context.Context, enabled bool) error {
|
||||
return s.store.SetIntegrationEnabled(ctx, integrationTracearr, enabled)
|
||||
},
|
||||
Probe: func(s *Server, ctx context.Context) error { return s.forYou.Ping(ctx) },
|
||||
Facts: func(s *Server, ctx context.Context) []integrationFact {
|
||||
facts := []integrationFact{
|
||||
{Label: "Address", Value: serviceAddress(s.cfg.TracearrURL)},
|
||||
{Label: "API key", Value: credentialState(s.cfg.TracearrAPIKey),
|
||||
Tone: credentialTone(s.cfg.TracearrAPIKey)},
|
||||
}
|
||||
if s.cfg.TracearrServerID != "" {
|
||||
facts = append(facts,
|
||||
integrationFact{Label: "Server", Value: s.cfg.TracearrServerID})
|
||||
}
|
||||
if state, err := s.store.TracearrImportState(ctx); err == nil {
|
||||
facts = append(facts, integrationFact{
|
||||
Label: "Last full import", Value: importStamp(state.LastFullAt),
|
||||
}, integrationFact{
|
||||
Label: "Last incremental import", Value: importStamp(state.LastIncrementalAt),
|
||||
})
|
||||
}
|
||||
return facts
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: integrationMDBList,
|
||||
Name: "MDBList",
|
||||
Summary: "External review scores. Bought by the day, so a title is fetched once and kept.",
|
||||
Powers: []string{
|
||||
"The ratings strip on detail pages and cards",
|
||||
"Score weighting in the home hero's ranking",
|
||||
},
|
||||
Configured: func(s *Server) bool { return s.mdblist != nil },
|
||||
Address: func(s *Server) string { return "mdblist.com" },
|
||||
Enabled: func(s *Server, ctx context.Context) bool {
|
||||
settings, err := s.store.MDBListSettings(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return settings.Enabled
|
||||
},
|
||||
SetEnabled: func(s *Server, ctx context.Context, enabled bool) error {
|
||||
settings, err := s.store.MDBListSettings(ctx)
|
||||
if err != nil {
|
||||
settings = store.DefaultMDBListSettings()
|
||||
}
|
||||
settings.Enabled = enabled
|
||||
if err := s.store.SetMDBListSettings(ctx, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
// The ratings path caches this document for thirty seconds; without this
|
||||
// the switch appears not to have worked for half a minute.
|
||||
s.forgetMDBListSettings()
|
||||
return nil
|
||||
},
|
||||
// Deliberately no probe. See the note at the top of this file.
|
||||
Facts: func(s *Server, ctx context.Context) []integrationFact {
|
||||
settings, err := s.store.MDBListSettings(ctx)
|
||||
if err != nil {
|
||||
settings = store.DefaultMDBListSettings()
|
||||
}
|
||||
facts := []integrationFact{
|
||||
{Label: "API key", Value: credentialState(settings.APIKey),
|
||||
Tone: credentialTone(settings.APIKey)},
|
||||
{Label: "Sources shown", Value: fmt.Sprint(len(settings.Sources))},
|
||||
}
|
||||
total, stale, statsErr := s.store.MediaRatingsStats(
|
||||
ctx, time.Now().Add(-ratingsRefreshInterval))
|
||||
if statsErr == nil {
|
||||
facts = append(facts,
|
||||
integrationFact{Label: "Titles stored", Value: fmt.Sprint(total)},
|
||||
integrationFact{Label: "Due to be re-checked", Value: fmt.Sprint(stale)})
|
||||
}
|
||||
return facts
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func integrationDefinitionFor(id string) (integrationDefinition, bool) {
|
||||
for _, definition := range integrationCatalogue() {
|
||||
if definition.ID == id {
|
||||
return definition, true
|
||||
}
|
||||
}
|
||||
return integrationDefinition{}, false
|
||||
}
|
||||
|
||||
// integrationEnabled is the single reader of an integration's global switch.
|
||||
//
|
||||
// It answers false for a service this deployment has not configured, which is what every
|
||||
// caller means by the question: "may I use Sonarr" and "is Sonarr switched on" are only
|
||||
// different questions to somebody looking at the console.
|
||||
func (s *Server) integrationEnabled(ctx context.Context, id string) bool {
|
||||
definition, ok := integrationDefinitionFor(id)
|
||||
if !ok || s.store == nil || !definition.Configured(s) {
|
||||
return false
|
||||
}
|
||||
return definition.Enabled(s, ctx)
|
||||
}
|
||||
|
||||
// integrationSuppressed reports that the operator has explicitly switched a service off.
|
||||
//
|
||||
// Deliberately not the negation of integrationEnabled, which is also false for a service
|
||||
// this deployment never configured. The difference matters at the import webhooks: a
|
||||
// household can perfectly well point Sonarr's notification at Memby without giving Memby
|
||||
// Sonarr's API key, and reading "no API credentials" as "the operator turned this off"
|
||||
// would silently stop recording their imports.
|
||||
func (s *Server) integrationSuppressed(ctx context.Context, id string) bool {
|
||||
definition, ok := integrationDefinitionFor(id)
|
||||
if !ok || s.store == nil {
|
||||
return false
|
||||
}
|
||||
return !definition.Enabled(s, ctx)
|
||||
}
|
||||
|
||||
// TracearrEnabled is the Tracearr switch, exported for the pieces of the gateway that are
|
||||
// built outside this package and still have to honour it — today the recommendation
|
||||
// engine, which calls Tracearr on the rebuild path.
|
||||
func (s *Server) TracearrEnabled(ctx context.Context) bool {
|
||||
return s.integrationEnabled(ctx, integrationTracearr)
|
||||
}
|
||||
|
||||
// serviceAddress is a service's location with anything credential-shaped removed.
|
||||
//
|
||||
// An *arr address is ordinarily a bare host and port, but nothing stops an operator
|
||||
// putting one behind basic auth, and a console that printed the URL verbatim would put
|
||||
// that password on a page. Scheme, host and path only.
|
||||
func serviceAddress(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return raw
|
||||
}
|
||||
parsed.User = nil
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return strings.TrimSuffix(parsed.String(), "/")
|
||||
}
|
||||
|
||||
// credentialState says whether a credential is set and never what it is. The console has
|
||||
// no use for the value and every reason not to hold it — the stance the MDBList key and
|
||||
// the OpenSubtitles login already take.
|
||||
func credentialState(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "not set"
|
||||
}
|
||||
return "saved"
|
||||
}
|
||||
|
||||
func credentialTone(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "warn"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func importStamp(at *time.Time) string {
|
||||
if at == nil {
|
||||
return "never"
|
||||
}
|
||||
return at.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// --- health -----------------------------------------------------------------------
|
||||
|
||||
// integrationProbeInterval is how often the health task asks each service whether it is
|
||||
// answering. Five minutes is chosen against what a probe is worth rather than against what
|
||||
// it costs: a service that has just gone away is news, and being up to five minutes late
|
||||
// with it is invisible next to the hourly jobs that would otherwise be the first thing to
|
||||
// notice. It is also the reason the console does not probe on page load — every open tab
|
||||
// would be its own request against somebody's Sonarr.
|
||||
const integrationProbeInterval = 5 * time.Minute
|
||||
|
||||
// integrationHealth is what the last probe found.
|
||||
type integrationHealth struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
CheckedAt time.Time `json:"checkedAt"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// LatencyMS is how long the service took to answer, which is the difference between
|
||||
// "working" and "working, and it is why the launcher is slow".
|
||||
LatencyMS int64 `json:"latencyMs"`
|
||||
}
|
||||
|
||||
type integrationHealthCache struct {
|
||||
mu sync.RWMutex
|
||||
states map[string]integrationHealth
|
||||
}
|
||||
|
||||
func (c *integrationHealthCache) get(id string) (integrationHealth, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
state, ok := c.states[id]
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (c *integrationHealthCache) set(id string, state integrationHealth) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.states == nil {
|
||||
c.states = map[string]integrationHealth{}
|
||||
}
|
||||
c.states[id] = state
|
||||
}
|
||||
|
||||
func (c *integrationHealthCache) forget(id string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.states, id)
|
||||
}
|
||||
|
||||
// probeIntegration asks one service whether it is answering and records what it found.
|
||||
//
|
||||
// It is also what the console's Test button calls, which is deliberate: a test that took a
|
||||
// different path from the scheduled probe could report a service as working while the page
|
||||
// beside it stayed red.
|
||||
func (s *Server) probeIntegration(
|
||||
ctx context.Context, definition integrationDefinition,
|
||||
) integrationHealth {
|
||||
if definition.Probe == nil || !definition.Configured(s) {
|
||||
return integrationHealth{}
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
err := definition.Probe(s, probeCtx)
|
||||
state := integrationHealth{
|
||||
CheckedAt: time.Now().UTC(),
|
||||
LatencyMS: time.Since(started).Milliseconds(),
|
||||
Reachable: err == nil,
|
||||
}
|
||||
if err != nil {
|
||||
state.Error = err.Error()
|
||||
}
|
||||
s.integrationHealth.set(definition.ID, state)
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The two rules in the integrations area that are pure, and therefore the two that can be
|
||||
// pinned without a database, a Sonarr or a clock. Both are the kind of judgement whose
|
||||
// failures are invisible from outside: a status word that is merely wrong still renders,
|
||||
// and a rebuild that never becomes due looks exactly like a working scheduler.
|
||||
|
||||
func TestIntegrationStatusResolvesInPriorityOrder(t *testing.T) {
|
||||
past := time.Now().Add(-time.Hour)
|
||||
recent := time.Now().Add(-time.Minute)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
view integrationView
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// Not configured outranks everything, including a switch left on from a
|
||||
// deployment that used to have an address for it.
|
||||
name: "unconfigured outranks enabled",
|
||||
view: integrationView{Configured: false, Enabled: true},
|
||||
want: integrationStatusUnconfigured,
|
||||
},
|
||||
{
|
||||
// Switched off outranks its own history: yesterday's success beside a service
|
||||
// nobody is running reads as one that is still working.
|
||||
name: "disabled outranks a past failure",
|
||||
view: integrationView{
|
||||
Configured: true, Enabled: false, LastFailureAt: &past, LastError: "boom",
|
||||
},
|
||||
want: integrationStatusDisabled,
|
||||
},
|
||||
{
|
||||
name: "running outranks a past failure",
|
||||
view: integrationView{
|
||||
Configured: true, Enabled: true, Running: true, LastFailureAt: &past,
|
||||
},
|
||||
want: integrationStatusRunning,
|
||||
},
|
||||
{
|
||||
// A probe that could not reach the service is the most current evidence there
|
||||
// is, and outranks a run that happened to succeed before it went away.
|
||||
name: "an unreachable probe beats an older success",
|
||||
view: integrationView{
|
||||
Configured: true, Enabled: true, LastSuccessAt: &past,
|
||||
Health: &integrationHealth{CheckedAt: recent, Error: "connection refused"},
|
||||
},
|
||||
want: integrationStatusError,
|
||||
},
|
||||
{
|
||||
// The comparison that stops one bad night leaving a service red for a month.
|
||||
name: "a failure followed by a success is history",
|
||||
view: integrationView{
|
||||
Configured: true, Enabled: true,
|
||||
LastFailureAt: &past, LastSuccessAt: &recent,
|
||||
},
|
||||
want: integrationStatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "a failure with no success since is the verdict",
|
||||
view: integrationView{
|
||||
Configured: true, Enabled: true,
|
||||
LastSuccessAt: &past, LastFailureAt: &recent, LastError: "401",
|
||||
},
|
||||
want: integrationStatusError,
|
||||
},
|
||||
{
|
||||
// Neither working nor broken. Claiming either would be a guess, and "healthy"
|
||||
// on a service that has never done anything is the more damaging guess.
|
||||
name: "nothing has run yet",
|
||||
view: integrationView{Configured: true, Enabled: true},
|
||||
want: integrationStatusIdle,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
status, label, _ := integrationStatus(testCase.view)
|
||||
if status != testCase.want {
|
||||
t.Fatalf("status = %q, want %q", status, testCase.want)
|
||||
}
|
||||
if label == "" {
|
||||
t.Fatal("every status must carry something to print")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationStatusExplainsEveryUnhappyAnswer(t *testing.T) {
|
||||
// The console prints this sentence and nothing else says why. An unconfigured or
|
||||
// disabled row with no explanation is one an operator has to guess at.
|
||||
for _, view := range []integrationView{
|
||||
{Configured: false},
|
||||
{Configured: true, Enabled: false},
|
||||
{Configured: true, Enabled: true},
|
||||
} {
|
||||
if _, _, detail := integrationStatus(view); detail == "" {
|
||||
t.Fatalf("no detail for %+v", view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForYouRebuildDue(t *testing.T) {
|
||||
location := time.UTC
|
||||
at := func(day, hour int) time.Time {
|
||||
return time.Date(2026, time.August, day, hour, 30, 0, 0, location)
|
||||
}
|
||||
|
||||
// A household that has never rebuilt does not wait until tomorrow evening for its
|
||||
// first rows — but it does wait for the hour it was told to use.
|
||||
if forYouRebuildDue(nil, at(19, 2), 4) {
|
||||
t.Fatal("rebuilt before the household's hour")
|
||||
}
|
||||
if !forYouRebuildDue(nil, at(19, 4), 4) {
|
||||
t.Fatal("a household that has never rebuilt is due at its hour")
|
||||
}
|
||||
|
||||
// Once today's rebuild has happened it is not due again today, however many times the
|
||||
// hourly task asks. This is the whole of what stops the heaviest job in the gateway
|
||||
// running twenty times an evening.
|
||||
today := at(19, 4)
|
||||
if forYouRebuildDue(&today, at(19, 23), 4) {
|
||||
t.Fatal("rebuilt twice in one day")
|
||||
}
|
||||
tomorrow := at(20, 4)
|
||||
if !forYouRebuildDue(&today, tomorrow, 4) {
|
||||
t.Fatal("not due on the next day")
|
||||
}
|
||||
|
||||
// The boundary is the local calendar day, not "24 hours ago". A rebuild at 04:30 and
|
||||
// a tick at 04:00 the next morning are 23.5 hours apart and are still two days.
|
||||
if !forYouRebuildDue(&today, at(20, 4), 4) {
|
||||
t.Fatal("an interval shorter than 24 hours must still be a new day")
|
||||
}
|
||||
|
||||
// An hour outside the clock is read as midnight rather than as a reason never to run.
|
||||
if !forYouRebuildDue(nil, at(19, 0), 99) {
|
||||
t.Fatal("an out-of-range hour must not strand the rebuild")
|
||||
}
|
||||
}
|
||||
@@ -331,14 +331,25 @@ func (s *Server) radarrMovieCatalogue(ctx context.Context) ([]radarr.Movie, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body, marshalErr := json.Marshal(movies); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, radarrMovieCacheKey, body, s.cfg.RadarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("radarr movie cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
s.cacheRadarrMovies(ctx, movies)
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
// cacheRadarrMovies stores the household's shared copy of Radarr's catalogue.
|
||||
//
|
||||
// Split out because the scheduled refresh writes it too: that task reads Radarr directly
|
||||
// — a refresh satisfied by the cache it exists to replace would do nothing — and would
|
||||
// otherwise need its own copy of the key, the TTL and the failure handling.
|
||||
func (s *Server) cacheRadarrMovies(ctx context.Context, movies []radarr.Movie) {
|
||||
body, err := json.Marshal(movies)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if cacheErr := s.cache.Set(ctx, radarrMovieCacheKey, body, s.cfg.RadarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("radarr movie cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) cachedRadarrMovies(ctx context.Context) []radarr.Movie {
|
||||
raw, err := s.cache.Get(ctx, radarrMovieCacheKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -75,6 +75,15 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid webhook token")
|
||||
return
|
||||
}
|
||||
// A switched-off integration records nothing. Answering 200 rather than refusing is
|
||||
// deliberate: neither *arr re-delivers a rejection, so a failure here would look to the
|
||||
// operator like Memby losing imports rather than like the switch they set. The library
|
||||
// sweep is what reconciles whatever arrives while it is off.
|
||||
if s.integrationSuppressed(r.Context(), integrationRadarr) {
|
||||
s.loggerFor(r.Context()).Debug("radarr webhook ignored: integration switched off")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ignored": true})
|
||||
return
|
||||
}
|
||||
|
||||
var payload radarrWebhookPayload
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil {
|
||||
|
||||
@@ -259,7 +259,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
// is not the same as them never having asked for it, and their page is the
|
||||
// only place that distinction is kept.
|
||||
s.recordMediaRequest(r.Context(), sess, req, movie.Year,
|
||||
radarrCoverURL(movie.Images, "poster"))
|
||||
radarrCoverURL(movie.Images, "poster"), openingRequestStatus(movie.HasFile))
|
||||
s.logMediaRequest(r.Context(), req, "already added", nil)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
|
||||
return
|
||||
@@ -282,7 +282,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
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"))
|
||||
radarrCoverURL(added.Images, "poster"), openingRequestStatus(false))
|
||||
s.logMediaRequest(r.Context(), req, "successful", nil)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
|
||||
return
|
||||
@@ -306,7 +306,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
if show.ID > 0 {
|
||||
req.Title = show.Title
|
||||
s.recordMediaRequest(r.Context(), sess, req, show.Year,
|
||||
sonarrCoverURL(show.Images, "poster"))
|
||||
sonarrCoverURL(show.Images, "poster"), openingRequestStatus(false))
|
||||
s.logMediaRequest(r.Context(), req, "already added", nil)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
|
||||
return
|
||||
@@ -329,7 +329,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
req.Title = added.Title
|
||||
s.logSonarrRequest(r.Context(), sess, req, added, added.ID, "successful", profileName, requestOptions.QualityProfileID, rootFolder, requestOptions.SearchImmediately, nil)
|
||||
s.recordMediaRequest(r.Context(), sess, req, added.Year,
|
||||
sonarrCoverURL(added.Images, "poster"))
|
||||
sonarrCoverURL(added.Images, "poster"), openingRequestStatus(false))
|
||||
s.logMediaRequest(r.Context(), req, "successful", nil)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
|
||||
return
|
||||
@@ -349,17 +349,18 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
// did. The cost of a lost write is that the ask is missing from their own page, which is
|
||||
// recoverable by asking again — the cost of the opposite is a viewer requesting it twice.
|
||||
func (s *Server) recordMediaRequest(
|
||||
ctx context.Context, sess store.Session, req requestPayload, year int, posterURL string,
|
||||
ctx context.Context, sess store.Session, req requestPayload, year int, posterURL, opening string,
|
||||
) {
|
||||
if s.store == nil || sess.EmbyUserID == "" {
|
||||
return
|
||||
}
|
||||
err := s.store.SaveMediaRequest(ctx, sess.EmbyUserID, store.MediaRequest{
|
||||
MediaType: req.MediaType,
|
||||
ForeignID: req.ForeignID,
|
||||
Title: req.Title,
|
||||
Year: year,
|
||||
PosterURL: posterURL,
|
||||
MediaType: req.MediaType,
|
||||
ForeignID: req.ForeignID,
|
||||
Title: req.Title,
|
||||
Year: year,
|
||||
PosterURL: posterURL,
|
||||
LastStatus: opening,
|
||||
})
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("media request not recorded",
|
||||
@@ -367,6 +368,25 @@ func (s *Server) recordMediaRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// openingRequestStatus is the state a request is born in, and it exists so that the ready
|
||||
// sweep has something to compare against on its very first pass.
|
||||
//
|
||||
// Leaving it blank and letting the first sweep fill it in would lose exactly the arrivals
|
||||
// worth announcing: a film that downloads in the three minutes between the ask and the first
|
||||
// sweep would have its arrival recorded as its opening state, and nobody would ever be told.
|
||||
// So the ask itself records what was true at the moment somebody pressed the button — which
|
||||
// is the one moment the handler knows for certain, having just asked the *arr.
|
||||
//
|
||||
// A series is never born available. Sonarr's series list carries no file information, so
|
||||
// "the household has this show" is a claim only the library can make, and the sweep is where
|
||||
// it gets made.
|
||||
func openingRequestStatus(hasFile bool) string {
|
||||
if hasFile {
|
||||
return RequestStatusAvailable
|
||||
}
|
||||
return RequestStatusSearching
|
||||
}
|
||||
|
||||
func (s *Server) logMediaRequest(
|
||||
ctx context.Context, req requestPayload, outcome string, err error,
|
||||
) {
|
||||
|
||||
@@ -3,11 +3,14 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -25,6 +28,15 @@ type myRequest struct {
|
||||
Status string `json:"status"`
|
||||
StatusLabel string `json:"statusLabel"`
|
||||
StatusDetail string `json:"statusDetail"`
|
||||
// Progress is whole percent and rides only a downloading card. It is omitted rather
|
||||
// than sent as zero everywhere else, so the television draws a bar when it is given a
|
||||
// figure and nothing at all when it is not — there is no "0%" state to confuse with a
|
||||
// download that has not started.
|
||||
Progress int `json:"progress,omitempty"`
|
||||
// EstimatedReadySeconds is how long the download client says the bytes will take, and is
|
||||
// omitted whenever nothing could say. That omission is the whole of "never fabricate an
|
||||
// ETA": a card with no number here prints the plain wording in StatusDetail instead.
|
||||
EstimatedReadySeconds int `json:"estimatedReadySeconds,omitempty"`
|
||||
// EmbyItemID is set once Emby has imported the title, so the card can open the ordinary
|
||||
// detail page instead of being a dead end at the moment it finally becomes watchable.
|
||||
EmbyItemID string `json:"embyItemId,omitempty"`
|
||||
@@ -49,21 +61,36 @@ func (s *Server) handleMyRequests(w http.ResponseWriter, r *http.Request, sess s
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, myRequestsResponse{
|
||||
Requests: s.decorateRequests(r.Context(), stored),
|
||||
Requests: s.decorateRequests(r.Context(), stored, requestProgressSupported(r)),
|
||||
Allowed: true,
|
||||
})
|
||||
}
|
||||
|
||||
// requestProgressSupported asks whether this television can draw the download states.
|
||||
//
|
||||
// A capability rather than a version floor, because that is what the client already declares
|
||||
// and what the console reports against — and because the answer is about what the app can
|
||||
// *draw*, which is exactly what a capability token says. An older build is sent the collapsed
|
||||
// vocabulary it understands (collapseRequestStatus) rather than four words it would file
|
||||
// under "Nothing happening" and a percentage it has nowhere to put.
|
||||
func requestProgressSupported(r *http.Request) bool {
|
||||
return slices.Contains(clientCapabilities(r), "request_progress_v1")
|
||||
}
|
||||
|
||||
// decorateRequests turns stored asks into cards by asking the two catalogues and the
|
||||
// library what has become of each.
|
||||
//
|
||||
// The three lookups run concurrently and every one of them is allowed to fail: a request
|
||||
// whose state cannot be established falls back to "requested", which is the honest answer —
|
||||
// The lookups run concurrently and every one of them is allowed to fail: a request whose
|
||||
// state cannot be established falls back to "requested", which is the honest answer —
|
||||
// somebody asked and we cannot currently say more. A page that errored because Radarr was
|
||||
// restarting would be a page that is broken exactly when a viewer wants to know why their
|
||||
// title has not arrived.
|
||||
// title has not arrived. The download queues degrade one step further and more gently
|
||||
// still: losing them costs the percentage and the estimate, and the card falls back to
|
||||
// "Searching", which is what the page said before there was a queue reader at all.
|
||||
//
|
||||
// progressAware is what this television can draw; see requestProgressSupported.
|
||||
func (s *Server) decorateRequests(
|
||||
ctx context.Context, stored []store.MediaRequest,
|
||||
ctx context.Context, stored []store.MediaRequest, progressAware bool,
|
||||
) []myRequest {
|
||||
if len(stored) == 0 {
|
||||
return []myRequest{}
|
||||
@@ -85,6 +112,14 @@ func (s *Server) decorateRequests(
|
||||
seriesInLibrary = map[int]bool{}
|
||||
movieItemIDs = map[int]string{}
|
||||
seriesItemIDs = map[int]string{}
|
||||
// The two halves of the queue join, gathered separately and put together after the
|
||||
// wait: a queue row names the *arr's own internal id, and only the catalogue can say
|
||||
// which TMDb or TVDb id that is. Fetching them concurrently and joining afterwards is
|
||||
// what keeps the queue read off the catalogue's critical path.
|
||||
tmdbByMovieID = map[int]int{}
|
||||
tvdbBySeriesID = map[int]int{}
|
||||
movieQueue []radarr.QueueItem
|
||||
seriesQueue []sonarr.QueueItem
|
||||
)
|
||||
now := time.Now()
|
||||
|
||||
@@ -101,6 +136,7 @@ func (s *Server) decorateRequests(
|
||||
if movie.TMDBID == 0 {
|
||||
continue
|
||||
}
|
||||
tmdbByMovieID[movie.ID] = movie.TMDBID
|
||||
movies[movie.TMDBID] = requestCatalogueEntry{
|
||||
tracked: true,
|
||||
hasFile: movie.HasFile,
|
||||
@@ -110,6 +146,16 @@ func (s *Server) decorateRequests(
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
queue, err := s.radarrQueue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("radarr queue unavailable for requests", "error", err)
|
||||
return
|
||||
}
|
||||
movieQueue = queue
|
||||
}()
|
||||
}
|
||||
if len(seriesIDs) > 0 && s.sonarrEnabled(ctx) {
|
||||
wg.Add(1)
|
||||
@@ -124,6 +170,7 @@ func (s *Server) decorateRequests(
|
||||
if show.TVDBID == 0 {
|
||||
continue
|
||||
}
|
||||
tvdbBySeriesID[show.ID] = show.TVDBID
|
||||
series[show.TVDBID] = requestCatalogueEntry{
|
||||
tracked: true,
|
||||
released: seriesReleased(show.Status, show.NextAiring, now),
|
||||
@@ -132,6 +179,16 @@ func (s *Server) decorateRequests(
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
queue, err := s.sonarrQueue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("sonarr queue unavailable for requests", "error", err)
|
||||
return
|
||||
}
|
||||
seriesQueue = queue
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -148,12 +205,18 @@ func (s *Server) decorateRequests(
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
movieWork := groupMovieWork(movieQueue, tmdbByMovieID)
|
||||
seriesWork := groupSeriesWork(seriesQueue, tvdbBySeriesID)
|
||||
|
||||
cards := make([]myRequest, 0, len(stored))
|
||||
for _, req := range stored {
|
||||
entry, inLibrary, itemID := movies[req.ForeignID], moviesInLibrary[req.ForeignID], movieItemIDs[req.ForeignID]
|
||||
work := movieWork[req.ForeignID]
|
||||
if req.MediaType == "series" {
|
||||
entry, inLibrary, itemID = series[req.ForeignID], seriesInLibrary[req.ForeignID], seriesItemIDs[req.ForeignID]
|
||||
work = seriesWork[req.ForeignID]
|
||||
}
|
||||
progress := downloadProgress(work)
|
||||
status := RequestStatusRequested
|
||||
// Only claim a state when something actually answered. With every catalogue down,
|
||||
// "requested" is all that is known and is what the card must say.
|
||||
@@ -163,13 +226,14 @@ func (s *Server) decorateRequests(
|
||||
HasFile: entry.hasFile,
|
||||
InLibrary: inLibrary,
|
||||
Released: entry.released,
|
||||
Progress: progress,
|
||||
})
|
||||
}
|
||||
poster := req.PosterURL
|
||||
if poster == "" {
|
||||
poster = entry.poster
|
||||
}
|
||||
cards = append(cards, myRequest{
|
||||
card := myRequest{
|
||||
MediaType: req.MediaType,
|
||||
ForeignID: req.ForeignID,
|
||||
Title: req.Title,
|
||||
@@ -179,9 +243,27 @@ func (s *Server) decorateRequests(
|
||||
RequestedAt: req.RequestedAt.UTC().Format(time.RFC3339),
|
||||
Status: status,
|
||||
StatusLabel: requestStatusLabel(status),
|
||||
StatusDetail: requestStatusDetail(status, req.MediaType),
|
||||
StatusDetail: requestStatusDetail(status, req.MediaType, progress.EstimatedReadySeconds),
|
||||
EmbyItemID: itemID,
|
||||
})
|
||||
}
|
||||
// The figures belong to a download and to nothing else. A title that is available,
|
||||
// pending or unavailable may still have a row in the queue — an upgrade, a stale
|
||||
// entry — and pinning a percentage to it would put a progress bar under a card
|
||||
// somebody can already press Play on.
|
||||
if status == RequestStatusDownloading {
|
||||
card.Progress = progress.Progress
|
||||
card.EstimatedReadySeconds = progress.EstimatedReadySeconds
|
||||
}
|
||||
if !progressAware {
|
||||
// Narrowed on the way out rather than on the way in, so the label and the
|
||||
// detail are still computed from the truth and only the *slug* is generalised.
|
||||
// That is the better half of the trade: an older television files the card under
|
||||
// "On the way" as it always did, and still reads "Downloading" on the chip and
|
||||
// the honest sentence underneath it. Only the bar and the number go.
|
||||
card.Status = collapseRequestStatus(card.Status)
|
||||
card.Progress, card.EstimatedReadySeconds = 0, 0
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What the download client is doing with something somebody asked for, normalised into the
|
||||
// few words a viewer can act on.
|
||||
//
|
||||
// The whole point of this file is that the television is never told about indexers, release
|
||||
// profiles, trackers or import queues. Radarr and Sonarr describe one download in three
|
||||
// overlapping vocabularies — see radarr.QueueItem — and between them they can produce
|
||||
// something like twenty distinct states. A viewer standing in front of a card needs to know
|
||||
// one of five things: nothing has been found yet, something has been found, it is coming
|
||||
// down and how far through it is, it is being filed away, or it went wrong.
|
||||
//
|
||||
// Everything here is pure, because it is the half of the feature that has to be right and
|
||||
// the half that is cheapest to be wrong about: an ETA is a promise, and a promise made from
|
||||
// a misread field is worse than no promise at all.
|
||||
|
||||
// requestWork is one row of a download queue with the *arr it came from forgotten.
|
||||
//
|
||||
// Keeping it free of radarr and sonarr types is what lets one rule answer for both — a film
|
||||
// and a season of a show are the same question about bytes — and lets the rule be tested
|
||||
// without either service.
|
||||
type requestWork struct {
|
||||
// Size and SizeLeft are bytes. Both zero means the client has not said, which is
|
||||
// different from a finished download and must not read as 100%.
|
||||
Size float64
|
||||
SizeLeft float64
|
||||
// TimeLeft is the download client's own estimate as a .NET TimeSpan ("00:14:32",
|
||||
// "1.02:03:04"), or empty when it will not say — which is the ordinary case for a
|
||||
// queued or stalled item and exactly where an ETA must not be manufactured.
|
||||
TimeLeft string
|
||||
// Status is the download client's word, TrackedState is what the *arr will do with the
|
||||
// bytes once they land, and TrackedStatus is the verdict over both.
|
||||
Status string
|
||||
TrackedState string
|
||||
TrackedStatus string
|
||||
}
|
||||
|
||||
// requestProgress is the normalised answer, and it is deliberately the shape of the wire.
|
||||
//
|
||||
// Progress and EstimatedReadySeconds are both zero when unknown, which is why they are
|
||||
// omitempty on the response types that embed this: a card draws a figure it was given and
|
||||
// says nothing at all when it was given none. Zero percent and "we have no idea" therefore
|
||||
// look the same to the television, which is the correct conflation — neither is a number
|
||||
// worth printing.
|
||||
type requestProgress struct {
|
||||
// Status is one of the request status slugs below, or empty when the queue had nothing
|
||||
// to say about this title at all.
|
||||
Status string
|
||||
// Progress is whole percent, 0-100.
|
||||
Progress int
|
||||
// EstimatedReadySeconds is how long until the bytes have landed. It never includes the
|
||||
// import that follows, because nothing measures that — see requestStatusDetail, where
|
||||
// "a few minutes" is wording rather than an estimate.
|
||||
EstimatedReadySeconds int
|
||||
}
|
||||
|
||||
// Download-client states, in the order a request passes through them. These sit alongside
|
||||
// the states in requests_status.go and share its vocabulary space; they are separate only
|
||||
// because these four are the ones a queue can answer for.
|
||||
const (
|
||||
// RequestStatusSearching means monitored, released, nothing found yet — the honest
|
||||
// reading of "the *arr holds this and the download client has never heard of it".
|
||||
RequestStatusSearching = "searching"
|
||||
// RequestStatusFound means a release has been grabbed and is waiting on the download
|
||||
// client: queued, paused, held by a delay profile. There is something to wait for, but
|
||||
// no bytes are moving.
|
||||
RequestStatusFound = "found"
|
||||
// RequestStatusDownloading means bytes are moving. This is the only state that carries
|
||||
// a percentage.
|
||||
RequestStatusDownloading = "downloading"
|
||||
// RequestStatusFailed means the download failed and the *arr will look for another
|
||||
// release. Deliberately not a dead end: it is the one state whose wording has to say
|
||||
// that Memby is still trying.
|
||||
RequestStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// workState is the per-row rule, and the order of its tests is the whole of it.
|
||||
//
|
||||
// A verdict of error outranks everything, because a row can look perfectly healthy —
|
||||
// "completed", even — while the *arr has decided it cannot use what arrived. Importing
|
||||
// outranks downloading next, since a row that has finished downloading is still reported by
|
||||
// some clients with a downloading-ish status while the *arr moves the file. Only then does
|
||||
// the download client's own word matter, and anything it says that is not "downloading" is
|
||||
// a wait of some kind, which is what "found" means.
|
||||
//
|
||||
// A word this build has never seen falls through to found rather than to failed: a new
|
||||
// vocabulary in a future Radarr must degrade to "something is happening" rather than
|
||||
// telling a viewer their film is broken.
|
||||
func workState(w requestWork) string {
|
||||
status := strings.ToLower(strings.TrimSpace(w.Status))
|
||||
tracked := strings.ToLower(strings.TrimSpace(w.TrackedState))
|
||||
verdict := strings.ToLower(strings.TrimSpace(w.TrackedStatus))
|
||||
switch {
|
||||
case verdict == "error", status == "failed", tracked == "failed", tracked == "failedpending":
|
||||
return RequestStatusFailed
|
||||
case tracked == "importpending", tracked == "importing", tracked == "imported",
|
||||
status == "completed":
|
||||
return RequestStatusProcessing
|
||||
case status == "downloading":
|
||||
return RequestStatusDownloading
|
||||
default:
|
||||
return RequestStatusFound
|
||||
}
|
||||
}
|
||||
|
||||
// workRank orders the states by how much they deserve to be what a card says when one title
|
||||
// has several rows — a season pack is a dozen episodes at a dozen different stages.
|
||||
//
|
||||
// Highest wins, and the ordering is "what is the most active thing happening to this": a
|
||||
// show with one episode downloading and eleven already filed is downloading. Failed is
|
||||
// lowest, so a single failed episode never overrides eleven healthy ones and a title only
|
||||
// reads as failed when every row of it has.
|
||||
func workRank(status string) int {
|
||||
switch status {
|
||||
case RequestStatusDownloading:
|
||||
return 4
|
||||
case RequestStatusFound:
|
||||
return 3
|
||||
case RequestStatusProcessing:
|
||||
return 2
|
||||
case RequestStatusFailed:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// downloadProgress folds a title's queue rows into one answer.
|
||||
//
|
||||
// No rows is not an error and not a state: it is the caller's question to answer, since
|
||||
// "nothing is downloading" means something different for a film that is already in the
|
||||
// library, one nobody has released yet, and one the *arr has been searching for all
|
||||
// afternoon. So this returns an empty status and requestStatusFor decides.
|
||||
func downloadProgress(work []requestWork) requestProgress {
|
||||
if len(work) == 0 {
|
||||
return requestProgress{}
|
||||
}
|
||||
|
||||
var (
|
||||
best string
|
||||
totalSize float64
|
||||
totalLeft float64
|
||||
// eta is the longest remaining time across the rows that have not landed yet: a
|
||||
// season is ready when its slowest episode is, not its fastest.
|
||||
eta time.Duration
|
||||
// etaKnown starts true and is cleared by the first unfinished row that will not say.
|
||||
// One silent row makes the total unknowable, and reporting the rest of the pack's
|
||||
// time as the whole pack's would be an ETA that quietly expires and keeps going.
|
||||
etaKnown = true
|
||||
)
|
||||
for _, row := range work {
|
||||
state := workState(row)
|
||||
if workRank(state) > workRank(best) {
|
||||
best = state
|
||||
}
|
||||
if row.Size > 0 {
|
||||
totalSize += row.Size
|
||||
totalLeft += math.Min(math.Max(row.SizeLeft, 0), row.Size)
|
||||
}
|
||||
switch state {
|
||||
case RequestStatusDownloading, RequestStatusFound:
|
||||
remaining, ok := parseTimeLeft(row.TimeLeft)
|
||||
if !ok {
|
||||
etaKnown = false
|
||||
continue
|
||||
}
|
||||
if remaining > eta {
|
||||
eta = remaining
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progress := requestProgress{Status: best}
|
||||
if totalSize > 0 {
|
||||
done := (totalSize - totalLeft) / totalSize * 100
|
||||
progress.Progress = int(math.Round(math.Min(math.Max(done, 0), 100)))
|
||||
}
|
||||
// An estimate is only ever offered against moving bytes. A queue that is paused or
|
||||
// waiting on a delay profile has a "time left" only in the sense that the download
|
||||
// client is guessing, and an import has no measured duration at all.
|
||||
if etaKnown && eta > 0 && best == RequestStatusDownloading {
|
||||
progress.EstimatedReadySeconds = int(math.Round(eta.Seconds()))
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
// parseTimeLeft reads the .NET TimeSpan the *arrs send: "hh:mm:ss", with an optional
|
||||
// "d." day part in front and an optional fractional-seconds part behind.
|
||||
//
|
||||
// It refuses anything it cannot read completely rather than salvaging a number from part of
|
||||
// it. This is the one input that becomes a promise to a viewer, and a misparse here is how
|
||||
// "ready in 14 minutes" becomes "ready in 14 hours".
|
||||
func parseTimeLeft(value string) (time.Duration, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, false
|
||||
}
|
||||
days := 0
|
||||
// A day part is separated by a full stop, and so is a fractional second — so a leading
|
||||
// "d." only exists when what follows still holds two colons.
|
||||
if dot := strings.Index(value, "."); dot > 0 && strings.Count(value[dot+1:], ":") == 2 {
|
||||
parsed, err := strconv.Atoi(value[:dot])
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, false
|
||||
}
|
||||
days = parsed
|
||||
value = value[dot+1:]
|
||||
}
|
||||
if dot := strings.Index(value, "."); dot >= 0 {
|
||||
value = value[:dot] // drop fractional seconds; nobody counts a film in milliseconds
|
||||
}
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0, false
|
||||
}
|
||||
units := []time.Duration{time.Hour, time.Minute, time.Second}
|
||||
total := time.Duration(days) * 24 * time.Hour
|
||||
for index, part := range parts {
|
||||
number, err := strconv.Atoi(strings.TrimSpace(part))
|
||||
if err != nil || number < 0 {
|
||||
return 0, false
|
||||
}
|
||||
total += time.Duration(number) * units[index]
|
||||
}
|
||||
return total, true
|
||||
}
|
||||
|
||||
// estimatedReadyLabel is the second half of the download line: the sentence under
|
||||
// "Downloading - 68%".
|
||||
//
|
||||
// It is worded in the coarsest unit that is still useful, because the precision the
|
||||
// download client reports is not precision anybody has: a client that says 14 minutes 3
|
||||
// seconds is guessing at the minutes, so printing the seconds claims an accuracy the number
|
||||
// does not have. An estimate of zero is not an estimate and produces nothing at all, which
|
||||
// is what makes "we will let you know when it is ready" reachable.
|
||||
func estimatedReadyLabel(seconds int) string {
|
||||
if seconds <= 0 {
|
||||
return ""
|
||||
}
|
||||
remaining := time.Duration(seconds) * time.Second
|
||||
switch {
|
||||
case remaining < 90*time.Second:
|
||||
return "Estimated ready in under a minute"
|
||||
case remaining < time.Hour:
|
||||
return "Estimated ready in ~" + strconv.Itoa(int(math.Round(remaining.Minutes()))) + " minutes"
|
||||
case remaining < 2*time.Hour:
|
||||
return "Estimated ready in about an hour"
|
||||
case remaining < 24*time.Hour:
|
||||
return "Estimated ready in ~" + strconv.Itoa(int(remaining.Hours())) + " hours"
|
||||
default:
|
||||
// Past a day the number stops being an estimate and starts being a warning that
|
||||
// something is wrong with the release, so it is deliberately vague.
|
||||
return "Estimated ready in over a day"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
)
|
||||
|
||||
func TestWorkStateReadsTheThreeVocabulariesInOrder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
work requestWork
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// The case the ordering exists for: everything about this row looks finished,
|
||||
// and the *arr has decided it cannot use what arrived.
|
||||
name: "an error verdict outranks a completed download",
|
||||
work: requestWork{Status: "completed", TrackedState: "importPending", TrackedStatus: "error"},
|
||||
want: RequestStatusFailed,
|
||||
},
|
||||
{
|
||||
name: "importing is processing even while the client still says downloading",
|
||||
work: requestWork{Status: "downloading", TrackedState: "importing", TrackedStatus: "ok"},
|
||||
want: RequestStatusProcessing,
|
||||
},
|
||||
{
|
||||
name: "moving bytes",
|
||||
work: requestWork{Status: "downloading", TrackedState: "downloading", TrackedStatus: "ok"},
|
||||
want: RequestStatusDownloading,
|
||||
},
|
||||
{
|
||||
name: "queued is a wait, not a download",
|
||||
work: requestWork{Status: "queued", TrackedState: "downloading", TrackedStatus: "ok"},
|
||||
want: RequestStatusFound,
|
||||
},
|
||||
{
|
||||
name: "a delay profile is a wait",
|
||||
work: requestWork{Status: "delay", TrackedStatus: "ok"},
|
||||
want: RequestStatusFound,
|
||||
},
|
||||
{
|
||||
// A future Radarr inventing a word must not tell a viewer their film is broken.
|
||||
name: "an unknown word degrades to found",
|
||||
work: requestWork{Status: "reticulatingSplines", TrackedState: "somethingNew"},
|
||||
want: RequestStatusFound,
|
||||
},
|
||||
{
|
||||
// A warning is not a failure. Radarr flags a stalled download this way and will
|
||||
// carry on with it.
|
||||
name: "a warning verdict is not a failure",
|
||||
work: requestWork{Status: "downloading", TrackedStatus: "warning"},
|
||||
want: RequestStatusDownloading,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := workState(tc.work); got != tc.want {
|
||||
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadProgressSaysNothingAboutAnEmptyQueue(t *testing.T) {
|
||||
// The absence of a queue row is the caller's question, not a state: it means something
|
||||
// different for a film already on the shelf, one nobody has released, and one that has
|
||||
// been searched for all afternoon.
|
||||
got := downloadProgress(nil)
|
||||
if got.Status != "" {
|
||||
t.Fatalf("an empty queue must claim no state, got %q", got.Status)
|
||||
}
|
||||
if got.Progress != 0 || got.EstimatedReadySeconds != 0 {
|
||||
t.Fatal("an empty queue must carry no figures")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadProgressReportsPercentAndEstimate(t *testing.T) {
|
||||
got := downloadProgress([]requestWork{{
|
||||
Size: 1000, SizeLeft: 320, TimeLeft: "00:14:00",
|
||||
Status: "downloading", TrackedState: "downloading", TrackedStatus: "ok",
|
||||
}})
|
||||
if got.Status != RequestStatusDownloading {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got.Status)
|
||||
}
|
||||
if got.Progress != 68 {
|
||||
t.Fatalf("expected 68%%, got %d%%", got.Progress)
|
||||
}
|
||||
if got.EstimatedReadySeconds != 840 {
|
||||
t.Fatalf("expected 840 seconds, got %d", got.EstimatedReadySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASeasonIsOneAnswerOverManyRows(t *testing.T) {
|
||||
// A season pack is one row per episode at a dozen different stages. What the card says
|
||||
// is the most active of them, and the percentage is over the whole pack rather than
|
||||
// whichever episode happens to be first.
|
||||
work := []requestWork{
|
||||
{Size: 100, SizeLeft: 0, Status: "completed", TrackedState: "imported"},
|
||||
{Size: 100, SizeLeft: 50, TimeLeft: "00:05:00", Status: "downloading", TrackedState: "downloading"},
|
||||
{Size: 100, SizeLeft: 100, TimeLeft: "00:20:00", Status: "queued", TrackedState: "downloading"},
|
||||
}
|
||||
got := downloadProgress(work)
|
||||
if got.Status != RequestStatusDownloading {
|
||||
t.Fatalf("a pack with one episode downloading is downloading, got %q", got.Status)
|
||||
}
|
||||
if got.Progress != 50 {
|
||||
t.Fatalf("expected 50%% over the whole pack, got %d%%", got.Progress)
|
||||
}
|
||||
// The pack is ready when its slowest episode is, not its fastest.
|
||||
if got.EstimatedReadySeconds != 1200 {
|
||||
t.Fatalf("expected the longest remaining time, got %d", got.EstimatedReadySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneFailedEpisodeDoesNotFailTheWholeShow(t *testing.T) {
|
||||
work := []requestWork{
|
||||
{Size: 100, SizeLeft: 100, Status: "failed", TrackedStatus: "error"},
|
||||
{Size: 100, SizeLeft: 40, TimeLeft: "00:03:00", Status: "downloading"},
|
||||
}
|
||||
if got := downloadProgress(work).Status; got != RequestStatusDownloading {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got)
|
||||
}
|
||||
// With nothing else happening, though, failed is the honest answer.
|
||||
only := []requestWork{{Size: 100, SizeLeft: 100, Status: "failed", TrackedStatus: "error"}}
|
||||
if got := downloadProgress(only).Status; got != RequestStatusFailed {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusFailed, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEstimateIsNeverManufactured(t *testing.T) {
|
||||
// A download client that will not say how long it has left leaves the estimate absent.
|
||||
// This is the acceptance criterion the feature is judged on, so it is asserted from
|
||||
// several directions.
|
||||
silent := downloadProgress([]requestWork{{Size: 100, SizeLeft: 60, Status: "downloading"}})
|
||||
if silent.EstimatedReadySeconds != 0 {
|
||||
t.Fatalf("no time left reported must produce no estimate, got %d", silent.EstimatedReadySeconds)
|
||||
}
|
||||
if silent.Progress != 40 {
|
||||
t.Fatalf("a missing estimate must not cost the percentage, got %d%%", silent.Progress)
|
||||
}
|
||||
|
||||
// One silent row makes the whole pack unknowable: reporting the rest of it as the whole
|
||||
// would be an estimate that quietly expires while the download carries on.
|
||||
partly := downloadProgress([]requestWork{
|
||||
{Size: 100, SizeLeft: 50, TimeLeft: "00:05:00", Status: "downloading"},
|
||||
{Size: 100, SizeLeft: 90, Status: "downloading"},
|
||||
})
|
||||
if partly.EstimatedReadySeconds != 0 {
|
||||
t.Fatalf("one silent row must make the pack unknowable, got %d", partly.EstimatedReadySeconds)
|
||||
}
|
||||
|
||||
// An import has no measured duration, so its "few minutes" is wording rather than a
|
||||
// number — see requestStatusDetail.
|
||||
importing := downloadProgress([]requestWork{
|
||||
{Size: 100, SizeLeft: 0, TimeLeft: "00:00:30", Status: "completed", TrackedState: "importing"},
|
||||
})
|
||||
if importing.EstimatedReadySeconds != 0 {
|
||||
t.Fatalf("an import must not carry an estimate, got %d", importing.EstimatedReadySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeIsOnlyReadWhenTheClientGaveOne(t *testing.T) {
|
||||
// Zero size and zero left is a client that has not said, and reading it as
|
||||
// (0-0)/0 finished would draw a full bar over a download that has not started.
|
||||
got := downloadProgress([]requestWork{{Status: "downloading"}})
|
||||
if got.Progress != 0 {
|
||||
t.Fatalf("an unsized download must report no percentage, got %d%%", got.Progress)
|
||||
}
|
||||
// A client reporting more left than the whole is clamped rather than trusted into a
|
||||
// negative percentage.
|
||||
odd := downloadProgress([]requestWork{{Size: 100, SizeLeft: 400, Status: "downloading"}})
|
||||
if odd.Progress != 0 {
|
||||
t.Fatalf("expected a clamped 0%%, got %d%%", odd.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeLeftReadsTheArrsFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
ok bool
|
||||
}{
|
||||
{"00:14:32", 14*time.Minute + 32*time.Second, true},
|
||||
{"01:00:00", time.Hour, true},
|
||||
{"1.02:03:04", 26*time.Hour + 3*time.Minute + 4*time.Second, true},
|
||||
{"00:00:30.5000000", 30 * time.Second, true},
|
||||
// Everything it cannot read completely is refused rather than salvaged. This is the
|
||||
// one input that becomes a promise to a viewer.
|
||||
{"", 0, false},
|
||||
{"14:32", 0, false},
|
||||
{"soon", 0, false},
|
||||
{"00:xx:32", 0, false},
|
||||
{"-1.00:00:01", 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := parseTimeLeft(tc.in)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("%q: expected ok=%v, got %v", tc.in, tc.ok, ok)
|
||||
}
|
||||
if ok && got != tc.want {
|
||||
t.Fatalf("%q: expected %s, got %s", tc.in, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatedReadyLabelIsCoarserThanTheNumberItWasGiven(t *testing.T) {
|
||||
// The download client's seconds are not precision anybody has, so printing them would
|
||||
// claim an accuracy the estimate does not carry.
|
||||
cases := []struct {
|
||||
seconds int
|
||||
want string
|
||||
}{
|
||||
{0, ""},
|
||||
{-5, ""},
|
||||
{45, "Estimated ready in under a minute"},
|
||||
{840, "Estimated ready in ~14 minutes"},
|
||||
{4500, "Estimated ready in about an hour"},
|
||||
{5 * 3600, "Estimated ready in ~5 hours"},
|
||||
{50 * 3600, "Estimated ready in over a day"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := estimatedReadyLabel(tc.seconds); got != tc.want {
|
||||
t.Fatalf("%d seconds: expected %q, got %q", tc.seconds, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueIsGroupedByTheIdTheRequestWasRecordedAgainst(t *testing.T) {
|
||||
// A queue row names the *arr's own internal id, which nothing outside it has seen. The
|
||||
// translation to TMDb is what lets a stored request find its own download.
|
||||
work := groupMovieWork(
|
||||
[]radarr.QueueItem{
|
||||
{MovieID: 7, Size: 100, Sizeleft: 25, Status: "downloading"},
|
||||
// Radarr id 99 is not in the catalogue map: a film added since it was cached.
|
||||
{MovieID: 99, Size: 100, Sizeleft: 100, Status: "queued"},
|
||||
},
|
||||
map[int]int{7: 550, 8: 603},
|
||||
)
|
||||
if len(work[550]) != 1 {
|
||||
t.Fatalf("expected one row against tmdb 550, got %d", len(work[550]))
|
||||
}
|
||||
if work[550][0].Status != "downloading" {
|
||||
t.Fatalf("the row lost its status: %+v", work[550][0])
|
||||
}
|
||||
// A download for a film added since the catalogue was cached cannot be translated, and
|
||||
// is dropped rather than guessed at.
|
||||
if _, ok := work[0]; ok {
|
||||
t.Fatal("an untranslatable row must not be filed under a zero id")
|
||||
}
|
||||
if len(work) != 1 {
|
||||
t.Fatalf("expected one translated title, got %d", len(work))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
)
|
||||
|
||||
// Reading the download queues, which is the one part of the request page that cannot be
|
||||
// cached for long and is read by every television in the house at once.
|
||||
//
|
||||
// The two *arr catalogues beside this are cached for the day: what Radarr holds and whether
|
||||
// it has a file changes a handful of times a day, and a viewer finding out at midnight costs
|
||||
// nothing. A queue is the opposite — it is the number moving on the card — so it carries its
|
||||
// own short cache instead. requestQueueTTL is what stops four televisions on the launcher
|
||||
// turning a page refresh into four requests apiece: within the window they share one answer,
|
||||
// and past it the figure is stale by at most that window, which at this cadence is invisible
|
||||
// against a download measured in minutes.
|
||||
const (
|
||||
radarrQueueCacheKey = "radarr:queue:v1"
|
||||
sonarrQueueCacheKey = "sonarr:queue:v1"
|
||||
requestQueueTTL = 15 * time.Second
|
||||
)
|
||||
|
||||
// radarrQueue is what Radarr's download client is working on.
|
||||
//
|
||||
// Single-flighted behind the same mutex the catalogue uses, so a burst of polls costs one
|
||||
// upstream read. A miss is not an error the page can be failed over: see decorateRequests,
|
||||
// where a queue that will not answer costs the percentage and never the card.
|
||||
func (s *Server) radarrQueue(ctx context.Context) ([]radarr.QueueItem, error) {
|
||||
if !s.radarrEnabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
if queue, ok := cachedQueue[radarr.QueueItem](ctx, s, radarrQueueCacheKey); ok {
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
s.radarrMu.Lock()
|
||||
defer s.radarrMu.Unlock()
|
||||
if queue, ok := cachedQueue[radarr.QueueItem](ctx, s, radarrQueueCacheKey); ok {
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
queue, err := s.radarr.Queue(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cacheQueue(ctx, radarrQueueCacheKey, queue)
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
// sonarrQueue is what Sonarr's download client is working on.
|
||||
func (s *Server) sonarrQueue(ctx context.Context) ([]sonarr.QueueItem, error) {
|
||||
if !s.sonarrEnabled(ctx) {
|
||||
return nil, nil
|
||||
}
|
||||
if queue, ok := cachedQueue[sonarr.QueueItem](ctx, s, sonarrQueueCacheKey); ok {
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
s.sonarrSeriesMu.Lock()
|
||||
defer s.sonarrSeriesMu.Unlock()
|
||||
if queue, ok := cachedQueue[sonarr.QueueItem](ctx, s, sonarrQueueCacheKey); ok {
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
queue, err := s.sonarr.Queue(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cacheQueue(ctx, sonarrQueueCacheKey, queue)
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
// cachedQueue reads a stored queue. An empty queue is a real and common answer — most of the
|
||||
// time the household is downloading nothing — so the second return distinguishes "nothing is
|
||||
// stored" from "nothing is downloading", which a nil slice could not.
|
||||
func cachedQueue[T any](ctx context.Context, s *Server, key string) ([]T, bool) {
|
||||
if s.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
raw, err := s.cache.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var queue []T
|
||||
if err := json.Unmarshal(raw, &queue); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if queue == nil {
|
||||
queue = []T{}
|
||||
}
|
||||
return queue, true
|
||||
}
|
||||
|
||||
func (s *Server) cacheQueue(ctx context.Context, key string, queue any) {
|
||||
if s.cache == nil {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(queue)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.cache.Set(ctx, key, body, requestQueueTTL); err != nil {
|
||||
s.loggerFor(ctx).Warn("download queue cache write failed", "key", key, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// groupMovieWork turns Radarr's queue into work per *TMDb* id.
|
||||
//
|
||||
// The translation is the point. A queue row names Radarr's own movie id, which is an
|
||||
// internal number nothing outside Radarr has ever seen; a request is recorded against the
|
||||
// TMDb id, which is what the catalogue, the library and Emby all agree on. The map comes
|
||||
// from the catalogue the caller already has, so this costs no extra request — and a row
|
||||
// whose film is not in that map is dropped rather than guessed at, which is what a download
|
||||
// for something added since the catalogue was cached looks like.
|
||||
func groupMovieWork(queue []radarr.QueueItem, tmdbByMovieID map[int]int) map[int][]requestWork {
|
||||
work := map[int][]requestWork{}
|
||||
for _, row := range queue {
|
||||
tmdbID, ok := tmdbByMovieID[row.MovieID]
|
||||
if !ok || tmdbID == 0 {
|
||||
continue
|
||||
}
|
||||
work[tmdbID] = append(work[tmdbID], requestWork{
|
||||
Size: row.Size,
|
||||
SizeLeft: row.Sizeleft,
|
||||
TimeLeft: row.Timeleft,
|
||||
Status: row.Status,
|
||||
TrackedState: row.TrackedDownloadState,
|
||||
TrackedStatus: row.TrackedDownloadStatus,
|
||||
})
|
||||
}
|
||||
return work
|
||||
}
|
||||
|
||||
// groupSeriesWork turns Sonarr's queue into work per TVDb id.
|
||||
//
|
||||
// A show legitimately has many rows — a season pack is one row per episode — and they are
|
||||
// kept as a list rather than reduced here, because downloadProgress is what decides how a
|
||||
// dozen episodes at a dozen stages become one sentence.
|
||||
func groupSeriesWork(queue []sonarr.QueueItem, tvdbBySeriesID map[int]int) map[int][]requestWork {
|
||||
work := map[int][]requestWork{}
|
||||
for _, row := range queue {
|
||||
tvdbID, ok := tvdbBySeriesID[row.SeriesID]
|
||||
if !ok || tvdbID == 0 {
|
||||
continue
|
||||
}
|
||||
work[tvdbID] = append(work[tvdbID], requestWork{
|
||||
Size: row.Size,
|
||||
SizeLeft: row.Sizeleft,
|
||||
TimeLeft: row.Timeleft,
|
||||
Status: row.Status,
|
||||
TrackedState: row.TrackedDownloadState,
|
||||
TrackedStatus: row.TrackedDownloadStatus,
|
||||
})
|
||||
}
|
||||
return work
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/scheduler"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// Telling somebody the thing they asked for has arrived.
|
||||
//
|
||||
// This is the one part of the request feature that cannot be answered by looking: every
|
||||
// other state on a card is derived per read from the *arrs and the library, but "it has just
|
||||
// become ready" is a *difference between two observations*, and a viewer has to be told about
|
||||
// it exactly once. media_requests.last_status is the memory that makes the difference
|
||||
// visible; this sweep is what looks.
|
||||
//
|
||||
// It is a personal notification rather than a service alert, and that is the same distinction
|
||||
// the watch-time digest makes: a service alert is the house being told something, and one
|
||||
// person's request arriving is news for one person. It therefore lands in My Alerts and
|
||||
// follows them to whichever television they sign into, rather than dropping a bar across
|
||||
// somebody else's film.
|
||||
|
||||
const (
|
||||
// requestReadyKind is what the television files the notification under. An app that
|
||||
// predates it draws the fallback icon, which is why the wording has to stand on its own.
|
||||
requestReadyKind = "request-ready"
|
||||
// notifySourceRequestReady names this producer in the notification log, so an operator
|
||||
// answering "did Memby tell them?" has one thing to filter on.
|
||||
notifySourceRequestReady = "request-ready"
|
||||
)
|
||||
|
||||
// RegisterRequestTasks declares the arrival sweep.
|
||||
//
|
||||
// Registered as an ordinary scheduler job for the reason the digest is: an operator can see
|
||||
// when it last ran and can run one by hand. For a job whose whole output is silence on a
|
||||
// quiet day, that is the difference between "nothing has arrived" and "it has not run".
|
||||
func (s *Server) RegisterRequestTasks(sched *scheduler.Scheduler) {
|
||||
if sched == nil {
|
||||
return
|
||||
}
|
||||
sched.Register(scheduler.Task{
|
||||
ID: "request-ready-scan",
|
||||
Name: "Requested content arrivals",
|
||||
Group: "Notifications",
|
||||
Description: "Notices when something a viewer requested has become watchable and tells " +
|
||||
"the person who asked for it. Needs Radarr or Sonarr.",
|
||||
// Five minutes is chosen against what it is watching rather than against cost. An
|
||||
// import is the last few seconds of a wait measured in tens of minutes, so being up
|
||||
// to five minutes late with the news is invisible; being an hour late is the
|
||||
// difference between a notification and a thing somebody had already found for
|
||||
// themselves.
|
||||
Interval: 5 * time.Minute,
|
||||
Timeout: 2 * time.Minute,
|
||||
Run: s.runRequestReadyScan,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) runRequestReadyScan(ctx context.Context) (string, error) {
|
||||
if s.store == nil {
|
||||
return "", nil
|
||||
}
|
||||
// Nothing to compare against with both catalogues gone: every card would read
|
||||
// "requested", which is not a transition and must not be recorded as one.
|
||||
if !s.radarrEnabled(ctx) && !s.sonarrEnabled(ctx) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
owned, err := s.store.AllMediaRequests(ctx, store.MediaRequestSweepLimit)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request arrivals: read requests: %w", err)
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
stored := make([]store.MediaRequest, 0, len(owned))
|
||||
for _, req := range owned {
|
||||
stored = append(stored, req.MediaRequest)
|
||||
}
|
||||
// progressAware is true because nothing here is drawn: the sweep wants the truth, and
|
||||
// collapsing it would make a download and a search indistinguishable in the memory this
|
||||
// writes back.
|
||||
cards := s.decorateRequests(ctx, stored, true)
|
||||
if len(cards) != len(owned) {
|
||||
// decorateRequests answers one card per stored ask, in order. If that ever stopped
|
||||
// being true the zip below would attribute one person's arrival to another, which is
|
||||
// the one mistake here that reaches somebody as a notification about a title they
|
||||
// never asked for.
|
||||
return "", fmt.Errorf("request arrivals: %d cards for %d requests", len(cards), len(owned))
|
||||
}
|
||||
|
||||
announced, moved := 0, 0
|
||||
for index, card := range cards {
|
||||
req := owned[index]
|
||||
if card.Status == req.LastStatus {
|
||||
continue
|
||||
}
|
||||
// A state that could not be established is not a transition. With Radarr restarting,
|
||||
// every card falls back to "requested", and recording that would throw away the
|
||||
// memory of what each request was actually doing — so the next sweep, once Radarr is
|
||||
// back, would read an arrival as a move from "requested" and announce titles that had
|
||||
// been on the shelf for a week.
|
||||
if card.Status == RequestStatusRequested {
|
||||
continue
|
||||
}
|
||||
if card.Status == RequestStatusAvailable && req.LastStatus != RequestStatusAvailable {
|
||||
if s.announceRequestReady(ctx, req, card) {
|
||||
announced++
|
||||
}
|
||||
}
|
||||
if err := s.store.SetMediaRequestStatus(
|
||||
ctx, req.UserID, req.MediaType, req.ForeignID, card.Status,
|
||||
); err != nil {
|
||||
s.loggerFor(ctx).Warn("request status not recorded",
|
||||
"user_id", req.UserID, "type", req.MediaType,
|
||||
"foreign_id", req.ForeignID, "error", err)
|
||||
continue
|
||||
}
|
||||
moved++
|
||||
}
|
||||
|
||||
// An empty detail keeps a job that runs every five minutes out of the operator's feed
|
||||
// every five minutes; see scheduler.announce. Movement without an arrival is real work
|
||||
// and worth reporting, because it is the evidence that the sweep is watching anything
|
||||
// at all on a household where nothing has finished downloading yet.
|
||||
switch {
|
||||
case announced == 1:
|
||||
return "1 request ready", nil
|
||||
case announced > 1:
|
||||
return fmt.Sprintf("%d requests ready", announced), nil
|
||||
case moved > 0:
|
||||
return fmt.Sprintf("%d requests moved on", moved), nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// announceRequestReady tells one viewer their title has landed.
|
||||
//
|
||||
// It carries the Emby item id whenever the library has one, which is what lets pressing the
|
||||
// notification open the title's own detail page rather than a page about nothing. A title the
|
||||
// *arr reports a file for but Emby has not imported yet legitimately has none — the news is
|
||||
// still true and still worth sending, and the notification simply is not a link.
|
||||
//
|
||||
// There is deliberately no preference check. The other personal notifications are things
|
||||
// Memby decided to send somebody; this one is the answer to a question they asked by pressing
|
||||
// a button, and a viewer who has requested a film has said as clearly as they can that they
|
||||
// want to know when it arrives.
|
||||
func (s *Server) announceRequestReady(
|
||||
ctx context.Context, req store.OwnedMediaRequest, card myRequest,
|
||||
) bool {
|
||||
title := card.Title
|
||||
if title == "" {
|
||||
title = req.Title
|
||||
}
|
||||
thing := "film"
|
||||
if req.MediaType == "series" {
|
||||
thing = "series"
|
||||
}
|
||||
eventAt := time.Now()
|
||||
sent := s.notifyUser(ctx, notify.Notification{
|
||||
Kind: requestReadyKind,
|
||||
Source: notifySourceRequestReady,
|
||||
UserID: req.UserID,
|
||||
Title: "Your " + thing + " is ready",
|
||||
Body: title + " is now available to watch.",
|
||||
ItemID: card.EmbyItemID,
|
||||
// The key names the request rather than the moment, so the arrival is announced once
|
||||
// however many times the sweep runs — and a viewer who deletes the request, asks
|
||||
// again and waits through a second download is a second key only if the title left
|
||||
// the library in between, which is the case where the news is genuinely new.
|
||||
SourceKey: "request-ready:" + req.MediaType + ":" + strconv.Itoa(req.ForeignID) +
|
||||
":" + req.UserID,
|
||||
EventAt: &eventAt,
|
||||
Metadata: map[string]any{
|
||||
"mediaType": req.MediaType,
|
||||
"foreignId": req.ForeignID,
|
||||
},
|
||||
})
|
||||
if sent {
|
||||
s.loggerFor(ctx).Info("requested title is ready",
|
||||
"user_id", req.UserID, "type", req.MediaType,
|
||||
"title", clientLogValue(title), "foreign_id", req.ForeignID,
|
||||
"item_id", card.EmbyItemID)
|
||||
}
|
||||
return sent
|
||||
}
|
||||
@@ -16,7 +16,11 @@ import (
|
||||
const (
|
||||
// The household has it. Either Emby has imported it or the *arr reports a file.
|
||||
RequestStatusAvailable = "available"
|
||||
// Accepted and being worked on: released, monitored, no file yet.
|
||||
// Being filed away: the bytes have landed and the *arr is importing them. This used to
|
||||
// mean "released, monitored, no file yet" — everything between the ask and the arrival —
|
||||
// and now means only the last step of it, with searching, found and downloading (in
|
||||
// requests_progress.go) covering the rest. A television that predates those three is
|
||||
// sent this word for all four; see collapseRequestStatus.
|
||||
RequestStatusProcessing = "processing"
|
||||
// Accepted, but there is nothing to fetch yet — unreleased, or still only in cinemas.
|
||||
RequestStatusPending = "pending"
|
||||
@@ -68,6 +72,12 @@ type RequestSubject struct {
|
||||
// Released is whether there is anything to fetch yet. A film not yet on digital and a
|
||||
// series whose first episode has not aired are both false.
|
||||
Released bool
|
||||
// Progress is what the download client is doing with it, already folded by
|
||||
// downloadProgress. It is passed in rather than computed here so that the caller — which
|
||||
// has the queue rows and has to send the percentage and the ETA anyway — folds them
|
||||
// once. An empty Status means the queue had nothing to say, which is the ordinary case
|
||||
// and not a state of its own.
|
||||
Progress requestProgress
|
||||
}
|
||||
|
||||
// requestStatusFor is the whole rule, and it is ordered by how much each signal is worth.
|
||||
@@ -77,16 +87,48 @@ type RequestSubject struct {
|
||||
// recorded. Only then does absence from the *arr mean removal — checked before the release
|
||||
// state, because an untracked title's release date says nothing about a request nobody is
|
||||
// working on any more.
|
||||
//
|
||||
// The download client outranks the release state, and that ordering is deliberate. Radarr's
|
||||
// minimum-availability setting is a policy about when to *start looking*, not a fact about
|
||||
// whether bytes are moving — a household that fetches on the cinema date has films that are
|
||||
// "not released" and 40% downloaded at the same time, and the honest thing to tell somebody
|
||||
// watching that card is the 40%.
|
||||
//
|
||||
// With nothing in the queue and nothing on disk, a tracked and released title is being
|
||||
// searched for. That is the state this feature exists to make visible: before it, the whole
|
||||
// span from "somebody asked" to "the file landed" was one word, so a viewer could not tell a
|
||||
// request nothing had been found for from one that was minutes away.
|
||||
func requestStatusFor(subject RequestSubject) string {
|
||||
switch {
|
||||
case subject.InLibrary || subject.HasFile:
|
||||
return RequestStatusAvailable
|
||||
case !subject.Tracked:
|
||||
return RequestStatusUnavailable
|
||||
case subject.Progress.Status != "":
|
||||
return subject.Progress.Status
|
||||
case !subject.Released:
|
||||
return RequestStatusPending
|
||||
default:
|
||||
return RequestStatusSearching
|
||||
}
|
||||
}
|
||||
|
||||
// collapseRequestStatus is what a television that predates the download states is told.
|
||||
//
|
||||
// The four new words all live inside the span the old "processing" covered, so an older app
|
||||
// is sent that one word and reads it exactly as it always did — "Searching for a copy",
|
||||
// filed under On the way. It loses the percentage and the estimate, which it has nowhere to
|
||||
// draw anyway, and it gains nothing wrong.
|
||||
//
|
||||
// This is a *narrowing*, never a translation in the other direction: a build that declares
|
||||
// request_progress_v1 is sent the truth. See requestProgressSupported.
|
||||
func collapseRequestStatus(status string) string {
|
||||
switch status {
|
||||
case RequestStatusSearching, RequestStatusFound,
|
||||
RequestStatusDownloading, RequestStatusFailed:
|
||||
return RequestStatusProcessing
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,11 +173,23 @@ func seriesReleased(status string, nextAiring *time.Time, now time.Time) bool {
|
||||
func requestStatusLabel(status string) string {
|
||||
switch status {
|
||||
case RequestStatusAvailable:
|
||||
return "Available"
|
||||
return "Ready to watch"
|
||||
case RequestStatusSearching:
|
||||
return "Searching"
|
||||
case RequestStatusFound:
|
||||
return "Found"
|
||||
// Deliberately just the word. The percentage is a number that moves every ten seconds
|
||||
// and the wording does not, so the television composes "Downloading - 43%" from this
|
||||
// label and the progress field rather than the server sending a sentence that is stale
|
||||
// before it is drawn.
|
||||
case RequestStatusDownloading:
|
||||
return "Downloading"
|
||||
case RequestStatusProcessing:
|
||||
return "Processing"
|
||||
case RequestStatusPending:
|
||||
return "Pending"
|
||||
case RequestStatusFailed:
|
||||
return "Unable to download"
|
||||
case RequestStatusUnavailable:
|
||||
return "Unavailable"
|
||||
case RequestStatusRequestable:
|
||||
@@ -147,7 +201,14 @@ func requestStatusLabel(status string) string {
|
||||
|
||||
// requestStatusDetail is the quiet second line: what the state means for the viewer, in
|
||||
// plain language, rather than a repeat of the word above it.
|
||||
func requestStatusDetail(status, mediaType string) string {
|
||||
//
|
||||
// The download states are where this line earns its place, because the word above it is not
|
||||
// the news. "Downloading" is not what somebody is standing there wanting to know — when it
|
||||
// will be ready is — and this is the only line that can say so. The estimate is passed in
|
||||
// rather than recomputed so that the sentence and the estimatedReadySeconds field on the
|
||||
// wire can never disagree; when there is no estimate the line says so plainly instead of
|
||||
// reaching for a vaguer number, which is the whole of "never manufacture an ETA".
|
||||
func requestStatusDetail(status, mediaType string, estimateSeconds int) string {
|
||||
thing := "film"
|
||||
if mediaType == "series" {
|
||||
thing = "series"
|
||||
@@ -155,8 +216,19 @@ func requestStatusDetail(status, mediaType string) string {
|
||||
switch status {
|
||||
case RequestStatusAvailable:
|
||||
return "Ready to watch now"
|
||||
case RequestStatusSearching:
|
||||
return "We haven't found a suitable release yet"
|
||||
case RequestStatusFound:
|
||||
return "Preparing the download"
|
||||
case RequestStatusDownloading:
|
||||
if label := estimatedReadyLabel(estimateSeconds); label != "" {
|
||||
return label
|
||||
}
|
||||
return "We'll let you know when it's ready"
|
||||
case RequestStatusProcessing:
|
||||
return "Searching for a copy"
|
||||
return "Should be ready in a few minutes"
|
||||
case RequestStatusFailed:
|
||||
return "Memby will keep looking for another release"
|
||||
case RequestStatusPending:
|
||||
if thing == "series" {
|
||||
return "Waiting for it to air"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -38,14 +39,62 @@ func TestRequestStatusReportsRemovalBeforeReleaseState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestStatusSeparatesPendingFromProcessing(t *testing.T) {
|
||||
func TestRequestStatusSeparatesPendingFromSearching(t *testing.T) {
|
||||
pending := requestStatusFor(RequestSubject{Tracked: true, Released: false})
|
||||
if pending != RequestStatusPending {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusPending, pending)
|
||||
}
|
||||
processing := requestStatusFor(RequestSubject{Tracked: true, Released: true})
|
||||
if processing != RequestStatusProcessing {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusProcessing, processing)
|
||||
// Tracked, released, and the download client has never heard of it: nothing has been
|
||||
// found. This is the distinction the whole feature rests on — before it, this case and
|
||||
// a download two minutes from finishing were the same word.
|
||||
searching := requestStatusFor(RequestSubject{Tracked: true, Released: true})
|
||||
if searching != RequestStatusSearching {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusSearching, searching)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueOutranksTheReleaseState(t *testing.T) {
|
||||
// Radarr's minimum-availability setting decides when to start looking, not whether bytes
|
||||
// are moving. A household that fetches on the cinema date has films that are "not
|
||||
// released" and half downloaded at once, and the honest answer is the half.
|
||||
got := requestStatusFor(RequestSubject{
|
||||
Tracked: true,
|
||||
Released: false,
|
||||
Progress: requestProgress{Status: RequestStatusDownloading, Progress: 50},
|
||||
})
|
||||
if got != RequestStatusDownloading {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusDownloading, got)
|
||||
}
|
||||
// But nothing outranks having it. A queue row against a title already on the shelf is an
|
||||
// upgrade, and a card somebody can press Play on must not read as downloading.
|
||||
got = requestStatusFor(RequestSubject{
|
||||
Tracked: true,
|
||||
InLibrary: true,
|
||||
Progress: requestProgress{Status: RequestStatusDownloading, Progress: 50},
|
||||
})
|
||||
if got != RequestStatusAvailable {
|
||||
t.Fatalf("expected %q, got %q", RequestStatusAvailable, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseNarrowsOnlyTheDownloadStates(t *testing.T) {
|
||||
// An older television is told "processing" for the whole span it used to cover, and
|
||||
// nothing else it understands is disturbed.
|
||||
for _, state := range []string{
|
||||
RequestStatusSearching, RequestStatusFound,
|
||||
RequestStatusDownloading, RequestStatusFailed,
|
||||
} {
|
||||
if got := collapseRequestStatus(state); got != RequestStatusProcessing {
|
||||
t.Fatalf("%q should collapse to %q, got %q", state, RequestStatusProcessing, got)
|
||||
}
|
||||
}
|
||||
for _, state := range []string{
|
||||
RequestStatusAvailable, RequestStatusPending, RequestStatusRequested,
|
||||
RequestStatusUnavailable, RequestStatusRequestable, RequestStatusProcessing,
|
||||
} {
|
||||
if got := collapseRequestStatus(state); got != state {
|
||||
t.Fatalf("%q must survive collapsing, got %q", state, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,8 +163,10 @@ func TestRequestStatusLabelAndDetailCoverEveryState(t *testing.T) {
|
||||
// a state that fell through to the default would show "Requested" on a card that is
|
||||
// actually available.
|
||||
states := []string{
|
||||
RequestStatusAvailable, RequestStatusProcessing, RequestStatusPending,
|
||||
RequestStatusUnavailable, RequestStatusRequestable, RequestStatusRequested,
|
||||
RequestStatusAvailable, RequestStatusSearching, RequestStatusFound,
|
||||
RequestStatusDownloading, RequestStatusProcessing, RequestStatusPending,
|
||||
RequestStatusFailed, RequestStatusUnavailable, RequestStatusRequestable,
|
||||
RequestStatusRequested,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, state := range states {
|
||||
@@ -128,8 +179,27 @@ func TestRequestStatusLabelAndDetailCoverEveryState(t *testing.T) {
|
||||
}
|
||||
seen[label] = true
|
||||
}
|
||||
if requestStatusDetail(RequestStatusPending, "series") ==
|
||||
requestStatusDetail(RequestStatusPending, "movie") {
|
||||
for _, state := range states {
|
||||
if requestStatusDetail(state, "movie", 0) == "" {
|
||||
t.Fatalf("state %q has no detail line", state)
|
||||
}
|
||||
}
|
||||
if requestStatusDetail(RequestStatusPending, "series", 0) ==
|
||||
requestStatusDetail(RequestStatusPending, "movie", 0) {
|
||||
t.Fatal("a pending series and a pending film are waiting for different things")
|
||||
}
|
||||
// The one rule this feature is judged on: an estimate is offered when the download
|
||||
// client gave one and never otherwise. Both sentences are legitimate; saying the second
|
||||
// while holding a number, or the first while holding none, is not.
|
||||
withEstimate := requestStatusDetail(RequestStatusDownloading, "movie", 840)
|
||||
withoutEstimate := requestStatusDetail(RequestStatusDownloading, "movie", 0)
|
||||
if withEstimate == withoutEstimate {
|
||||
t.Fatal("a download with an estimate must not read the same as one without")
|
||||
}
|
||||
if !strings.Contains(withEstimate, "14 minutes") {
|
||||
t.Fatalf("840 seconds should read as about 14 minutes, got %q", withEstimate)
|
||||
}
|
||||
if strings.Contains(withoutEstimate, "~") {
|
||||
t.Fatalf("a download with no estimate must not print one, got %q", withoutEstimate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,38 +12,28 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// WatchSonarrLifecycle seeds the durable status history on startup, then refreshes it
|
||||
// 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.sonarrEnabled(ctx) || interval <= 0 {
|
||||
return
|
||||
}
|
||||
scan := func() {
|
||||
if s.quietTimeActive() {
|
||||
return
|
||||
}
|
||||
if err := s.scanSonarrLifecycle(ctx); err != nil && ctx.Err() == nil {
|
||||
s.log.Warn("Sonarr lifecycle scan failed", "error", err)
|
||||
}
|
||||
}
|
||||
scan()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
scan()
|
||||
}
|
||||
}
|
||||
// sonarrLifecycleResult is what one scan looked at and what moved.
|
||||
//
|
||||
// Counted rather than only logged because these are the figures the integrations console
|
||||
// prints beside the run: "412 series checked, 3 changed" is an answer, where "the scan
|
||||
// finished" is a line in a log an operator has to go and find.
|
||||
type sonarrLifecycleResult struct {
|
||||
Series int
|
||||
Changes int
|
||||
Added int
|
||||
Cancelled int
|
||||
Notifications int
|
||||
}
|
||||
|
||||
func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
// scanSonarrLifecycle seeds the durable status history and records what changed since
|
||||
// the last reading. 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) scanSonarrLifecycle(ctx context.Context) (sonarrLifecycleResult, error) {
|
||||
result := sonarrLifecycleResult{}
|
||||
series, err := s.sonarr.Series(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Sonarr series: %w", err)
|
||||
return result, fmt.Errorf("read Sonarr series: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
observations := make([]store.SonarrSeriesStatus, 0, len(series))
|
||||
@@ -57,10 +47,12 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
Title: item.Title, Year: item.Year, Status: item.Status, ObservedAt: now,
|
||||
})
|
||||
}
|
||||
result.Series = len(observations)
|
||||
changes, err := s.store.RecordSonarrSeriesStatuses(ctx, observations)
|
||||
if err != nil {
|
||||
return err
|
||||
return result, err
|
||||
}
|
||||
result.Changes = len(changes)
|
||||
cancellations := make([]store.SonarrSeriesStatusChange, 0, len(changes))
|
||||
additions := make([]store.SonarrSeriesStatusChange, 0, len(changes))
|
||||
for _, change := range changes {
|
||||
@@ -71,14 +63,15 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
cancellations = append(cancellations, change)
|
||||
}
|
||||
}
|
||||
result.Added, result.Cancelled = len(additions), len(cancellations)
|
||||
if len(cancellations) == 0 && len(additions) == 0 {
|
||||
s.log.Info("Sonarr lifecycle scan complete", "series", len(observations), "changes", len(changes))
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
users, err := s.store.KnownUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return result, err
|
||||
}
|
||||
preferences := map[string]store.NotificationPreferences{}
|
||||
preferenceErrors := map[string]bool{}
|
||||
@@ -153,10 +146,11 @@ func (s *Server) scanSonarrLifecycle(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
result.Notifications = notifications
|
||||
s.log.Info("Sonarr lifecycle scan complete",
|
||||
"series", len(observations), "changes", len(changes),
|
||||
"added", len(additions), "cancelled", len(cancellations), "notifications", notifications)
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sonarrLifecycleNotificationKind(previous, current string) string {
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.57
|
||||
0.1.59
|
||||
|
||||
@@ -99,6 +99,20 @@ func (s *Service) Running() bool {
|
||||
return s.importRunning || len(s.building) > 0
|
||||
}
|
||||
|
||||
// Ping asks Tracearr for the smallest thing it will answer with.
|
||||
//
|
||||
// It exists so the integrations console can report whether Tracearr is reachable without
|
||||
// reaching past this service for the client: the service owns the connection, and a
|
||||
// console holding its own copy of the client would be a second place the address and key
|
||||
// could be wrong. One user, one page — the answer is discarded and only the error matters.
|
||||
func (s *Service) Ping(ctx context.Context) error {
|
||||
if s == nil || s.tracearr == nil {
|
||||
return errors.New("tracearr is not configured")
|
||||
}
|
||||
_, err := s.tracearr.Users(ctx, 1, 1)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Stats(ctx context.Context) (store.ForYouStats, error) {
|
||||
return s.store.ForYouStats(ctx)
|
||||
}
|
||||
@@ -403,17 +417,35 @@ func (s *Service) RefreshAsync(sess store.Session, force bool) {
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) RebuildAll(ctx context.Context, force bool) error {
|
||||
// RebuildResult is what one pass over the household did.
|
||||
//
|
||||
// Counted because a rebuild that quietly failed for every viewer and one that succeeded
|
||||
// for every viewer are the same "no error" from outside: a per-user failure is logged and
|
||||
// swallowed on purpose — one viewer's broken profile must not stop the rest being built —
|
||||
// so the count is the only thing that can say it happened.
|
||||
type RebuildResult struct {
|
||||
Users int
|
||||
Built int
|
||||
Failed int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
func (s *Service) RebuildAll(ctx context.Context, force bool) (RebuildResult, error) {
|
||||
result := RebuildResult{}
|
||||
users, err := s.recommendationUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return result, err
|
||||
}
|
||||
result.Users = len(users)
|
||||
for _, user := range users {
|
||||
if err := s.Rebuild(ctx, user, force); err != nil {
|
||||
result.Failed++
|
||||
s.log.Warn("For You user rebuild failed", "user", user.EmbyUserID, "error", err)
|
||||
continue
|
||||
}
|
||||
result.Built++
|
||||
}
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RebuildOutdated refreshes only profiles produced by an older algorithm. This makes
|
||||
@@ -767,66 +799,13 @@ func importDue(
|
||||
return false, false
|
||||
}
|
||||
|
||||
func (s *Service) Schedule(
|
||||
ctx context.Context,
|
||||
importEvery, fullEvery time.Duration,
|
||||
rebuildHour int,
|
||||
paused ...func() bool,
|
||||
) {
|
||||
// One ticker asks "is anything owed?"; the persisted stamps decide what and whether.
|
||||
// Two independent tickers measured process uptime, which is what let a restart reset
|
||||
// the cadence and a bounced container import far more often than configured.
|
||||
checkEvery := importEvery
|
||||
if checkEvery <= 0 || (fullEvery > 0 && fullEvery < checkEvery) {
|
||||
checkEvery = fullEvery
|
||||
}
|
||||
var importC <-chan time.Time
|
||||
if checkEvery > 0 {
|
||||
importTicker := time.NewTicker(checkEvery)
|
||||
importC = importTicker.C
|
||||
defer importTicker.Stop()
|
||||
s.log.Info("Tracearr auto-import scheduled",
|
||||
"incremental", importEvery.String(), "full", fullEvery.String())
|
||||
} else {
|
||||
s.log.Info("Tracearr auto-import disabled")
|
||||
}
|
||||
nextRebuild := nextDailyRebuild(time.Now(), s.location, rebuildHour)
|
||||
rebuildTimer := time.NewTimer(time.Until(nextRebuild))
|
||||
defer rebuildTimer.Stop()
|
||||
s.log.Info("For You daily rebuild scheduled", "next", nextRebuild)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-importC:
|
||||
if len(paused) > 0 && paused[0] != nil && paused[0]() {
|
||||
continue
|
||||
}
|
||||
if _, _, err := s.ImportIfDue(ctx, importEvery, fullEvery); err != nil {
|
||||
s.log.Warn("scheduled Tracearr import failed", "error", err)
|
||||
}
|
||||
case <-rebuildTimer.C:
|
||||
if len(paused) == 0 || paused[0] == nil || !paused[0]() {
|
||||
if err := s.RebuildAll(ctx, true); err != nil {
|
||||
s.log.Warn("scheduled daily For You rebuild failed", "error", err)
|
||||
}
|
||||
}
|
||||
rebuildTimer.Reset(time.Until(nextDailyRebuild(time.Now(), s.location, rebuildHour)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nextDailyRebuild(now time.Time, location *time.Location, hour int) time.Time {
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
local := now.In(location)
|
||||
next := time.Date(local.Year(), local.Month(), local.Day(), hour, 0, 0, 0, location)
|
||||
if !next.After(local) {
|
||||
next = next.AddDate(0, 0, 1)
|
||||
}
|
||||
return next
|
||||
}
|
||||
// Schedule used to own both the Tracearr import ticker and the daily rebuild timer, and
|
||||
// nextDailyRebuild was when the household's off-peak hour next came round. Both are gone:
|
||||
// the import and the rebuild are scheduler tasks now (see api.RegisterIntegrationTasks),
|
||||
// because a ticker in here could report nothing to an operator, could not be started by
|
||||
// hand, and — since the tasks carry an integration id — could not be counted towards
|
||||
// Tracearr's run history. The "is the rebuild due" rule moved with the work, to
|
||||
// api.forYouRebuildDue, and is still pure and still tested.
|
||||
|
||||
func (s *Service) beginBuild(userID string) bool {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -179,17 +179,6 @@ func TestImportDueRespectsDisabledIntervals(t *testing.T) {
|
||||
full, due)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextDailyRebuildUsesConfiguredLocalHour(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 31, 5, 30, 0, 0, location)
|
||||
next := nextDailyRebuild(now, location, 4)
|
||||
want := time.Date(2026, 8, 1, 4, 0, 0, 0, location)
|
||||
if !next.Equal(want) {
|
||||
t.Fatalf("next rebuild = %v, want %v", next, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredResultCapsCandidatePoolAndPersistsAlgorithmVersion(t *testing.T) {
|
||||
result := recommend.PreparedResult{
|
||||
Candidates: make([]recommend.PreparedCandidate, maxPreparedCandidates+50),
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/runtimestats"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/notify"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
@@ -108,7 +110,9 @@ func (d *Dispatcher) register(transport Transport) {
|
||||
// of events a minute, and serialising them means a destination cannot be hit with four
|
||||
// concurrent posts by a burst.
|
||||
func (d *Dispatcher) Start(ctx context.Context) {
|
||||
go func() {
|
||||
// Named rather than launched bare, so the console can say this worker is running
|
||||
// without having to infer it from a stack. See internal/runtimestats.
|
||||
runtimestats.Go("Integrations dispatcher", "Integrations", func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -126,7 +130,7 @@ func (d *Dispatcher) Start(ctx context.Context) {
|
||||
d.deliver(ctx, work)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// DeliverAdminEvent implements adminevents.Sink. It only enqueues: the bus calls this
|
||||
|
||||
@@ -285,3 +285,59 @@ func (c *Client) Movies(ctx context.Context) ([]Movie, error) {
|
||||
}
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
// QueueItem is one thing the download client is working on, in the narrow shape the
|
||||
// request page needs: which film, how far through, and how it is going.
|
||||
//
|
||||
// Radarr describes a download in three overlapping words rather than one, and all three
|
||||
// are needed. Status is the *download client's* view (queued, downloading, paused,
|
||||
// completed, failed, warning, delay). TrackedDownloadState is Radarr's own view of what
|
||||
// happens after the bytes land (downloading, importPending, importing, imported,
|
||||
// failedPending, failed) — which is the only thing that separates "still coming down the
|
||||
// wire" from "almost on the shelf". TrackedDownloadStatus is the verdict (ok, warning,
|
||||
// error), and is what says an otherwise healthy-looking row has actually gone wrong.
|
||||
type QueueItem struct {
|
||||
ID int `json:"id"`
|
||||
MovieID int `json:"movieId"`
|
||||
// Size and Sizeleft are bytes, as floats — Radarr sends them that way, and a film is
|
||||
// comfortably past what a 32-bit int holds.
|
||||
Size float64 `json:"size"`
|
||||
Sizeleft float64 `json:"sizeleft"`
|
||||
// Timeleft is the download client's own estimate, formatted "00:14:32" or
|
||||
// "1.02:03:04". It is absent for a queued or stalled item, which is exactly the case
|
||||
// where Memby must not invent one.
|
||||
Timeleft string `json:"timeleft"`
|
||||
Status string `json:"status"`
|
||||
TrackedDownloadState string `json:"trackedDownloadState"`
|
||||
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
}
|
||||
|
||||
// queuePageSize is what one read asks for. The queue is what the household is downloading
|
||||
// right now, so it is small by nature; the cap exists so a download client that has wedged
|
||||
// with a thousand rows cannot turn a request-page refresh into a large response.
|
||||
const queuePageSize = 200
|
||||
|
||||
type queuePage struct {
|
||||
Records []QueueItem `json:"records"`
|
||||
}
|
||||
|
||||
// Queue returns what Radarr is currently working on.
|
||||
//
|
||||
// Unknown items are excluded: those are downloads in the client that Radarr cannot match
|
||||
// to a film it tracks, so they can never be the answer to "what is happening to the thing
|
||||
// I asked for" and would only be rows nothing could use.
|
||||
func (c *Client) Queue(ctx context.Context) ([]QueueItem, error) {
|
||||
req, err := c.request(ctx, "/api/v3/queue", url.Values{
|
||||
"pageSize": {strconv.Itoa(queuePageSize)},
|
||||
"includeUnknownMovieItems": {"false"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page queuePage
|
||||
if err := c.do(req, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return page.Records, nil
|
||||
}
|
||||
|
||||
@@ -94,6 +94,15 @@ type Engine struct {
|
||||
Library LibrarySource
|
||||
Tracearr TracearrSource
|
||||
Behavior BehaviorSource
|
||||
// TracearrAllowed is the operator's global switch for the Tracearr integration, read
|
||||
// at call time rather than at construction: a switch consulted once at start-up is
|
||||
// not a switch, and turning an integration off must stop the calls without a restart.
|
||||
//
|
||||
// It is a function rather than a bool for the same reason it is not a policy lookup
|
||||
// inside the engine: what "switched off" means belongs to the gateway's integrations
|
||||
// area, and the ranker's business is ranking. Nil means allowed, so every test and
|
||||
// every caller that predates the switch behaves as it did.
|
||||
TracearrAllowed func(ctx context.Context) bool
|
||||
|
||||
// MinRowItems is the shortest row worth showing. A two-item "Recommended" strip
|
||||
// looks broken next to full rows, so short rows are dropped entirely.
|
||||
@@ -144,7 +153,7 @@ func (e *Engine) BuildForYou(
|
||||
|
||||
var sessions []tracearr.Session
|
||||
contextAffinity := NewContextAffinityProfile()
|
||||
if e.Tracearr != nil {
|
||||
if e.tracearrUsable(ctx) {
|
||||
if fetched, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
|
||||
e.log.Warn("tracearr history unavailable; using emby signals", "error", traceErr)
|
||||
} else {
|
||||
@@ -217,6 +226,17 @@ func powDecay(base float64, position int) float64 {
|
||||
return math.Pow(base, float64(position))
|
||||
}
|
||||
|
||||
// tracearrUsable reports whether the engine may call Tracearr right now. Falling back to
|
||||
// Emby's own signals is what happens when it may not, which is the same degradation an
|
||||
// unreachable Tracearr already produces — so switching it off costs the extra signal and
|
||||
// never the row.
|
||||
func (e *Engine) tracearrUsable(ctx context.Context) bool {
|
||||
if e.Tracearr == nil {
|
||||
return false
|
||||
}
|
||||
return e.TracearrAllowed == nil || e.TracearrAllowed(ctx)
|
||||
}
|
||||
|
||||
func (e *Engine) applyTracearrSignals(
|
||||
profile *Profile,
|
||||
history []Item,
|
||||
@@ -614,7 +634,7 @@ func (e *Engine) BuildRowsForUser(
|
||||
|
||||
profile := BuildProfile(history, favorites)
|
||||
contextAffinity := NewContextAffinityProfile()
|
||||
if e.Tracearr != nil && strings.TrimSpace(username) != "" {
|
||||
if e.tracearrUsable(ctx) && strings.TrimSpace(username) != "" {
|
||||
if sessions, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
|
||||
e.log.Warn("tracearr signals unavailable for shelves", "error", traceErr)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
/* The expensive half, and the reason it is a separate route.
|
||||
|
||||
Reading every goroutine's stack means stopping the world for as long as it takes to walk
|
||||
them all. That is measured in milliseconds rather than seconds, but it is not something
|
||||
to do on a poll every open console tab makes every thirty seconds — so this is collected
|
||||
only when an operator asks, and the answer says when it was collected and what it cost.
|
||||
|
||||
What comes back is a text dump, and parsing text a runtime produced is exactly the sort
|
||||
of thing that quietly stops working. Two rules keep that honest: parseGoroutines is pure
|
||||
and pinned by tests carrying real dump text, and every unrecognised state or frame falls
|
||||
into a named "other" bucket rather than being dropped — a breakdown whose parts do not
|
||||
add up to the total is worse than no breakdown, so the total is always the count of
|
||||
goroutines parsed and the categories always partition it. */
|
||||
|
||||
// Category is the readable grouping a goroutine state falls into. These are the words an
|
||||
// operator is being asked to reason with, so there are six of them and none is Go jargon.
|
||||
type Category string
|
||||
|
||||
const (
|
||||
CategoryRunning Category = "running"
|
||||
CategoryIO Category = "io"
|
||||
CategoryWaiting Category = "waiting"
|
||||
CategoryTimers Category = "timers"
|
||||
CategoryRuntime Category = "runtime"
|
||||
CategoryOther Category = "other"
|
||||
)
|
||||
|
||||
// categoryLabels is the console's wording. Sent from here, the stance every label the
|
||||
// gateway prints takes: a category added later reads correctly on a console that predates
|
||||
// it, because the console prints what it was handed.
|
||||
var categoryLabels = map[Category]string{
|
||||
CategoryRunning: "Running",
|
||||
CategoryIO: "Network / I/O",
|
||||
CategoryWaiting: "Waiting / idle",
|
||||
CategoryTimers: "Timers / scheduled work",
|
||||
CategoryRuntime: "Go runtime / collection",
|
||||
CategoryOther: "Other",
|
||||
}
|
||||
|
||||
// categoryOrder is the order they are presented in: busiest kind of work first, then the
|
||||
// two that mean the goroutine is doing nothing, then the runtime's own housekeeping. It is
|
||||
// fixed rather than sorted by count so the table does not reorder between two collections.
|
||||
var categoryOrder = []Category{
|
||||
CategoryRunning, CategoryIO, CategoryWaiting, CategoryTimers, CategoryRuntime, CategoryOther,
|
||||
}
|
||||
|
||||
// categoryDescriptions say what the operator is looking at, because "semacquire" means
|
||||
// nothing to somebody who does not write Go and the whole point of this page is that they
|
||||
// should not have to.
|
||||
var categoryDescriptions = map[Category]string{
|
||||
CategoryRunning: "On a processor now, or queued for one.",
|
||||
CategoryIO: "Waiting on a network read or write — Emby, Postgres, Redis, or a television.",
|
||||
CategoryWaiting: "Parked waiting for work or for a lock. Idle, and costing almost nothing.",
|
||||
CategoryTimers: "Asleep until a scheduled time.",
|
||||
CategoryRuntime: "The Go runtime's own housekeeping. This handful is always present.",
|
||||
CategoryOther: "A state this build does not have a category for.",
|
||||
}
|
||||
|
||||
// categoryFor maps a runtime state string onto one of the six. The GC states are tested
|
||||
// first because several of them contain words the later rules would otherwise claim —
|
||||
// "wait for GC cycle" is the runtime's own, not a component waiting on a channel.
|
||||
func categoryFor(state string) Category {
|
||||
state = strings.TrimSpace(state)
|
||||
lower := strings.ToLower(state)
|
||||
switch {
|
||||
case strings.Contains(lower, "gc "), strings.HasPrefix(lower, "gc"),
|
||||
lower == "finalizer wait", strings.HasPrefix(lower, "trace reader"),
|
||||
strings.HasPrefix(lower, "dumping heap"), strings.HasPrefix(lower, "stopping the world"),
|
||||
strings.HasPrefix(lower, "idle"):
|
||||
return CategoryRuntime
|
||||
case lower == "running", lower == "runnable", strings.HasPrefix(lower, "syscall"):
|
||||
return CategoryRunning
|
||||
case strings.HasPrefix(lower, "io wait"), strings.HasPrefix(lower, "netpoll"):
|
||||
return CategoryIO
|
||||
case strings.HasPrefix(lower, "sleep"), strings.HasPrefix(lower, "timer"):
|
||||
return CategoryTimers
|
||||
case strings.HasPrefix(lower, "select"), strings.HasPrefix(lower, "chan "),
|
||||
strings.HasPrefix(lower, "semacquire"), strings.HasPrefix(lower, "sync."),
|
||||
strings.HasPrefix(lower, "wait"), strings.HasPrefix(lower, "preempted"):
|
||||
return CategoryWaiting
|
||||
default:
|
||||
return CategoryOther
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- component attribution ---------- */
|
||||
|
||||
type componentRule struct {
|
||||
component string
|
||||
// patterns are matched against every frame of the stack — the function names and the
|
||||
// file paths alike — plus the "created by" line, which is often the only frame that
|
||||
// still names the component a long-parked goroutine belongs to.
|
||||
patterns []string
|
||||
}
|
||||
|
||||
// componentRules is ordered, and the order is the whole of it: Memby's own packages are
|
||||
// tested before the libraries they call, or every database query in the gateway would be
|
||||
// filed under "Database pool" and nothing would be attributable to the feature that made
|
||||
// it. First match wins.
|
||||
var componentRules = []componentRule{
|
||||
{"Library sync", []string{"/internal/library"}},
|
||||
{"Scheduled jobs", []string{"/internal/scheduler"}},
|
||||
{"Credits detection", []string{"/internal/credits"}},
|
||||
{"Recommendations", []string{"/internal/recommend", "/internal/foryou", "/internal/tracearr"}},
|
||||
{"Notifications", []string{"/internal/notify"}},
|
||||
{"Integrations", []string{"/internal/integrations", "/internal/adminevents"}},
|
||||
{"Subtitles", []string{"/internal/bazarr", "/internal/opensubtitles", "/internal/subsync"}},
|
||||
{"Sonarr and Radarr", []string{"/internal/sonarr", "/internal/radarr"}},
|
||||
{"Ratings", []string{"/internal/mdblist"}},
|
||||
{"Emby", []string{"/internal/emby", "/internal/trickplay"}},
|
||||
{"Gateway API", []string{"/internal/api", "/internal/store", "/internal/cache", "/internal/logging"}},
|
||||
{"Database pool", []string{"jackc/pgx", "jackc/puddle", "database/sql"}},
|
||||
{"Redis", []string{"redis/go-redis"}},
|
||||
{"HTTP server", []string{"net/http.(*conn).serve", "net/http.(*Server)", "net/http.(*connReader)"}},
|
||||
{"HTTP client", []string{"net/http.(*persistConn)", "net/http.(*Transport)"}},
|
||||
{"Go runtime", []string{"runtime.", "runtime/pprof"}},
|
||||
}
|
||||
|
||||
const componentUnattributed = "Unattributed"
|
||||
|
||||
func componentFor(info goroutineInfo) string {
|
||||
haystack := info.searchText()
|
||||
for _, rule := range componentRules {
|
||||
for _, pattern := range rule.patterns {
|
||||
if strings.Contains(haystack, pattern) {
|
||||
return rule.component
|
||||
}
|
||||
}
|
||||
}
|
||||
return componentUnattributed
|
||||
}
|
||||
|
||||
/* ---------- parsing ---------- */
|
||||
|
||||
type frame struct {
|
||||
Function string
|
||||
File string
|
||||
}
|
||||
|
||||
type goroutineInfo struct {
|
||||
ID int
|
||||
State string
|
||||
WaitMinutes int
|
||||
Frames []frame
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
func (info goroutineInfo) searchText() string {
|
||||
var builder strings.Builder
|
||||
for _, one := range info.Frames {
|
||||
builder.WriteString(one.Function)
|
||||
builder.WriteByte('\n')
|
||||
builder.WriteString(one.File)
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
builder.WriteString(info.CreatedBy)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// topFrame is the function the goroutine is actually sitting in, skipping the runtime
|
||||
// plumbing that every parked goroutine shares. Without the skip, half the table reads
|
||||
// "runtime.gopark", which is true of every waiting goroutine in the process and therefore
|
||||
// tells nobody anything.
|
||||
func (info goroutineInfo) topFrame() frame {
|
||||
for _, one := range info.Frames {
|
||||
function := one.Function
|
||||
if strings.HasPrefix(function, "runtime.") || strings.HasPrefix(function, "internal/poll.runtime_") ||
|
||||
strings.HasPrefix(function, "sync.runtime_") || strings.HasPrefix(function, "sync.(*") ||
|
||||
strings.HasPrefix(function, "time.Sleep") {
|
||||
continue
|
||||
}
|
||||
return one
|
||||
}
|
||||
if len(info.Frames) > 0 {
|
||||
return info.Frames[0]
|
||||
}
|
||||
return frame{}
|
||||
}
|
||||
|
||||
// parseGoroutines reads a runtime.Stack dump. It is pure so the shape of the dump can be
|
||||
// pinned by a test rather than discovered when a Go release changes it.
|
||||
func parseGoroutines(dump string) []goroutineInfo {
|
||||
var out []goroutineInfo
|
||||
var current *goroutineInfo
|
||||
flush := func() {
|
||||
if current != nil {
|
||||
out = append(out, *current)
|
||||
current = nil
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(dump, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "goroutine "):
|
||||
flush()
|
||||
info, ok := parseHeader(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
current = &info
|
||||
case current == nil:
|
||||
continue
|
||||
case strings.HasPrefix(line, "created by "):
|
||||
current.CreatedBy = strings.TrimPrefix(line, "created by ")
|
||||
case strings.HasPrefix(line, "\t"):
|
||||
// A file line belongs to the function line above it.
|
||||
if len(current.Frames) > 0 {
|
||||
path := strings.TrimSpace(line)
|
||||
if cut := strings.LastIndex(path, " +0x"); cut > 0 {
|
||||
path = path[:cut]
|
||||
}
|
||||
current.Frames[len(current.Frames)-1].File = path
|
||||
}
|
||||
case strings.TrimSpace(line) == "":
|
||||
flush()
|
||||
default:
|
||||
current.Frames = append(current.Frames, frame{Function: functionName(line)})
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// parseHeader reads `goroutine 42 [select, 5 minutes]:`.
|
||||
func parseHeader(line string) (goroutineInfo, bool) {
|
||||
open := strings.Index(line, "[")
|
||||
shut := strings.LastIndex(line, "]")
|
||||
if open < 0 || shut < open {
|
||||
return goroutineInfo{}, false
|
||||
}
|
||||
id, _ := strconv.Atoi(strings.TrimSpace(line[len("goroutine "):open]))
|
||||
inside := line[open+1 : shut]
|
||||
state := inside
|
||||
minutes := 0
|
||||
// The state itself can contain a comma — "chan receive, 5 minutes" and "GC worker
|
||||
// (idle)" both do not, but "semacquire, 3 minutes, locked to thread" does, so only a
|
||||
// part that parses as a duration is taken as one.
|
||||
parts := strings.Split(inside, ", ")
|
||||
state = parts[0]
|
||||
for _, part := range parts[1:] {
|
||||
if value, ok := parseMinutes(part); ok {
|
||||
minutes = value
|
||||
}
|
||||
}
|
||||
return goroutineInfo{ID: id, State: state, WaitMinutes: minutes}, true
|
||||
}
|
||||
|
||||
func parseMinutes(part string) (int, bool) {
|
||||
part = strings.TrimSpace(part)
|
||||
if !strings.HasSuffix(part, " minutes") && !strings.HasSuffix(part, " minute") {
|
||||
return 0, false
|
||||
}
|
||||
value, err := strconv.Atoi(strings.Fields(part)[0])
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// functionName strips the argument list a stack dump prints after the function, since the
|
||||
// arguments are addresses and would make every otherwise-identical goroutine its own group.
|
||||
func functionName(line string) string {
|
||||
line = strings.TrimSpace(line)
|
||||
if open := strings.Index(line, "("); open > 0 {
|
||||
// Method receivers are themselves parenthesised — `pkg.(*Type).Method(0x1)` — so
|
||||
// the argument list is the last balanced group rather than the first bracket.
|
||||
if last := strings.LastIndex(line, "("); last > 0 && !strings.HasSuffix(line[:last], ".") {
|
||||
return line[:last]
|
||||
}
|
||||
return line[:open]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
/* ---------- the report ---------- */
|
||||
|
||||
type CategoryCount struct {
|
||||
Category Category `json:"category"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Count int `json:"count"`
|
||||
States []StateCount `json:"states"`
|
||||
}
|
||||
|
||||
type StateCount struct {
|
||||
State string `json:"state"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type ComponentCount struct {
|
||||
Component string `json:"component"`
|
||||
Count int `json:"count"`
|
||||
// LongestWaitMinutes is the age of the oldest goroutine attributed here. A component
|
||||
// whose count is climbing and whose oldest is hours old is the shape of a leak; one
|
||||
// that is busy and young is the shape of a busy evening.
|
||||
LongestWaitMinutes int `json:"longestWaitMinutes"`
|
||||
}
|
||||
|
||||
type StackGroup struct {
|
||||
Count int `json:"count"`
|
||||
Component string `json:"component"`
|
||||
Category Category `json:"category"`
|
||||
State string `json:"state"`
|
||||
Function string `json:"function"`
|
||||
File string `json:"file"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
LongestWaitMinutes int `json:"longestWaitMinutes"`
|
||||
}
|
||||
|
||||
type GoroutineReport struct {
|
||||
At time.Time `json:"at"`
|
||||
Total int `json:"total"`
|
||||
CollectedInMs float64 `json:"collectedInMs"`
|
||||
DumpBytes int `json:"dumpBytes"`
|
||||
Categories []CategoryCount `json:"categories"`
|
||||
Components []ComponentCount `json:"components"`
|
||||
Groups []StackGroup `json:"groups"`
|
||||
// GroupsTotal is how many distinct groups there were before the cap below; a gateway
|
||||
// with thousands of goroutines has a long tail that would be a page nobody reads.
|
||||
GroupsTotal int `json:"groupsTotal"`
|
||||
Workers []Worker `json:"workers"`
|
||||
}
|
||||
|
||||
// maxGroups caps the table. The groups are sorted by count, so what is dropped is the tail
|
||||
// of one-off goroutines, which is exactly the part that says nothing.
|
||||
const maxGroups = 40
|
||||
|
||||
// CollectGoroutines takes the dump and builds the report. This is the expensive call.
|
||||
func CollectGoroutines() GoroutineReport {
|
||||
begin := time.Now()
|
||||
dump := stackDump()
|
||||
report := buildReport(dump, time.Now())
|
||||
report.CollectedInMs = float64(time.Since(begin).Microseconds()) / 1000
|
||||
report.Workers = Workers()
|
||||
return report
|
||||
}
|
||||
|
||||
// StackDump is the raw text, for an operator who wants the whole thing rather than the
|
||||
// summary — the on-demand snapshot that replaces any temptation to leave a profiler
|
||||
// endpoint permanently open.
|
||||
func StackDump() string { return stackDump() }
|
||||
|
||||
// stackDump grows its buffer until the whole dump fits. runtime.Stack truncates silently
|
||||
// at the length of the buffer it is given, and a truncated dump would produce a breakdown
|
||||
// that is quietly missing whichever goroutines came last — which on a leak is the ones
|
||||
// worth seeing.
|
||||
func stackDump() string {
|
||||
size := 1 << 20
|
||||
for {
|
||||
buffer := make([]byte, size)
|
||||
written := runtime.Stack(buffer, true)
|
||||
if written < len(buffer) {
|
||||
return string(buffer[:written])
|
||||
}
|
||||
if size >= 1<<26 {
|
||||
return string(buffer[:written])
|
||||
}
|
||||
size *= 2
|
||||
}
|
||||
}
|
||||
|
||||
func buildReport(dump string, at time.Time) GoroutineReport {
|
||||
list := parseGoroutines(dump)
|
||||
report := GoroutineReport{At: at, Total: len(list), DumpBytes: len(dump)}
|
||||
|
||||
states := map[Category]map[string]int{}
|
||||
counts := map[Category]int{}
|
||||
components := map[string]*ComponentCount{}
|
||||
groups := map[string]*StackGroup{}
|
||||
|
||||
for _, info := range list {
|
||||
category := categoryFor(info.State)
|
||||
counts[category]++
|
||||
if states[category] == nil {
|
||||
states[category] = map[string]int{}
|
||||
}
|
||||
states[category][info.State]++
|
||||
|
||||
component := componentFor(info)
|
||||
entry := components[component]
|
||||
if entry == nil {
|
||||
entry = &ComponentCount{Component: component}
|
||||
components[component] = entry
|
||||
}
|
||||
entry.Count++
|
||||
if info.WaitMinutes > entry.LongestWaitMinutes {
|
||||
entry.LongestWaitMinutes = info.WaitMinutes
|
||||
}
|
||||
|
||||
top := info.topFrame()
|
||||
key := component + "\x00" + info.State + "\x00" + top.Function
|
||||
group := groups[key]
|
||||
if group == nil {
|
||||
group = &StackGroup{
|
||||
Component: component, Category: category, State: info.State,
|
||||
Function: top.Function, File: top.File, CreatedBy: info.CreatedBy,
|
||||
}
|
||||
groups[key] = group
|
||||
}
|
||||
group.Count++
|
||||
if info.WaitMinutes > group.LongestWaitMinutes {
|
||||
group.LongestWaitMinutes = info.WaitMinutes
|
||||
}
|
||||
}
|
||||
|
||||
for _, category := range categoryOrder {
|
||||
count := counts[category]
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
report.Categories = append(report.Categories, CategoryCount{
|
||||
Category: category,
|
||||
Label: categoryLabels[category],
|
||||
Description: categoryDescriptions[category],
|
||||
Count: count,
|
||||
States: sortedStates(states[category]),
|
||||
})
|
||||
}
|
||||
|
||||
for _, entry := range components {
|
||||
report.Components = append(report.Components, *entry)
|
||||
}
|
||||
sort.Slice(report.Components, func(a, b int) bool {
|
||||
if report.Components[a].Count != report.Components[b].Count {
|
||||
return report.Components[a].Count > report.Components[b].Count
|
||||
}
|
||||
return report.Components[a].Component < report.Components[b].Component
|
||||
})
|
||||
|
||||
for _, group := range groups {
|
||||
report.Groups = append(report.Groups, *group)
|
||||
}
|
||||
sort.Slice(report.Groups, func(a, b int) bool {
|
||||
if report.Groups[a].Count != report.Groups[b].Count {
|
||||
return report.Groups[a].Count > report.Groups[b].Count
|
||||
}
|
||||
return report.Groups[a].Function < report.Groups[b].Function
|
||||
})
|
||||
report.GroupsTotal = len(report.Groups)
|
||||
if len(report.Groups) > maxGroups {
|
||||
report.Groups = report.Groups[:maxGroups]
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func sortedStates(counts map[string]int) []StateCount {
|
||||
out := make([]StateCount, 0, len(counts))
|
||||
for state, count := range counts {
|
||||
out = append(out, StateCount{State: state, Count: count})
|
||||
}
|
||||
sort.Slice(out, func(a, b int) bool {
|
||||
if out[a].Count != out[b].Count {
|
||||
return out[a].Count > out[b].Count
|
||||
}
|
||||
return out[a].State < out[b].State
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Health is the answer the Process card exists to give: is the gateway healthy, and if
|
||||
// something is abnormal, which area should be looked at?
|
||||
//
|
||||
// It is stated on the server rather than derived in the console for the same reason every
|
||||
// schedule label is the gateway's wording — the thresholds and the sentence explaining
|
||||
// them belong together, and an older console must not be the thing deciding what "watch"
|
||||
// means. It is a pure function of a snapshot so the thresholds can be tested rather than
|
||||
// discovered in production.
|
||||
type Health struct {
|
||||
Level Level `json:"level"`
|
||||
Summary string `json:"summary"`
|
||||
Notes []Note `json:"notes"`
|
||||
Areas []string `json:"areas,omitempty"`
|
||||
}
|
||||
|
||||
type Level string
|
||||
|
||||
const (
|
||||
// LevelOK is nothing to do. LevelWatch is something that is not wrong yet and would
|
||||
// be if it continued — which is the whole reason trends are collected. LevelBad is
|
||||
// something an operator should act on now.
|
||||
LevelOK Level = "ok"
|
||||
LevelWatch Level = "watch"
|
||||
LevelBad Level = "bad"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
Level Level `json:"level"`
|
||||
// Area names the part of Memby to look at. It is the note's most useful field: a
|
||||
// number that has moved is only actionable once it points somewhere.
|
||||
Area string `json:"area"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// The thresholds. Heap is judged against the configured limit because that is the number
|
||||
// the container is actually killed at; the watch line is deliberately well below it, since
|
||||
// the point of a watch is to arrive before the incident rather than during it.
|
||||
const (
|
||||
heapShareBad = 0.90
|
||||
heapShareWatch = 0.75
|
||||
// goroutineLeakPerHour is what separates a gateway that has picked up work from one
|
||||
// that is not letting go of it. A household arriving home adds goroutines and gives
|
||||
// them back; a leak does not, so this is only ever consulted alongside a rising trend
|
||||
// measured over the trend window.
|
||||
goroutineLeakPerHour = 10
|
||||
goroutineFloodPerHour = 60
|
||||
// pauseWatchMs is a collection pause long enough for a television to notice. The
|
||||
// gateway answers a status poll every ten seconds from every open set.
|
||||
pauseWatchMs = 50
|
||||
pauseBadMs = 250
|
||||
// restartsWatch: a worker that has been launched this many times is one that keeps
|
||||
// dying. The registry counts starts for exactly this.
|
||||
restartsWatch = 3
|
||||
)
|
||||
|
||||
func assess(snapshot Snapshot) Health {
|
||||
var notes []Note
|
||||
|
||||
memory := snapshot.Memory
|
||||
switch {
|
||||
case memory.HeapShare >= heapShareBad:
|
||||
notes = append(notes, Note{
|
||||
Level: LevelBad, Area: "Memory",
|
||||
Message: fmt.Sprintf(
|
||||
"The heap is using %s of the %s limit. Past the limit the container is stopped, so this needs attention now.",
|
||||
percent(memory.HeapShare), bytes(uint64(memory.MemoryLimit)),
|
||||
),
|
||||
})
|
||||
case memory.HeapShare >= heapShareWatch:
|
||||
notes = append(notes, Note{
|
||||
Level: LevelWatch, Area: "Memory",
|
||||
Message: fmt.Sprintf(
|
||||
"The heap is using %s of the %s limit. There is room, but less than usual.",
|
||||
percent(memory.HeapShare), bytes(uint64(memory.MemoryLimit)),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if snapshot.HeapTrend.Direction == DirectionRising {
|
||||
notes = append(notes, Note{
|
||||
Level: LevelWatch, Area: "Memory",
|
||||
Message: fmt.Sprintf(
|
||||
"Heap in use has risen by about %s an hour over the last %s. If it does not fall after a collection, something is holding on to it.",
|
||||
bytes(uint64(math.Max(0, snapshot.HeapTrend.PerHour))), duration(snapshot.HeapTrend.SpanSeconds),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
if snapshot.GoroutineTrend.Direction == DirectionRising {
|
||||
rate := snapshot.GoroutineTrend.PerHour
|
||||
level := LevelOK
|
||||
switch {
|
||||
case rate >= goroutineFloodPerHour:
|
||||
level = LevelBad
|
||||
case rate >= goroutineLeakPerHour:
|
||||
level = LevelWatch
|
||||
}
|
||||
if level != LevelOK {
|
||||
notes = append(notes, Note{
|
||||
Level: level, Area: "Goroutines",
|
||||
Message: fmt.Sprintf(
|
||||
"Goroutines have risen by about %.0f an hour over the last %s, from %.0f to %.0f. Open the breakdown to see which component they belong to.",
|
||||
rate, duration(snapshot.GoroutineTrend.SpanSeconds),
|
||||
snapshot.GoroutineTrend.First, snapshot.GoroutineTrend.Latest,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case memory.PauseRecentMs >= pauseBadMs:
|
||||
notes = append(notes, Note{
|
||||
Level: LevelBad, Area: "Collection",
|
||||
Message: fmt.Sprintf(
|
||||
"Recent collections have paused the gateway for %.0f ms on average. Everything the televisions ask for waits that long.",
|
||||
memory.PauseRecentMs,
|
||||
),
|
||||
})
|
||||
case memory.PauseRecentMs >= pauseWatchMs:
|
||||
notes = append(notes, Note{
|
||||
Level: LevelWatch, Area: "Collection",
|
||||
Message: fmt.Sprintf(
|
||||
"Recent collections have paused the gateway for %.0f ms on average.", memory.PauseRecentMs,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
for _, worker := range snapshot.Workers {
|
||||
if worker.State == WorkerRunning && worker.Starts >= restartsWatch {
|
||||
notes = append(notes, Note{
|
||||
Level: LevelWatch, Area: worker.Component,
|
||||
Message: fmt.Sprintf(
|
||||
"%s has been started %d times. A background worker that keeps restarting is failing at something.",
|
||||
worker.Name, worker.Starts,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Health{
|
||||
Level: worst(notes),
|
||||
Summary: summarise(snapshot, notes),
|
||||
Notes: notes,
|
||||
Areas: areasOf(notes),
|
||||
}
|
||||
}
|
||||
|
||||
func worst(notes []Note) Level {
|
||||
level := LevelOK
|
||||
for _, note := range notes {
|
||||
if note.Level == LevelBad {
|
||||
return LevelBad
|
||||
}
|
||||
if note.Level == LevelWatch {
|
||||
level = LevelWatch
|
||||
}
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// summarise is the one line the overview card prints. When there is nothing to say it says
|
||||
// so plainly rather than reciting figures the operator can already see above it — and it
|
||||
// says whether the verdict is based on enough history to be worth anything, because "no
|
||||
// trouble" from four minutes of readings is a weaker claim than the same words from four
|
||||
// hours.
|
||||
func summarise(snapshot Snapshot, notes []Note) string {
|
||||
if len(notes) == 0 {
|
||||
if snapshot.GoroutineTrend.Direction == DirectionUnknown {
|
||||
return "Nothing abnormal. Still gathering history — trends need about fifteen minutes."
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Nothing abnormal. Goroutines and memory have been %s over the last %s.",
|
||||
steadyWord(snapshot), duration(snapshot.GoroutineTrend.SpanSeconds),
|
||||
)
|
||||
}
|
||||
areas := areasOf(notes)
|
||||
if len(notes) == 1 {
|
||||
return notes[0].Message
|
||||
}
|
||||
return fmt.Sprintf("%d things to look at, in %s.", len(notes), joinAreas(areas))
|
||||
}
|
||||
|
||||
func steadyWord(snapshot Snapshot) string {
|
||||
if snapshot.GoroutineTrend.Direction == DirectionFalling || snapshot.HeapTrend.Direction == DirectionFalling {
|
||||
return "steady or falling"
|
||||
}
|
||||
return "steady"
|
||||
}
|
||||
|
||||
func areasOf(notes []Note) []string {
|
||||
seen := map[string]bool{}
|
||||
var areas []string
|
||||
for _, note := range notes {
|
||||
if note.Area == "" || seen[note.Area] {
|
||||
continue
|
||||
}
|
||||
seen[note.Area] = true
|
||||
areas = append(areas, note.Area)
|
||||
}
|
||||
sort.Strings(areas)
|
||||
return areas
|
||||
}
|
||||
|
||||
func joinAreas(areas []string) string {
|
||||
switch len(areas) {
|
||||
case 0:
|
||||
return "the gateway"
|
||||
case 1:
|
||||
return areas[0]
|
||||
case 2:
|
||||
return areas[0] + " and " + areas[1]
|
||||
}
|
||||
return fmt.Sprintf("%s and %d other areas", areas[0], len(areas)-1)
|
||||
}
|
||||
|
||||
/* ---------- wording helpers ----------
|
||||
|
||||
These exist because the sentences above are the gateway's own wording, the stance every
|
||||
label the console prints takes: a console built before a threshold existed still reads
|
||||
correctly, and the figure in the sentence cannot disagree with the threshold that
|
||||
produced it. */
|
||||
|
||||
func percent(share float64) string {
|
||||
return fmt.Sprintf("%.1f%%", share*100)
|
||||
}
|
||||
|
||||
func bytes(value uint64) string {
|
||||
const unit = 1024
|
||||
if value < unit {
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
div, exponent := uint64(unit), 0
|
||||
for size := value / unit; size >= unit; size /= unit {
|
||||
div *= unit
|
||||
exponent++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(value)/float64(div), "KMGT"[exponent])
|
||||
}
|
||||
|
||||
func duration(seconds float64) string {
|
||||
switch {
|
||||
case seconds < 90:
|
||||
return fmt.Sprintf("%.0f seconds", seconds)
|
||||
case seconds < 5400:
|
||||
return fmt.Sprintf("%.0f minutes", seconds/60)
|
||||
default:
|
||||
return fmt.Sprintf("%.1f hours", seconds/3600)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime/pprof"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Process is what the operating system knows about the container that the Go runtime does
|
||||
// not. Every field of it is optional: this is read from /proc, which exists in the
|
||||
// container the gateway is deployed in and does not exist on a developer's machine, so an
|
||||
// unavailable figure is omitted rather than reported as zero — nought open sockets and
|
||||
// "could not look" are different answers and the console must not print the first for the
|
||||
// second.
|
||||
type Process struct {
|
||||
PID int `json:"pid"`
|
||||
// CPUSeconds is cumulative processor time, and CPUPercent is the share of one
|
||||
// processor used since the previous read. A hundred per cent is one core saturated,
|
||||
// not the machine: with GOMAXPROCS processors available the ceiling is that times a
|
||||
// hundred, which is why the console prints both.
|
||||
CPUSeconds float64 `json:"cpuSeconds,omitempty"`
|
||||
CPUPercent float64 `json:"cpuPercent,omitempty"`
|
||||
CPUKnown bool `json:"cpuKnown"`
|
||||
OpenFiles int `json:"openFiles,omitempty"`
|
||||
OpenSockets int `json:"openSockets,omitempty"`
|
||||
FileLimit int `json:"fileLimit,omitempty"`
|
||||
FilesKnown bool `json:"filesKnown"`
|
||||
}
|
||||
|
||||
// threadCount is operating-system threads, which is a different number from goroutines and
|
||||
// is the one that matters when goroutines are blocked in syscalls: the runtime creates a
|
||||
// thread per blocked call, and a thread costs far more than a goroutine does.
|
||||
func threadCount() int {
|
||||
profile := pprof.Lookup("threadcreate")
|
||||
if profile == nil {
|
||||
return 0
|
||||
}
|
||||
return profile.Count()
|
||||
}
|
||||
|
||||
// configuredMemoryLimit reports what the container was started with rather than what the
|
||||
// runtime currently holds, so the console can say whether the limit is deliberate.
|
||||
func configuredMemoryLimit() string {
|
||||
return strings.TrimSpace(os.Getenv("GOMEMLIMIT"))
|
||||
}
|
||||
|
||||
var (
|
||||
cpuMu sync.Mutex
|
||||
lastCPUAt time.Time
|
||||
lastCPUSecs float64
|
||||
lastPercent float64
|
||||
)
|
||||
|
||||
func readProcess() Process {
|
||||
process := Process{PID: os.Getpid()}
|
||||
if seconds, ok := processCPUSeconds(); ok {
|
||||
process.CPUSeconds = seconds
|
||||
process.CPUKnown = true
|
||||
process.CPUPercent = cpuPercentSince(seconds, time.Now())
|
||||
}
|
||||
if files, sockets, ok := openDescriptors(); ok {
|
||||
process.OpenFiles = files
|
||||
process.OpenSockets = sockets
|
||||
process.FilesKnown = true
|
||||
process.FileLimit = descriptorLimit()
|
||||
}
|
||||
return process
|
||||
}
|
||||
|
||||
// cpuPercentSince needs two readings, so the first call after start-up reports nothing
|
||||
// rather than dividing by the life of the process — an average over four hours would hide
|
||||
// exactly the spike somebody opened the page to find. A read that arrives too soon after
|
||||
// the last one repeats the previous answer instead of amplifying rounding into a spike.
|
||||
func cpuPercentSince(seconds float64, now time.Time) float64 {
|
||||
const minInterval = 5 * time.Second
|
||||
cpuMu.Lock()
|
||||
defer cpuMu.Unlock()
|
||||
if lastCPUAt.IsZero() {
|
||||
lastCPUAt, lastCPUSecs = now, seconds
|
||||
return 0
|
||||
}
|
||||
elapsed := now.Sub(lastCPUAt)
|
||||
if elapsed < minInterval {
|
||||
return lastPercent
|
||||
}
|
||||
used := seconds - lastCPUSecs
|
||||
lastCPUAt, lastCPUSecs = now, seconds
|
||||
if used < 0 {
|
||||
lastPercent = 0
|
||||
return 0
|
||||
}
|
||||
lastPercent = used / elapsed.Seconds() * 100
|
||||
return lastPercent
|
||||
}
|
||||
|
||||
// processCPUSeconds prefers /proc/self/schedstat, whose first field is cumulative time on
|
||||
// a processor in nanoseconds — exact, where /proc/self/stat is in clock ticks whose length
|
||||
// cannot be read without cgo and has to be assumed to be the usual hundredth of a second.
|
||||
func processCPUSeconds() (float64, bool) {
|
||||
if raw, err := os.ReadFile("/proc/self/schedstat"); err == nil {
|
||||
fields := strings.Fields(string(raw))
|
||||
if len(fields) > 0 {
|
||||
if nanos, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
||||
return nanos / 1e9, true
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile("/proc/self/stat")
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
// The second field is the command name in brackets and may itself contain spaces, so
|
||||
// the fields after it are counted from the closing bracket rather than from the start.
|
||||
shut := strings.LastIndex(string(raw), ")")
|
||||
if shut < 0 {
|
||||
return 0, false
|
||||
}
|
||||
fields := strings.Fields(string(raw)[shut+1:])
|
||||
// After the bracket, field 1 is the state; utime and stime are fields 12 and 13.
|
||||
if len(fields) < 13 {
|
||||
return 0, false
|
||||
}
|
||||
user, userErr := strconv.ParseFloat(fields[11], 64)
|
||||
system, systemErr := strconv.ParseFloat(fields[12], 64)
|
||||
if userErr != nil || systemErr != nil {
|
||||
return 0, false
|
||||
}
|
||||
const ticksPerSecond = 100
|
||||
return (user + system) / ticksPerSecond, true
|
||||
}
|
||||
|
||||
func openDescriptors() (files, sockets int, ok bool) {
|
||||
entries, err := os.ReadDir("/proc/self/fd")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
files++
|
||||
target, err := os.Readlink("/proc/self/fd/" + entry.Name())
|
||||
if err == nil && strings.HasPrefix(target, "socket:") {
|
||||
sockets++
|
||||
}
|
||||
}
|
||||
return files, sockets, true
|
||||
}
|
||||
|
||||
func descriptorLimit() int {
|
||||
raw, err := os.ReadFile("/proc/self/limits")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if !strings.HasPrefix(line, "Max open files") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(strings.TrimPrefix(line, "Max open files"))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
limit, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return limit
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A real dump, trimmed. The parser is the one part of this package that reads text a Go
|
||||
// release produces, so the fixture is kept verbatim rather than idealised — the argument
|
||||
// lists, the "+0x" offsets and the "created by … in goroutine N" suffix are all things the
|
||||
// parser has to survive.
|
||||
const sampleDump = `goroutine 1 [chan receive, 940 minutes]:
|
||||
main.run(0xc000122000)
|
||||
/app/cmd/memby-server/main.go:430 +0x8f4
|
||||
main.main()
|
||||
/app/cmd/memby-server/main.go:41 +0x1f
|
||||
|
||||
goroutine 18 [select]:
|
||||
github.com/ponzischeme89/memby/server/internal/library.(*Ingester).Run(0xc0001a2000, {0x1a4c0d0, 0xc0000a2000})
|
||||
/app/internal/library/ingest.go:118 +0x145
|
||||
created by github.com/ponzischeme89/memby/server/internal/runtimestats.Go in goroutine 1
|
||||
|
||||
goroutine 40 [IO wait, 3 minutes]:
|
||||
internal/poll.runtime_pollWait(0x7f2c1c0, 0x72)
|
||||
/usr/local/go/src/runtime/netpoll.go:351 +0x85
|
||||
net/http.(*conn).serve(0xc000310000, {0x1a4c0d0, 0xc0003a0000})
|
||||
/usr/local/go/src/net/http/server.go:2092 +0x5db
|
||||
created by net/http.(*Server).Serve in goroutine 55
|
||||
|
||||
goroutine 41 [GC worker (idle)]:
|
||||
runtime.gopark(0x0, 0x0, 0x0, 0x0, 0x0)
|
||||
/usr/local/go/src/runtime/proc.go:402 +0xce
|
||||
runtime.gcBgMarkWorker(0xc0000581c0)
|
||||
/usr/local/go/src/runtime/mgc.go:1310 +0xe5
|
||||
created by runtime.gcBgMarkStartWorkers in goroutine 1
|
||||
|
||||
goroutine 77 [semacquire, 3 minutes, locked to thread]:
|
||||
github.com/jackc/puddle/v2.(*Pool).acquire(0xc00019c000)
|
||||
/root/go/pkg/mod/github.com/jackc/puddle/v2@v2.2.2/pool.go:481 +0x10a
|
||||
created by github.com/jackc/pgx/v5/pgxpool.NewWithConfig in goroutine 1
|
||||
`
|
||||
|
||||
func TestParseGoroutinesReadsHeaderStateAndFrames(t *testing.T) {
|
||||
list := parseGoroutines(sampleDump)
|
||||
if len(list) != 5 {
|
||||
t.Fatalf("parsed %d goroutines, want 5", len(list))
|
||||
}
|
||||
|
||||
first := list[0]
|
||||
if first.ID != 1 || first.State != "chan receive" || first.WaitMinutes != 940 {
|
||||
t.Fatalf("first = %+v", first)
|
||||
}
|
||||
if first.Frames[0].Function != "main.run" {
|
||||
t.Fatalf("first frame function = %q", first.Frames[0].Function)
|
||||
}
|
||||
// The offset is stripped so two goroutines in the same place group together rather
|
||||
// than differing by an address.
|
||||
if first.Frames[0].File != "/app/cmd/memby-server/main.go:430" {
|
||||
t.Fatalf("first frame file = %q", first.Frames[0].File)
|
||||
}
|
||||
|
||||
if list[1].CreatedBy == "" {
|
||||
t.Fatal("created-by line was dropped")
|
||||
}
|
||||
// A state with trailing detail after a second comma keeps its state and its wait.
|
||||
last := list[4]
|
||||
if last.State != "semacquire" || last.WaitMinutes != 3 {
|
||||
t.Fatalf("last = %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// The categories have to partition the total or the breakdown is misleading, which is
|
||||
// worse than not offering one.
|
||||
func TestBuildReportPartitionsAndAttributes(t *testing.T) {
|
||||
report := buildReport(sampleDump, time.Now())
|
||||
if report.Total != 5 {
|
||||
t.Fatalf("total = %d", report.Total)
|
||||
}
|
||||
counted := 0
|
||||
for _, category := range report.Categories {
|
||||
counted += category.Count
|
||||
}
|
||||
if counted != report.Total {
|
||||
t.Fatalf("categories sum to %d, total %d", counted, report.Total)
|
||||
}
|
||||
attributed := 0
|
||||
for _, component := range report.Components {
|
||||
attributed += component.Count
|
||||
}
|
||||
if attributed != report.Total {
|
||||
t.Fatalf("components sum to %d, total %d", attributed, report.Total)
|
||||
}
|
||||
|
||||
got := map[string]string{}
|
||||
for _, group := range report.Groups {
|
||||
got[group.Component] = group.Function
|
||||
}
|
||||
// Memby's own packages win over the libraries they call, and the frame reported is
|
||||
// the one that names the work rather than the runtime plumbing every parked goroutine
|
||||
// shares.
|
||||
if got["Library sync"] != "github.com/ponzischeme89/memby/server/internal/library.(*Ingester).Run" {
|
||||
t.Fatalf("library group = %q", got["Library sync"])
|
||||
}
|
||||
if got["HTTP server"] != "net/http.(*conn).serve" {
|
||||
t.Fatalf("http group = %q", got["HTTP server"])
|
||||
}
|
||||
if got["Database pool"] != "github.com/jackc/puddle/v2.(*Pool).acquire" {
|
||||
t.Fatalf("database group = %q", got["Database pool"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryForKnownStates(t *testing.T) {
|
||||
cases := map[string]Category{
|
||||
"running": CategoryRunning,
|
||||
"runnable": CategoryRunning,
|
||||
"syscall": CategoryRunning,
|
||||
"IO wait": CategoryIO,
|
||||
"select": CategoryWaiting,
|
||||
"chan receive": CategoryWaiting,
|
||||
"semacquire": CategoryWaiting,
|
||||
"sync.Mutex.Lock": CategoryWaiting,
|
||||
"sleep": CategoryTimers,
|
||||
"timer goroutine": CategoryTimers,
|
||||
"GC worker (idle)": CategoryRuntime,
|
||||
"force gc (idle)": CategoryRuntime,
|
||||
"finalizer wait": CategoryRuntime,
|
||||
// The runtime's own wait outranks the "wait" rule, or the collector would be
|
||||
// reported as a component sitting on a channel.
|
||||
"wait for GC cycle": CategoryRuntime,
|
||||
"something new": CategoryOther,
|
||||
}
|
||||
for state, want := range cases {
|
||||
if got := categoryFor(state); got != want {
|
||||
t.Errorf("categoryFor(%q) = %q, want %q", state, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A live report has to hold together too: this is the one test that would notice a Go
|
||||
// release changing the dump format under the parser.
|
||||
func TestCollectGoroutinesAgreesWithTheRuntime(t *testing.T) {
|
||||
blocked := make(chan struct{})
|
||||
var ready sync.WaitGroup
|
||||
ready.Add(1)
|
||||
go func() {
|
||||
ready.Done()
|
||||
<-blocked
|
||||
}()
|
||||
ready.Wait()
|
||||
defer close(blocked)
|
||||
|
||||
report := CollectGoroutines()
|
||||
if report.Total < 2 {
|
||||
t.Fatalf("total = %d", report.Total)
|
||||
}
|
||||
counted := 0
|
||||
for _, category := range report.Categories {
|
||||
counted += category.Count
|
||||
}
|
||||
if counted != report.Total {
|
||||
t.Fatalf("categories sum to %d, total %d", counted, report.Total)
|
||||
}
|
||||
if report.CollectedInMs < 0 {
|
||||
t.Fatalf("collectedInMs = %v", report.CollectedInMs)
|
||||
}
|
||||
if !strings.Contains(StackDump(), "goroutine ") {
|
||||
t.Fatal("stack dump does not look like a stack dump")
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- trends ---------- */
|
||||
|
||||
func series(values []float64, every time.Duration) ([]float64, []time.Time) {
|
||||
at := make([]time.Time, len(values))
|
||||
base := time.Date(2026, 8, 19, 9, 0, 0, 0, time.UTC)
|
||||
for index := range values {
|
||||
at[index] = base.Add(time.Duration(index) * every)
|
||||
}
|
||||
return values, at
|
||||
}
|
||||
|
||||
func TestTrendRefusesToAnswerWithoutEnoughHistory(t *testing.T) {
|
||||
// Four readings a minute apart is a gateway that restarted three minutes ago, not a
|
||||
// trend, and "steady" would be a claim the data does not support.
|
||||
points, at := series([]float64{20, 24, 30, 44}, time.Minute)
|
||||
if got := trendOf(points, at, 5); got.Direction != DirectionUnknown {
|
||||
t.Fatalf("direction = %q, want unknown", got.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrendIgnoresABurstAndNamesASustainedRise(t *testing.T) {
|
||||
// A burst in the middle — a library import, a household arriving home — must not be
|
||||
// reported as a rise; medians are what make that true where a fitted line would not.
|
||||
burst := []float64{40, 41, 40, 42, 41, 300, 290, 40, 41, 40, 42, 41}
|
||||
points, at := series(burst, 5*time.Minute)
|
||||
if got := trendOf(points, at, 5); got.Direction != DirectionSteady {
|
||||
t.Fatalf("burst direction = %q (%+v), want steady", got.Direction, got)
|
||||
}
|
||||
|
||||
leak := make([]float64, 12)
|
||||
for index := range leak {
|
||||
leak[index] = float64(40 + index*5)
|
||||
}
|
||||
points, at = series(leak, 5*time.Minute)
|
||||
rising := trendOf(points, at, 5)
|
||||
if rising.Direction != DirectionRising {
|
||||
t.Fatalf("leak direction = %q (%+v), want rising", rising.Direction, rising)
|
||||
}
|
||||
if rising.PerHour <= 0 {
|
||||
t.Fatalf("perHour = %v, want a positive rate", rising.PerHour)
|
||||
}
|
||||
if rising.Latest != 95 || rising.First != 40 {
|
||||
t.Fatalf("first/latest = %v/%v", rising.First, rising.Latest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrendFloorKeepsAQuietGatewaySteady(t *testing.T) {
|
||||
// One extra goroutine over an hour is not a leak, and without the absolute floor the
|
||||
// proportional threshold would call it one on a gateway holding a handful.
|
||||
points, at := series([]float64{8, 8, 9, 9, 9, 9, 9, 10}, 10*time.Minute)
|
||||
if got := trendOf(points, at, 5); got.Direction != DirectionSteady {
|
||||
t.Fatalf("direction = %q (%+v), want steady", got.Direction, got)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the verdict ---------- */
|
||||
|
||||
func risingTrend(first, latest, perHour float64) Trend {
|
||||
return Trend{
|
||||
Direction: DirectionRising, PerHour: perHour, First: first, Latest: latest,
|
||||
SpanSeconds: 3600, Points: 60,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessSaysNothingWhenThereIsNothingToSay(t *testing.T) {
|
||||
health := assess(Snapshot{
|
||||
Memory: Memory{MemoryLimit: 384 << 20, HeapInuse: 16 << 20, HeapShare: 0.04},
|
||||
GoroutineTrend: Trend{Direction: DirectionSteady, SpanSeconds: 7200},
|
||||
HeapTrend: Trend{Direction: DirectionSteady},
|
||||
})
|
||||
if health.Level != LevelOK || len(health.Notes) != 0 {
|
||||
t.Fatalf("health = %+v", health)
|
||||
}
|
||||
if !strings.Contains(health.Summary, "Nothing abnormal") {
|
||||
t.Fatalf("summary = %q", health.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessNamesTheAreaToInvestigate(t *testing.T) {
|
||||
health := assess(Snapshot{
|
||||
Memory: Memory{MemoryLimit: 384 << 20, HeapInuse: 350 << 20, HeapShare: 0.92},
|
||||
GoroutineTrend: risingTrend(60, 400, 340),
|
||||
HeapTrend: Trend{Direction: DirectionSteady},
|
||||
})
|
||||
if health.Level != LevelBad {
|
||||
t.Fatalf("level = %q", health.Level)
|
||||
}
|
||||
areas := strings.Join(health.Areas, ",")
|
||||
if !strings.Contains(areas, "Memory") || !strings.Contains(areas, "Goroutines") {
|
||||
t.Fatalf("areas = %v", health.Areas)
|
||||
}
|
||||
// A verdict with no area is the number that could not be acted on, which is the whole
|
||||
// defect this replaced.
|
||||
for _, note := range health.Notes {
|
||||
if note.Area == "" {
|
||||
t.Fatalf("note without an area: %+v", note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessSeparatesADriftFromAFlood(t *testing.T) {
|
||||
drift := assess(Snapshot{GoroutineTrend: risingTrend(40, 60, 20)})
|
||||
if drift.Level != LevelWatch {
|
||||
t.Fatalf("drift level = %q", drift.Level)
|
||||
}
|
||||
flood := assess(Snapshot{GoroutineTrend: risingTrend(40, 400, 360)})
|
||||
if flood.Level != LevelBad {
|
||||
t.Fatalf("flood level = %q", flood.Level)
|
||||
}
|
||||
// A rise slower than the leak threshold is ordinary breathing and must not be a note
|
||||
// at all, or the card cries wolf on every busy evening.
|
||||
quiet := assess(Snapshot{GoroutineTrend: risingTrend(40, 44, 4)})
|
||||
if quiet.Level != LevelOK || len(quiet.Notes) != 0 {
|
||||
t.Fatalf("quiet = %+v", quiet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessNoticesAWorkerThatKeepsRestarting(t *testing.T) {
|
||||
health := assess(Snapshot{Workers: []Worker{
|
||||
{Name: "Library ingest", Component: "Library sync", State: WorkerRunning, Starts: 5},
|
||||
{Name: "Startup library sync", Component: "Library sync", State: WorkerFinished, Starts: 1},
|
||||
}})
|
||||
if health.Level != LevelWatch || len(health.Notes) != 1 {
|
||||
t.Fatalf("health = %+v", health)
|
||||
}
|
||||
if !strings.Contains(health.Notes[0].Message, "Library ingest") {
|
||||
t.Fatalf("note = %q", health.Notes[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- the registry ---------- */
|
||||
|
||||
func TestWorkersRecordRunningAndFinished(t *testing.T) {
|
||||
resetWorkers()
|
||||
t.Cleanup(resetWorkers)
|
||||
|
||||
release := make(chan struct{})
|
||||
var running sync.WaitGroup
|
||||
running.Add(1)
|
||||
Go("Library ingest", "Library sync", func() {
|
||||
running.Done()
|
||||
<-release
|
||||
})
|
||||
running.Wait()
|
||||
|
||||
var done sync.WaitGroup
|
||||
done.Add(1)
|
||||
Go("Startup library sync", "Library sync", func() { done.Done() })
|
||||
done.Wait()
|
||||
// The finished worker's goroutine has run its body; give the deferred end() the moment
|
||||
// it needs to land before reading the registry.
|
||||
waitFor(t, func() bool { return stateOf(t, "Startup library sync") == WorkerFinished })
|
||||
|
||||
list := Workers()
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("workers = %+v", list)
|
||||
}
|
||||
// Running first, so the table does not bury the thing that is still working under the
|
||||
// startup jobs that are not.
|
||||
if list[0].Name != "Library ingest" || list[0].State != WorkerRunning {
|
||||
t.Fatalf("first = %+v", list[0])
|
||||
}
|
||||
if list[1].State != WorkerFinished || list[1].Stopped.IsZero() {
|
||||
t.Fatalf("second = %+v", list[1])
|
||||
}
|
||||
close(release)
|
||||
waitFor(t, func() bool { return stateOf(t, "Library ingest") == WorkerFinished })
|
||||
|
||||
// A restart is counted rather than replacing the record: a worker restarted in a loop
|
||||
// reads exactly like a healthy one from a single snapshot and does not from Starts.
|
||||
Go("Library ingest", "Library sync", func() {})
|
||||
waitFor(t, func() bool { return startsOf(t, "Library ingest") == 2 })
|
||||
}
|
||||
|
||||
func stateOf(t *testing.T, name string) WorkerState {
|
||||
t.Helper()
|
||||
for _, worker := range Workers() {
|
||||
if worker.Name == name {
|
||||
return worker.State
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func startsOf(t *testing.T, name string) int {
|
||||
t.Helper()
|
||||
for _, worker := range Workers() {
|
||||
if worker.Name == name {
|
||||
return worker.Starts
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, done func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if done() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not reached")
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SampleInterval is how often the trend ring records a reading, and the ring's capacity is
|
||||
// how far back it can therefore see. A minute apart is far cheaper than the 30s poll the
|
||||
// console already makes, and four hours is long enough for a slow leak to become a line
|
||||
// rather than noise while costing a few kilobytes.
|
||||
const (
|
||||
SampleInterval = time.Minute
|
||||
trendCapacity = 240
|
||||
)
|
||||
|
||||
// Sample is one reading. Deliberately the same source as the headline figures — both come
|
||||
// from one ReadMemStats — because a trend drawn from a different counter than the number
|
||||
// printed above it is a trend an operator cannot check.
|
||||
type Sample struct {
|
||||
At time.Time `json:"at"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
HeapInuse uint64 `json:"heapInuse"`
|
||||
Sys uint64 `json:"sys"`
|
||||
NumGC uint32 `json:"numGc"`
|
||||
}
|
||||
|
||||
var (
|
||||
samplesMu sync.Mutex
|
||||
samples []Sample
|
||||
started = time.Now()
|
||||
)
|
||||
|
||||
// StartSampling records a reading every SampleInterval until ctx ends. It takes the first
|
||||
// reading immediately, so a console opened a minute after a deployment has a baseline
|
||||
// rather than an empty chart.
|
||||
func StartSampling(ctx context.Context) {
|
||||
record(readSample())
|
||||
ticker := time.NewTicker(SampleInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
record(readSample())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readSample() Sample {
|
||||
var memory runtime.MemStats
|
||||
runtime.ReadMemStats(&memory)
|
||||
return Sample{
|
||||
At: time.Now(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
HeapInuse: memory.HeapInuse,
|
||||
Sys: memory.Sys,
|
||||
NumGC: memory.NumGC,
|
||||
}
|
||||
}
|
||||
|
||||
func record(sample Sample) {
|
||||
samplesMu.Lock()
|
||||
defer samplesMu.Unlock()
|
||||
samples = append(samples, sample)
|
||||
if len(samples) > trendCapacity {
|
||||
samples = append(samples[:0], samples[len(samples)-trendCapacity:]...)
|
||||
}
|
||||
}
|
||||
|
||||
// Samples returns the trend ring oldest first.
|
||||
func Samples() []Sample {
|
||||
samplesMu.Lock()
|
||||
defer samplesMu.Unlock()
|
||||
out := make([]Sample, len(samples))
|
||||
copy(out, samples)
|
||||
return out
|
||||
}
|
||||
|
||||
/* ---------- trends ---------- */
|
||||
|
||||
// Direction is a trend's verdict. "unknown" is a real answer and the common one for the
|
||||
// first few minutes after a restart: claiming "steady" from two readings a minute apart
|
||||
// would be a claim the data does not support.
|
||||
type Direction string
|
||||
|
||||
const (
|
||||
DirectionUnknown Direction = "unknown"
|
||||
DirectionSteady Direction = "steady"
|
||||
DirectionRising Direction = "rising"
|
||||
DirectionFalling Direction = "falling"
|
||||
)
|
||||
|
||||
type Trend struct {
|
||||
Direction Direction `json:"direction"`
|
||||
// PerHour is the observed rate of change in the value's own units. It is what makes
|
||||
// "rising" actionable: two goroutines an hour is a leak worth naming, two a minute is
|
||||
// one worth investigating tonight.
|
||||
PerHour float64 `json:"perHour"`
|
||||
First float64 `json:"first"`
|
||||
Latest float64 `json:"latest"`
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
SpanSeconds float64 `json:"spanSeconds"`
|
||||
Points int `json:"points"`
|
||||
}
|
||||
|
||||
// minTrendPoints and minTrendSpan are what a trend has to earn before it is stated at all.
|
||||
// A leak is a claim about a slope, and a slope read off three readings inside five minutes
|
||||
// is indistinguishable from a household that happened to start watching something.
|
||||
const (
|
||||
minTrendPoints = 5
|
||||
minTrendSpan = 15 * time.Minute
|
||||
)
|
||||
|
||||
// trendOf is the whole rule and it is pure. It compares the median of the oldest quarter
|
||||
// of the window with the median of the newest quarter rather than fitting a line, because
|
||||
// a single burst — a library import, a household arriving home — is exactly the shape
|
||||
// least-squares reports as a trend and medians ignore.
|
||||
//
|
||||
// The threshold is proportional with an absolute floor: without the proportion a busy
|
||||
// gateway is permanently "rising", and without the floor a quiet one calls one extra
|
||||
// goroutine a trend.
|
||||
func trendOf(points []float64, at []time.Time, floor float64) Trend {
|
||||
trend := Trend{Direction: DirectionUnknown, Points: len(points)}
|
||||
if len(points) == 0 || len(at) != len(points) {
|
||||
return trend
|
||||
}
|
||||
trend.First, trend.Latest = points[0], points[len(points)-1]
|
||||
trend.Min, trend.Max = points[0], points[0]
|
||||
for _, value := range points {
|
||||
trend.Min = math.Min(trend.Min, value)
|
||||
trend.Max = math.Max(trend.Max, value)
|
||||
}
|
||||
span := at[len(at)-1].Sub(at[0])
|
||||
trend.SpanSeconds = span.Seconds()
|
||||
if len(points) < minTrendPoints || span < minTrendSpan {
|
||||
return trend
|
||||
}
|
||||
|
||||
quarter := len(points) / 4
|
||||
if quarter < 1 {
|
||||
quarter = 1
|
||||
}
|
||||
early := median(points[:quarter])
|
||||
late := median(points[len(points)-quarter:])
|
||||
change := late - early
|
||||
trend.PerHour = change / span.Hours()
|
||||
|
||||
threshold := math.Max(floor, early*0.15)
|
||||
switch {
|
||||
case change > threshold:
|
||||
trend.Direction = DirectionRising
|
||||
case change < -threshold:
|
||||
trend.Direction = DirectionFalling
|
||||
default:
|
||||
trend.Direction = DirectionSteady
|
||||
}
|
||||
return trend
|
||||
}
|
||||
|
||||
func median(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := make([]float64, len(values))
|
||||
copy(sorted, values)
|
||||
sort.Float64s(sorted)
|
||||
middle := len(sorted) / 2
|
||||
if len(sorted)%2 == 1 {
|
||||
return sorted[middle]
|
||||
}
|
||||
return (sorted[middle-1] + sorted[middle]) / 2
|
||||
}
|
||||
|
||||
func trendsFrom(list []Sample) (goroutines, heap, reserved Trend) {
|
||||
at := make([]time.Time, len(list))
|
||||
goroutinePoints := make([]float64, len(list))
|
||||
heapPoints := make([]float64, len(list))
|
||||
reservedPoints := make([]float64, len(list))
|
||||
for index, sample := range list {
|
||||
at[index] = sample.At
|
||||
goroutinePoints[index] = float64(sample.Goroutines)
|
||||
heapPoints[index] = float64(sample.HeapInuse)
|
||||
reservedPoints[index] = float64(sample.Sys)
|
||||
}
|
||||
// The floors: five goroutines, and four megabytes of memory. Below those, movement is
|
||||
// the ordinary breathing of a server answering requests.
|
||||
return trendOf(goroutinePoints, at, 5),
|
||||
trendOf(heapPoints, at, 4<<20),
|
||||
trendOf(reservedPoints, at, 4<<20)
|
||||
}
|
||||
|
||||
/* ---------- the snapshot the console polls ---------- */
|
||||
|
||||
// Memory separates the three figures an operator keeps conflating: what the program is
|
||||
// actually holding, what the Go runtime has reserved from the operating system on its
|
||||
// behalf, and the ceiling it has been given. Reserved is always the largest and is not a
|
||||
// leak; the ratio to the limit is the number worth watching.
|
||||
type Memory struct {
|
||||
HeapAlloc uint64 `json:"heapAlloc"`
|
||||
HeapInuse uint64 `json:"heapInuse"`
|
||||
HeapIdle uint64 `json:"heapIdle"`
|
||||
HeapReleased uint64 `json:"heapReleased"`
|
||||
StackInuse uint64 `json:"stackInuse"`
|
||||
Sys uint64 `json:"sys"`
|
||||
NextGC uint64 `json:"nextGc"`
|
||||
NumGC uint32 `json:"numGc"`
|
||||
// PauseTotalMs and PauseRecentMs are how much of the life of the gateway has been
|
||||
// spent stopped for collection. A rising heap that is being collected without pausing
|
||||
// is a working cache; one that pauses for longer every cycle is not.
|
||||
PauseTotalMs float64 `json:"pauseTotalMs"`
|
||||
PauseRecentMs float64 `json:"pauseRecentMs"`
|
||||
MemoryLimit int64 `json:"memoryLimit"`
|
||||
ConfiguredLim string `json:"configuredLimit,omitempty"`
|
||||
// HeapShare is heap in use as a fraction of the limit, or 0 when there is no limit.
|
||||
// Computed here rather than in the console so the health verdict and the figure the
|
||||
// operator reads cannot disagree.
|
||||
HeapShare float64 `json:"heapShare"`
|
||||
// GCPerHour is measured over the trend window rather than over the whole life of the
|
||||
// process, because "collections have become more frequent" is the useful form of it.
|
||||
GCPerHour float64 `json:"gcPerHour"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
At time.Time `json:"at"`
|
||||
UptimeSeconds float64 `json:"uptimeSeconds"`
|
||||
Goroutines int `json:"goroutines"`
|
||||
GOMAXPROCS int `json:"gomaxprocs"`
|
||||
Threads int `json:"threads"`
|
||||
GoVersion string `json:"goVersion"`
|
||||
|
||||
Memory Memory `json:"memory"`
|
||||
Workers []Worker `json:"workers"`
|
||||
Process Process `json:"process"`
|
||||
|
||||
Samples []Sample `json:"samples"`
|
||||
GoroutineTrend Trend `json:"goroutineTrend"`
|
||||
HeapTrend Trend `json:"heapTrend"`
|
||||
ReservedTrend Trend `json:"reservedTrend"`
|
||||
SampleEverySecs float64 `json:"sampleEverySeconds"`
|
||||
|
||||
Health Health `json:"health"`
|
||||
}
|
||||
|
||||
// Read builds the cheap snapshot. Nothing in here walks a stack or stops the world for
|
||||
// longer than a ReadMemStats, so it is safe on the 30-second poll every open console tab
|
||||
// makes.
|
||||
func Read() Snapshot {
|
||||
var memory runtime.MemStats
|
||||
runtime.ReadMemStats(&memory)
|
||||
now := time.Now()
|
||||
|
||||
limit := debug.SetMemoryLimit(-1)
|
||||
share := 0.0
|
||||
if limit > 0 && limit < math.MaxInt64 {
|
||||
share = float64(memory.HeapInuse) / float64(limit)
|
||||
}
|
||||
|
||||
list := Samples()
|
||||
goroutineTrend, heapTrend, reservedTrend := trendsFrom(list)
|
||||
|
||||
snapshot := Snapshot{
|
||||
At: now,
|
||||
UptimeSeconds: now.Sub(started).Seconds(),
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
GOMAXPROCS: runtime.GOMAXPROCS(0),
|
||||
Threads: threadCount(),
|
||||
GoVersion: runtime.Version(),
|
||||
Memory: Memory{
|
||||
HeapAlloc: memory.HeapAlloc,
|
||||
HeapInuse: memory.HeapInuse,
|
||||
HeapIdle: memory.HeapIdle,
|
||||
HeapReleased: memory.HeapReleased,
|
||||
StackInuse: memory.StackInuse,
|
||||
Sys: memory.Sys,
|
||||
NextGC: memory.NextGC,
|
||||
NumGC: memory.NumGC,
|
||||
PauseTotalMs: float64(memory.PauseTotalNs) / 1e6,
|
||||
PauseRecentMs: recentPauseMs(&memory),
|
||||
MemoryLimit: limit,
|
||||
ConfiguredLim: configuredMemoryLimit(),
|
||||
HeapShare: share,
|
||||
GCPerHour: gcPerHour(list),
|
||||
},
|
||||
Workers: Workers(),
|
||||
Process: readProcess(),
|
||||
Samples: list,
|
||||
GoroutineTrend: goroutineTrend,
|
||||
HeapTrend: heapTrend,
|
||||
ReservedTrend: reservedTrend,
|
||||
SampleEverySecs: SampleInterval.Seconds(),
|
||||
}
|
||||
snapshot.Health = assess(snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// recentPauseMs averages the last few recorded pauses. Go keeps a circular buffer of the
|
||||
// most recent 256, and the running total on its own cannot distinguish a process that
|
||||
// paused badly an hour ago from one pausing badly now.
|
||||
func recentPauseMs(memory *runtime.MemStats) float64 {
|
||||
const window = 16
|
||||
if memory.NumGC == 0 {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
total := uint64(0)
|
||||
for index := 0; index < window; index++ {
|
||||
slot := int(memory.NumGC) - 1 - index
|
||||
if slot < 0 {
|
||||
break
|
||||
}
|
||||
total += memory.PauseNs[slot%256]
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(total) / float64(count) / 1e6
|
||||
}
|
||||
|
||||
func gcPerHour(list []Sample) float64 {
|
||||
if len(list) < 2 {
|
||||
return 0
|
||||
}
|
||||
span := list[len(list)-1].At.Sub(list[0].At).Hours()
|
||||
if span <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(list[len(list)-1].NumGC-list[0].NumGC) / span
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package runtimestats answers the one question the console's Process card exists for: is
|
||||
// the gateway healthy, and if something is abnormal, which area should be looked at?
|
||||
//
|
||||
// A bare goroutine count cannot answer it. Twenty-five means nothing on its own — an
|
||||
// operator cannot tell from it what those goroutines are doing, which part of Memby they
|
||||
// belong to, whether the number is normal, or whether it has been climbing all week. This
|
||||
// package supplies the three things that make the number readable, and keeps them apart by
|
||||
// what they cost:
|
||||
//
|
||||
// - The registry here, which is free. A long-running Memby worker says its own name when
|
||||
// it starts, so "Library ingest" and "Emby health probe" are named rather than inferred
|
||||
// from a stack.
|
||||
// - The sampler in sample.go, which is a handful of counters on a slow tick. A single
|
||||
// instantaneous figure cannot show a leak; a trend can.
|
||||
// - The stack breakdown in goroutines.go, which is genuinely expensive and is therefore
|
||||
// collected only when an operator asks for it.
|
||||
//
|
||||
// Nothing here recovers a panic. A worker that dies must die exactly as it always did —
|
||||
// this package reports, and reporting must never change what it is reporting on.
|
||||
package runtimestats
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkerState is what became of a tracked worker. A worker that ends is not a fault —
|
||||
// several of the gateway's background jobs are one-shot startup work — but "still running"
|
||||
// and "finished" are different answers and the console must not merge them.
|
||||
type WorkerState string
|
||||
|
||||
const (
|
||||
WorkerRunning WorkerState = "running"
|
||||
WorkerFinished WorkerState = "finished"
|
||||
)
|
||||
|
||||
// Worker is one named background goroutine.
|
||||
type Worker struct {
|
||||
Name string `json:"name"`
|
||||
Component string `json:"component"`
|
||||
State WorkerState `json:"state"`
|
||||
Started time.Time `json:"started"`
|
||||
Stopped time.Time `json:"stopped,omitempty"`
|
||||
// Starts counts how many times this name has been launched. It is on the record
|
||||
// because a worker that is being restarted in a loop reads exactly like a healthy one
|
||||
// from a single snapshot, and does not from this number.
|
||||
Starts int `json:"starts"`
|
||||
}
|
||||
|
||||
type workerEntry struct {
|
||||
component string
|
||||
running int
|
||||
starts int
|
||||
started time.Time
|
||||
stopped time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
workersMu sync.Mutex
|
||||
workers = map[string]*workerEntry{}
|
||||
)
|
||||
|
||||
// Go starts fn on its own goroutine and records it under a name an operator can read. The
|
||||
// name is the worker's identity across restarts, so it must be stable and must not carry a
|
||||
// count or an address in it.
|
||||
func Go(name, component string, fn func()) {
|
||||
begin(name, component)
|
||||
go func() {
|
||||
defer end(name)
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
// Track records a worker whose goroutine something else owns. The returned function marks
|
||||
// it finished, and is safe to call more than once.
|
||||
func Track(name, component string) (done func()) {
|
||||
begin(name, component)
|
||||
var once sync.Once
|
||||
return func() { once.Do(func() { end(name) }) }
|
||||
}
|
||||
|
||||
func begin(name, component string) {
|
||||
now := time.Now()
|
||||
workersMu.Lock()
|
||||
defer workersMu.Unlock()
|
||||
entry := workers[name]
|
||||
if entry == nil {
|
||||
entry = &workerEntry{}
|
||||
workers[name] = entry
|
||||
}
|
||||
entry.component = component
|
||||
entry.running++
|
||||
entry.starts++
|
||||
entry.started = now
|
||||
entry.stopped = time.Time{}
|
||||
}
|
||||
|
||||
func end(name string) {
|
||||
now := time.Now()
|
||||
workersMu.Lock()
|
||||
defer workersMu.Unlock()
|
||||
entry := workers[name]
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
if entry.running > 0 {
|
||||
entry.running--
|
||||
}
|
||||
if entry.running == 0 {
|
||||
entry.stopped = now
|
||||
}
|
||||
}
|
||||
|
||||
// Workers lists what has been registered, running first and then by name, so the table
|
||||
// does not reorder itself under the operator on every poll.
|
||||
func Workers() []Worker {
|
||||
workersMu.Lock()
|
||||
list := make([]Worker, 0, len(workers))
|
||||
for name, entry := range workers {
|
||||
worker := Worker{
|
||||
Name: name,
|
||||
Component: entry.component,
|
||||
State: WorkerFinished,
|
||||
Started: entry.started,
|
||||
Stopped: entry.stopped,
|
||||
Starts: entry.starts,
|
||||
}
|
||||
if entry.running > 0 {
|
||||
worker.State = WorkerRunning
|
||||
worker.Stopped = time.Time{}
|
||||
}
|
||||
list = append(list, worker)
|
||||
}
|
||||
workersMu.Unlock()
|
||||
sort.Slice(list, func(a, b int) bool {
|
||||
if (list[a].State == WorkerRunning) != (list[b].State == WorkerRunning) {
|
||||
return list[a].State == WorkerRunning
|
||||
}
|
||||
return list[a].Name < list[b].Name
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
// resetWorkers exists for the tests; the registry is process-wide by design.
|
||||
func resetWorkers() {
|
||||
workersMu.Lock()
|
||||
workers = map[string]*workerEntry{}
|
||||
workersMu.Unlock()
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/adminevents"
|
||||
"github.com/ponzischeme89/memby/server/internal/runtimestats"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,22 @@ const tick = 30 * time.Second
|
||||
// minutes announcing itself into the notification bell every ten minutes.
|
||||
type TaskFunc func(ctx context.Context) (detail string, err error)
|
||||
|
||||
// Outcome is the richer form: the sentence, plus what the run counted.
|
||||
//
|
||||
// It exists because "412 movies checked, 7 updated, 2 skipped" is the question an operator
|
||||
// has about an integration and a sentence is a poor place to keep numbers — they cannot be
|
||||
// compared between runs, sorted, or drawn as anything but prose. Most tasks count nothing
|
||||
// and keep the plain TaskFunc; only work that processes a batch has anything to say here.
|
||||
type Outcome struct {
|
||||
// Detail is the one line the console prints beside the run, and is empty when nothing
|
||||
// happened — the same rule TaskFunc's return follows, and for the same reason.
|
||||
Detail string
|
||||
store.RunCounts
|
||||
}
|
||||
|
||||
// WorkFunc is a task that counts what it did. A task declares Run or Work, never both.
|
||||
type WorkFunc func(ctx context.Context) (Outcome, error)
|
||||
|
||||
// Task is a declaration. Everything about it except the operator's overrides is code, so
|
||||
// the registry is readable as a list of what the gateway does in the background.
|
||||
type Task struct {
|
||||
@@ -45,7 +62,14 @@ type Task struct {
|
||||
// RunOnStart runs the task once shortly after boot regardless of when it last ran.
|
||||
// For jobs whose cost is trivial and whose value is highest immediately.
|
||||
RunOnStart bool
|
||||
Run TaskFunc
|
||||
// Integration names the external service this job belongs to, and is empty for the
|
||||
// gateway's own work. It is what lets the integrations area read its operational
|
||||
// history out of this registry rather than keeping a second one: a run is filed
|
||||
// against the service as well as against the job, and nothing else is needed.
|
||||
Integration string
|
||||
Run TaskFunc
|
||||
// Work is Run's counting form. Exactly one of the two must be set.
|
||||
Work WorkFunc
|
||||
}
|
||||
|
||||
// Status is one task as the console reads it: the declaration, the operator's overrides,
|
||||
@@ -55,6 +79,9 @@ type Status struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Group string `json:"group"`
|
||||
// Integration is the external service this job belongs to, empty for the gateway's
|
||||
// own work. The integrations area lists a service's jobs by matching on it.
|
||||
Integration string `json:"integration,omitempty"`
|
||||
Interval int64 `json:"intervalSeconds"`
|
||||
// DefaultInterval is the cadence declared in code, which Interval hides whenever an
|
||||
// operator has overridden it. Both are sent because the console cannot otherwise tell
|
||||
@@ -132,8 +159,8 @@ func (s *Scheduler) Register(task Task) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if task.ID == "" || task.Run == nil {
|
||||
panic("scheduler: a task needs an id and a function")
|
||||
if task.ID == "" || (task.Run == nil) == (task.Work == nil) {
|
||||
panic("scheduler: a task needs an id and exactly one of Run or Work")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -166,7 +193,9 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
s.mu.Unlock()
|
||||
|
||||
s.restore(ctx)
|
||||
go s.loop(ctx)
|
||||
// Named rather than launched bare, so the console can say this worker is running
|
||||
// without having to infer it from a stack. See internal/runtimestats.
|
||||
runtimestats.Go("Task scheduler", "Scheduled jobs", func() { s.loop(ctx) })
|
||||
}
|
||||
|
||||
func (s *Scheduler) restore(ctx context.Context) {
|
||||
@@ -320,7 +349,7 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
|
||||
|
||||
var runID int64
|
||||
if s.store != nil {
|
||||
id, err := s.store.BeginTaskRun(ctx, task.ID, trigger)
|
||||
id, err := s.store.BeginTaskRun(ctx, task.ID, task.Integration, trigger)
|
||||
if err != nil {
|
||||
s.log.Warn("could not open task run", "task", task.ID, "error", err)
|
||||
} else {
|
||||
@@ -329,9 +358,10 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, task.Timeout)
|
||||
detail, err := s.safeRun(runCtx, task)
|
||||
outcome, err := s.safeRun(runCtx, task)
|
||||
cancel()
|
||||
elapsed := time.Since(started)
|
||||
detail := outcome.Detail
|
||||
|
||||
status := store.TaskSuccess
|
||||
failure := ""
|
||||
@@ -340,7 +370,7 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
|
||||
}
|
||||
if s.store != nil && runID != 0 {
|
||||
if closeErr := s.store.FinishTaskRun(
|
||||
context.WithoutCancel(ctx), runID, status, detail, failure,
|
||||
context.WithoutCancel(ctx), runID, status, detail, failure, outcome.RunCounts,
|
||||
); closeErr != nil {
|
||||
s.log.Warn("could not close task run", "task", task.ID, "error", closeErr)
|
||||
}
|
||||
@@ -349,9 +379,11 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
|
||||
finished := time.Now()
|
||||
entry.mu.Lock()
|
||||
entry.lastRun = &store.TaskRun{
|
||||
ID: runID, TaskID: task.ID, Trigger: trigger, Status: status,
|
||||
ID: runID, TaskID: task.ID, IntegrationID: task.Integration,
|
||||
Trigger: trigger, Status: status,
|
||||
StartedAt: started, FinishedAt: &finished,
|
||||
DurationMS: elapsed.Milliseconds(), Detail: detail, Error: failure,
|
||||
Counts: outcome.RunCounts,
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
|
||||
@@ -362,13 +394,17 @@ func (s *Scheduler) execute(ctx context.Context, entry *registered, trigger stri
|
||||
// panic takes the whole process down for a reason nobody is watching for, and one
|
||||
// housekeeping job with a nil map must not be able to stop the gateway serving
|
||||
// television.
|
||||
func (s *Scheduler) safeRun(ctx context.Context, task Task) (detail string, err error) {
|
||||
func (s *Scheduler) safeRun(ctx context.Context, task Task) (outcome Outcome, err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("task panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
return task.Run(ctx)
|
||||
if task.Work != nil {
|
||||
return task.Work(ctx)
|
||||
}
|
||||
detail, err := task.Run(ctx)
|
||||
return Outcome{Detail: detail}, err
|
||||
}
|
||||
|
||||
// announce writes the log line and, when it is worth an operator's attention, publishes
|
||||
@@ -489,6 +525,7 @@ func (s *Scheduler) Snapshot() []Status {
|
||||
status := Status{
|
||||
ID: entry.task.ID, Name: entry.task.Name,
|
||||
Description: entry.task.Description, Group: entry.task.Group,
|
||||
Integration: entry.task.Integration,
|
||||
Interval: int64(entry.effectiveInterval() / time.Second),
|
||||
DefaultInterval: int64(entry.task.Interval / time.Second),
|
||||
Enabled: entry.enabled, Running: entry.running,
|
||||
|
||||
@@ -307,3 +307,46 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueueItem is one episode the download client is working on. See radarr.QueueItem for why
|
||||
// three status words are carried rather than one; Sonarr describes a download in the same
|
||||
// vocabulary.
|
||||
//
|
||||
// SeriesID is what the request page joins on: a viewer asks for a show, and what comes back
|
||||
// is some number of episodes of it.
|
||||
type QueueItem struct {
|
||||
ID int `json:"id"`
|
||||
SeriesID int `json:"seriesId"`
|
||||
EpisodeID int `json:"episodeId"`
|
||||
Size float64 `json:"size"`
|
||||
Sizeleft float64 `json:"sizeleft"`
|
||||
Timeleft string `json:"timeleft"`
|
||||
Status string `json:"status"`
|
||||
TrackedDownloadState string `json:"trackedDownloadState"`
|
||||
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
}
|
||||
|
||||
// queuePageSize is what one read asks for; see radarr's own note.
|
||||
const queuePageSize = 200
|
||||
|
||||
type queuePage struct {
|
||||
Records []QueueItem `json:"records"`
|
||||
}
|
||||
|
||||
// Queue returns what Sonarr is currently working on, excluding downloads it cannot match to
|
||||
// a series it tracks.
|
||||
func (c *Client) Queue(ctx context.Context) ([]QueueItem, error) {
|
||||
req, err := c.request(ctx, "/api/v3/queue", url.Values{
|
||||
"pageSize": {strconv.Itoa(queuePageSize)},
|
||||
"includeUnknownSeriesItems": {"false"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page queuePage
|
||||
if err := c.do(req, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return page.Records, nil
|
||||
}
|
||||
|
||||
@@ -64,6 +64,14 @@ type TracearrImportState struct {
|
||||
LastIncrementalAt *time.Time `json:"lastIncrementalAt,omitempty"`
|
||||
LastFullAt *time.Time `json:"lastFullAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
// LastRebuildAt is when the household's For You rows were last rebuilt in full.
|
||||
//
|
||||
// It lives in this document rather than in a table of its own because it is the same
|
||||
// kind of fact as the two stamps above it — where the For You pipeline has got to —
|
||||
// and because the daily rebuild is now a scheduled task, which means the alternative
|
||||
// was inferring "did today's rebuild happen" from run history that also records the
|
||||
// ticks on which it correctly declined to run.
|
||||
LastRebuildAt *time.Time `json:"lastRebuildAt,omitempty"`
|
||||
}
|
||||
|
||||
type RecommendationProfile struct {
|
||||
@@ -407,6 +415,20 @@ func (s *Store) SetTracearrImportState(ctx context.Context, state TracearrImport
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkForYouRebuild records that the household's rows have just been rebuilt.
|
||||
//
|
||||
// Read-modify-write rather than a whole-document put, because the importer owns the other
|
||||
// two stamps in this document and an import running beside a rebuild must not lose its own.
|
||||
func (s *Store) MarkForYouRebuild(ctx context.Context, at time.Time) error {
|
||||
state, err := s.TracearrImportState(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stamp := at.UTC()
|
||||
state.LastRebuildAt = &stamp
|
||||
return s.SetTracearrImportState(ctx, state)
|
||||
}
|
||||
|
||||
func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (emby_user_id)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// IntegrationPolicyKey is the operator's global on/off switch for external services that
|
||||
// have no configuration document of their own.
|
||||
//
|
||||
// It is deliberately *not* a second copy of every integration's switch. Sonarr and Radarr
|
||||
// already store theirs in the arr integration policy and MDBList stores its own in the
|
||||
// ratings settings; moving those here would mean a migration and, worse, a window in which
|
||||
// two documents disagreed about whether a service was on. The rule is one stored truth per
|
||||
// integration, kept where that integration's other configuration already lives — and this
|
||||
// document is that home for the ones that have nowhere else, which today is Tracearr.
|
||||
//
|
||||
// The API's integrationEnabled/setIntegrationEnabled pair is the single reader and writer,
|
||||
// so the console never has to know which document answers for which service.
|
||||
const IntegrationPolicyKey = "integration_policy"
|
||||
|
||||
// IntegrationPolicy is a map of integration id to whether it is switched on.
|
||||
//
|
||||
// Absence means on. A household that upgrades into this feature has every service it had
|
||||
// configured still working, which is the only safe reading: the alternative silently turns
|
||||
// off recommendation imports on the day the console gained a switch for them.
|
||||
type IntegrationPolicy struct {
|
||||
Disabled map[string]bool `json:"disabled"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Enabled reports whether one integration is switched on. A service nobody has ever
|
||||
// touched has no entry, and no entry is on.
|
||||
func (p IntegrationPolicy) Enabled(id string) bool {
|
||||
return !p.Disabled[strings.TrimSpace(id)]
|
||||
}
|
||||
|
||||
func (s *Store) IntegrationPolicy(ctx context.Context) (IntegrationPolicy, error) {
|
||||
empty := IntegrationPolicy{Disabled: map[string]bool{}}
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT value FROM app_settings WHERE key = $1`, IntegrationPolicyKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return empty, nil
|
||||
}
|
||||
if err != nil {
|
||||
return empty, fmt.Errorf("store: read integration policy: %w", err)
|
||||
}
|
||||
var policy IntegrationPolicy
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return empty, fmt.Errorf("store: decode integration policy: %w", err)
|
||||
}
|
||||
if policy.Disabled == nil {
|
||||
policy.Disabled = map[string]bool{}
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
// SetIntegrationEnabled records one service's switch, leaving every other entry alone.
|
||||
//
|
||||
// Read-modify-write rather than a whole-document put, because the console sends one
|
||||
// switch at a time and a put would let a page rendered before another integration existed
|
||||
// silently re-enable it.
|
||||
func (s *Store) SetIntegrationEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("store: integration policy needs an id")
|
||||
}
|
||||
policy, err := s.IntegrationPolicy(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled {
|
||||
delete(policy.Disabled, id)
|
||||
} else {
|
||||
policy.Disabled[id] = true
|
||||
}
|
||||
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()`,
|
||||
IntegrationPolicyKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write integration policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -223,6 +223,40 @@ func (s *Store) MediaRatingsStats(ctx context.Context, staleBefore time.Time) (t
|
||||
return total, stale, nil
|
||||
}
|
||||
|
||||
// StaleRatingKeys is the oldest stored titles due to be re-checked, oldest first.
|
||||
//
|
||||
// Oldest first rather than by any measure of popularity, because the refresh is bounded
|
||||
// per run: taking the oldest means every title comes round eventually, where taking the
|
||||
// most-watched would leave the tail of the library permanently on scores from the year it
|
||||
// was imported. The limit is what keeps a run's cost — and therefore the day's external
|
||||
// allowance — a figure the operator can reason about.
|
||||
func (s *Store) StaleRatingKeys(
|
||||
ctx context.Context, staleBefore time.Time, limit int,
|
||||
) ([]RatingKey, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT media_type, provider, provider_id
|
||||
FROM external_media_ratings
|
||||
WHERE fetched_at < $1
|
||||
ORDER BY fetched_at ASC
|
||||
LIMIT $2`, staleBefore, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: stale rating keys: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
keys := []RatingKey{}
|
||||
for rows.Next() {
|
||||
var key RatingKey
|
||||
if err := rows.Scan(&key.MediaType, &key.Provider, &key.ProviderID); err != nil {
|
||||
return nil, fmt.Errorf("store: scan stale rating key: %w", err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// SaveMediaRatings upserts a successfully loaded provider response.
|
||||
func (s *Store) SaveMediaRatings(
|
||||
ctx context.Context, mediaType, provider, providerID string, ratings json.RawMessage,
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// MediaRequest is one viewer's ask, as recorded. It carries no status: see the schema
|
||||
// comment on media_requests for why the state is derived per read rather than stored.
|
||||
// MediaRequest is one viewer's ask, as recorded.
|
||||
//
|
||||
// The status a card shows is not here: it is derived per read from the *arrs and the
|
||||
// library, for the reason the schema gives. The one exception is LastStatus, which is not
|
||||
// the card's status but the memory of it — the only way to notice that something changed.
|
||||
type MediaRequest struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
ForeignID int `json:"foreignId"`
|
||||
@@ -16,6 +19,19 @@ type MediaRequest struct {
|
||||
Year int `json:"year,omitempty"`
|
||||
PosterURL string `json:"posterUrl,omitempty"`
|
||||
RequestedAt time.Time `json:"requestedAt"`
|
||||
// LastStatus is the state this request was in the last time anything looked, and it is
|
||||
// the only piece of request state that is stored. See the schema comment: an arrival is
|
||||
// a difference between two observations rather than a property of one, and the viewer is
|
||||
// told about it once.
|
||||
LastStatus string `json:"-"`
|
||||
}
|
||||
|
||||
// OwnedMediaRequest is a stored ask with the person who made it, which the per-viewer read
|
||||
// does not need to carry because it was asked for by user. The ready sweep looks at the
|
||||
// whole household in one query, so there it is the point.
|
||||
type OwnedMediaRequest struct {
|
||||
MediaRequest
|
||||
UserID string
|
||||
}
|
||||
|
||||
// RequestUsage is the operator-facing use of the request feature. A recorded request is
|
||||
@@ -59,16 +75,20 @@ func (s *Store) SaveMediaRequest(ctx context.Context, userID string, req MediaRe
|
||||
if userID == "" || req.ForeignID <= 0 {
|
||||
return fmt.Errorf("store: media request needs a user and a foreign id")
|
||||
}
|
||||
// last_status is deliberately absent from the UPDATE. Asking again is somebody saying
|
||||
// they still want it, not a reason to re-announce an arrival they were already told
|
||||
// about — and re-seeding it here would make a second press of Request the way to make
|
||||
// the gateway repeat itself.
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO media_requests
|
||||
(emby_user_id, media_type, foreign_id, title, year, poster_url, requested_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||
(emby_user_id, media_type, foreign_id, title, year, poster_url, last_status, requested_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||
ON CONFLICT (emby_user_id, media_type, foreign_id) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
year = EXCLUDED.year,
|
||||
poster_url = EXCLUDED.poster_url,
|
||||
requested_at = now()`,
|
||||
userID, req.MediaType, req.ForeignID, req.Title, req.Year, req.PosterURL)
|
||||
userID, req.MediaType, req.ForeignID, req.Title, req.Year, req.PosterURL, req.LastStatus)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: save media request: %w", err)
|
||||
}
|
||||
@@ -78,7 +98,7 @@ func (s *Store) SaveMediaRequest(ctx context.Context, userID string, req MediaRe
|
||||
// MediaRequests returns one viewer's asks, most recent first.
|
||||
func (s *Store) MediaRequests(ctx context.Context, userID string) ([]MediaRequest, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT media_type, foreign_id, title, year, poster_url, requested_at
|
||||
SELECT media_type, foreign_id, title, year, poster_url, last_status, requested_at
|
||||
FROM media_requests
|
||||
WHERE emby_user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
@@ -92,7 +112,8 @@ func (s *Store) MediaRequests(ctx context.Context, userID string) ([]MediaReques
|
||||
for rows.Next() {
|
||||
var req MediaRequest
|
||||
if err := rows.Scan(
|
||||
&req.MediaType, &req.ForeignID, &req.Title, &req.Year, &req.PosterURL, &req.RequestedAt,
|
||||
&req.MediaType, &req.ForeignID, &req.Title, &req.Year, &req.PosterURL,
|
||||
&req.LastStatus, &req.RequestedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan media request: %w", err)
|
||||
}
|
||||
@@ -118,3 +139,60 @@ func (s *Store) DeleteMediaRequest(
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllMediaRequests reads the whole household's asks, newest first, with the person attached.
|
||||
//
|
||||
// The per-viewer read above is what a page needs; this is what the ready sweep needs, and
|
||||
// the difference is worth one query rather than one per account: a household of six with
|
||||
// eighty requests between them is one read, and the sweep has to look at all of them anyway
|
||||
// because two people can be waiting for the same film.
|
||||
//
|
||||
// It is bounded like the per-viewer read. A sweep that fell behind on a household which had
|
||||
// been asking for things for two years must not become an unbounded query on a timer.
|
||||
func (s *Store) AllMediaRequests(ctx context.Context, limit int) ([]OwnedMediaRequest, error) {
|
||||
if limit <= 0 {
|
||||
limit = MediaRequestSweepLimit
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT emby_user_id, media_type, foreign_id, title, year, poster_url, last_status, requested_at
|
||||
FROM media_requests
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: read all media requests: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
requests := []OwnedMediaRequest{}
|
||||
for rows.Next() {
|
||||
var req OwnedMediaRequest
|
||||
if err := rows.Scan(
|
||||
&req.UserID, &req.MediaType, &req.ForeignID, &req.Title, &req.Year,
|
||||
&req.PosterURL, &req.LastStatus, &req.RequestedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan media request: %w", err)
|
||||
}
|
||||
requests = append(requests, req)
|
||||
}
|
||||
return requests, rows.Err()
|
||||
}
|
||||
|
||||
// MediaRequestSweepLimit caps what one pass of the ready sweep will look at.
|
||||
const MediaRequestSweepLimit = 500
|
||||
|
||||
// SetMediaRequestStatus records what a request was last seen doing.
|
||||
//
|
||||
// Written only when the state actually moved, so a sweep over a household where nothing has
|
||||
// changed — which is almost every sweep — costs no writes at all.
|
||||
func (s *Store) SetMediaRequestStatus(
|
||||
ctx context.Context, userID, mediaType string, foreignID int, status string,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE media_requests SET last_status = $4
|
||||
WHERE emby_user_id = $1 AND media_type = $2 AND foreign_id = $3`,
|
||||
strings.TrimSpace(userID), mediaType, foreignID, status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set media request status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,17 +29,44 @@ const (
|
||||
TriggerStartup = "startup"
|
||||
)
|
||||
|
||||
// RunCounts is what a run did, in numbers.
|
||||
//
|
||||
// Four figures rather than a free map, because these are the four questions an operator
|
||||
// asks of any piece of batch work — how much did it look at, how much did it change, how
|
||||
// much did it decline, how much went wrong — and a schema-free bag would let two
|
||||
// integrations answer them under different names. Anything an integration counts beyond
|
||||
// these belongs in the run's own sentence.
|
||||
//
|
||||
// Processed is the load-bearing one: a run reporting zero processed counted nothing at
|
||||
// all, and the console draws no figures rather than four zeroes.
|
||||
type RunCounts struct {
|
||||
Processed int `json:"processed"`
|
||||
Changed int `json:"changed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// Counted reports whether this run counted anything worth printing.
|
||||
func (c RunCounts) Counted() bool {
|
||||
return c.Processed != 0 || c.Changed != 0 || c.Skipped != 0 || c.Failed != 0
|
||||
}
|
||||
|
||||
// TaskRun is one execution.
|
||||
type TaskRun struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskID string `json:"taskId"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
TaskID string `json:"taskId"`
|
||||
// IntegrationID names the external service this run belongs to, and is empty for the
|
||||
// gateway's own housekeeping. It is what lets the integrations area read operational
|
||||
// history out of the scheduler's own table instead of keeping a second one.
|
||||
IntegrationID string `json:"integrationId,omitempty"`
|
||||
Trigger string `json:"trigger"`
|
||||
Status string `json:"status"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Counts RunCounts `json:"counts"`
|
||||
}
|
||||
|
||||
// TaskSettings is an operator's override for one task. IntervalSeconds of 0 means "the
|
||||
@@ -55,24 +82,31 @@ type TaskSettings struct {
|
||||
// BeginTaskRun opens a run and returns its id. The row exists before the work starts so a
|
||||
// task killed by a restart leaves evidence it began — which is the only way to tell a job
|
||||
// that hangs from one that was never scheduled.
|
||||
func (s *Store) BeginTaskRun(ctx context.Context, taskID, trigger string) (int64, error) {
|
||||
func (s *Store) BeginTaskRun(
|
||||
ctx context.Context, taskID, integrationID, trigger string,
|
||||
) (int64, error) {
|
||||
var id int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO scheduled_task_runs (task_id, trigger, status)
|
||||
VALUES ($1, $2, $3) RETURNING id`, taskID, trigger, TaskRunning).Scan(&id)
|
||||
INSERT INTO scheduled_task_runs (task_id, integration_id, trigger, status)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
taskID, integrationID, trigger, TaskRunning).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: begin task run: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// FinishTaskRun closes a run with its outcome.
|
||||
func (s *Store) FinishTaskRun(ctx context.Context, id int64, status, detail, failure string) error {
|
||||
// FinishTaskRun closes a run with its outcome and whatever it counted.
|
||||
func (s *Store) FinishTaskRun(
|
||||
ctx context.Context, id int64, status, detail, failure string, counts RunCounts,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE scheduled_task_runs
|
||||
SET status = $2, finished_at = now(), detail = $3, error = $4,
|
||||
processed = $5, changed = $6, skipped = $7, failed = $8,
|
||||
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - started_at)) * 1000)::bigint
|
||||
WHERE id = $1`, id, status, detail, failure)
|
||||
WHERE id = $1`, id, status, detail, failure,
|
||||
counts.Processed, counts.Changed, counts.Skipped, counts.Failed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: finish task run: %w", err)
|
||||
}
|
||||
@@ -103,7 +137,8 @@ func (s *Store) AbandonRunningTasks(ctx context.Context) (int64, error) {
|
||||
func (s *Store) LatestTaskRuns(ctx context.Context) (map[string]TaskRun, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (task_id)
|
||||
id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
|
||||
id, task_id, integration_id, trigger, status, started_at, finished_at,
|
||||
duration_ms, detail, error, processed, changed, skipped, failed
|
||||
FROM scheduled_task_runs
|
||||
ORDER BY task_id, started_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
@@ -113,9 +148,10 @@ func (s *Store) LatestTaskRuns(ctx context.Context) (map[string]TaskRun, error)
|
||||
latest := map[string]TaskRun{}
|
||||
for rows.Next() {
|
||||
var run TaskRun
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
|
||||
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error); err != nil {
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.IntegrationID, &run.Trigger,
|
||||
&run.Status, &run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error, &run.Counts.Processed, &run.Counts.Changed,
|
||||
&run.Counts.Skipped, &run.Counts.Failed); err != nil {
|
||||
return nil, fmt.Errorf("store: scan task run: %w", err)
|
||||
}
|
||||
latest[run.TaskID] = run
|
||||
@@ -129,7 +165,8 @@ func (s *Store) TaskRuns(ctx context.Context, taskID string, limit int) ([]TaskR
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, task_id, trigger, status, started_at, finished_at, duration_ms, detail, error
|
||||
SELECT id, task_id, integration_id, trigger, status, started_at, finished_at,
|
||||
duration_ms, detail, error, processed, changed, skipped, failed
|
||||
FROM scheduled_task_runs
|
||||
WHERE ($1 = '' OR task_id = $1)
|
||||
ORDER BY started_at DESC, id DESC
|
||||
@@ -141,9 +178,10 @@ func (s *Store) TaskRuns(ctx context.Context, taskID string, limit int) ([]TaskR
|
||||
runs := []TaskRun{}
|
||||
for rows.Next() {
|
||||
var run TaskRun
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.Trigger, &run.Status,
|
||||
&run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error); err != nil {
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.IntegrationID, &run.Trigger,
|
||||
&run.Status, &run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error, &run.Counts.Processed, &run.Counts.Changed,
|
||||
&run.Counts.Skipped, &run.Counts.Failed); err != nil {
|
||||
return nil, fmt.Errorf("store: scan task run: %w", err)
|
||||
}
|
||||
runs = append(runs, run)
|
||||
@@ -338,3 +376,93 @@ func (s *Store) PruneIntegrationDeliveries(ctx context.Context, keep int) (int64
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// --- integration run history ------------------------------------------------------
|
||||
//
|
||||
// The same rows as above, read with a different question in mind. An integration run *is*
|
||||
// a scheduled task run: giving the integrations area a table of its own would mean two
|
||||
// schedulers, two retention jobs and two places one piece of work could be recorded as
|
||||
// having failed. What differs is only the axis — by service rather than by job.
|
||||
|
||||
// IntegrationRuns is the operational history for one external service, newest first.
|
||||
func (s *Store) IntegrationRuns(
|
||||
ctx context.Context, integrationID string, limit int,
|
||||
) ([]TaskRun, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, task_id, integration_id, trigger, status, started_at, finished_at,
|
||||
duration_ms, detail, error, processed, changed, skipped, failed
|
||||
FROM scheduled_task_runs
|
||||
WHERE integration_id <> '' AND ($1 = '' OR integration_id = $1)
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT $2`, integrationID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list integration runs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
runs := []TaskRun{}
|
||||
for rows.Next() {
|
||||
var run TaskRun
|
||||
if err := rows.Scan(&run.ID, &run.TaskID, &run.IntegrationID, &run.Trigger,
|
||||
&run.Status, &run.StartedAt, &run.FinishedAt, &run.DurationMS, &run.Detail,
|
||||
&run.Error, &run.Counts.Processed, &run.Counts.Changed,
|
||||
&run.Counts.Skipped, &run.Counts.Failed); err != nil {
|
||||
return nil, fmt.Errorf("store: scan integration run: %w", err)
|
||||
}
|
||||
runs = append(runs, run)
|
||||
}
|
||||
return runs, rows.Err()
|
||||
}
|
||||
|
||||
// IntegrationRunSummary answers the overview's whole row for one service.
|
||||
//
|
||||
// Last success and last failure are both carried because they are different questions and
|
||||
// the answer to one is not the absence of the other: a service that failed an hour ago and
|
||||
// has worked since is healthy, and one that succeeded last week and has failed every hour
|
||||
// since is not. Neither is derivable from a single "last run".
|
||||
type IntegrationRunSummary struct {
|
||||
IntegrationID string `json:"integrationId"`
|
||||
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
||||
LastFailureAt *time.Time `json:"lastFailureAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Runs int `json:"runs"`
|
||||
Failures int `json:"failures"`
|
||||
}
|
||||
|
||||
// IntegrationRunSummaries is every service's summary in one query, because the overview
|
||||
// draws one per row and a query per integration would grow with the catalogue.
|
||||
func (s *Store) IntegrationRunSummaries(
|
||||
ctx context.Context,
|
||||
) (map[string]IntegrationRunSummary, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT integration_id,
|
||||
max(started_at) FILTER (WHERE status = $1),
|
||||
max(started_at) FILTER (WHERE status = $2),
|
||||
(array_remove(array_agg(error ORDER BY started_at DESC)
|
||||
FILTER (WHERE status = $2), ''))[1],
|
||||
count(*), count(*) FILTER (WHERE status = $2)
|
||||
FROM scheduled_task_runs
|
||||
WHERE integration_id <> ''
|
||||
GROUP BY integration_id`, TaskSuccess, TaskFailed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: integration run summaries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
summaries := map[string]IntegrationRunSummary{}
|
||||
for rows.Next() {
|
||||
var summary IntegrationRunSummary
|
||||
var lastError *string
|
||||
if err := rows.Scan(&summary.IntegrationID, &summary.LastSuccessAt,
|
||||
&summary.LastFailureAt, &lastError, &summary.Runs,
|
||||
&summary.Failures); err != nil {
|
||||
return nil, fmt.Errorf("store: scan integration run summary: %w", err)
|
||||
}
|
||||
if lastError != nil {
|
||||
summary.LastError = *lastError
|
||||
}
|
||||
summaries[summary.IntegrationID] = summary
|
||||
}
|
||||
return summaries, rows.Err()
|
||||
}
|
||||
|
||||
@@ -577,6 +577,17 @@ CREATE TABLE IF NOT EXISTS media_requests (
|
||||
PRIMARY KEY (emby_user_id, media_type, foreign_id)
|
||||
);
|
||||
|
||||
-- last_status is the one piece of request state that *is* stored, and only because a
|
||||
-- transition cannot be derived from a single read. Everything else on a request card is
|
||||
-- computed per read from the *arrs and the library; "it has just become ready" is not a
|
||||
-- property of the present, it is the difference between two observations, and the viewer
|
||||
-- has to be told about it exactly once.
|
||||
--
|
||||
-- It is seeded when the request is recorded rather than left blank for a sweep to fill in,
|
||||
-- because a film that downloads in the three minutes before the first sweep would otherwise
|
||||
-- have its arrival recorded as its opening state and nobody would ever be told.
|
||||
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS last_status TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
|
||||
ON media_requests (emby_user_id, requested_at DESC);
|
||||
|
||||
@@ -663,6 +674,28 @@ CREATE TABLE IF NOT EXISTS scheduled_task_runs (
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- Which integration a run belongs to, and what it actually did.
|
||||
--
|
||||
-- Deliberately more columns on this table rather than a second one: an integration run IS
|
||||
-- a scheduled task run, read with a different question in mind. Operations history and
|
||||
-- "what does the gateway do in the background" are the same rows; giving integrations
|
||||
-- their own table would mean two schedulers, two retention jobs and two places a run can
|
||||
-- be recorded as having failed.
|
||||
--
|
||||
-- The counters are nullable-by-default zeroes because most tasks count nothing: a
|
||||
-- housekeeping prune has one number and it is already in `detail`. A run that counted
|
||||
-- nothing is drawn without figures rather than as four zeroes, which is why the API sends
|
||||
-- them only when `processed` is non-zero.
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS integration_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS processed INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS changed INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS skipped INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE scheduled_task_runs ADD COLUMN IF NOT EXISTS failed INT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS scheduled_task_runs_integration_idx
|
||||
ON scheduled_task_runs (integration_id, started_at DESC)
|
||||
WHERE integration_id <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS scheduled_task_runs_task_idx
|
||||
ON scheduled_task_runs (task_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS scheduled_task_runs_time_idx
|
||||
|
||||
Reference in New Issue
Block a user