diff --git a/web/index.html b/web/index.html
index dda56e6..3e88a44 100644
--- a/web/index.html
+++ b/web/index.html
@@ -446,6 +446,8 @@
rpc · grab and seed
+
+
diff --git a/web/src/health.ts b/web/src/health.ts
index f864a94..e893afe 100644
--- a/web/src/health.ts
+++ b/web/src/health.ts
@@ -9,12 +9,28 @@ export interface Check {
detail?: string;
}
+/** One enabled subtitle provider (#200). */
+export interface ProviderCheck {
+ id: string;
+ status: CheckStatus;
+ detail?: string;
+}
+
+/** The subtitle lane: providers in use, the selected engine, the binaries. */
+export interface SubtitleHealth {
+ providers: ProviderCheck[];
+ translation?: Check | null;
+ alass: Check;
+ ffmpeg: Check;
+}
+
export interface HealthReport {
status: "ok" | "degraded";
version: string;
prowlarr: Check;
transmission: Check;
tmdb: Check;
+ subtitles: SubtitleHealth;
}
export type Probe =
diff --git a/web/src/main.ts b/web/src/main.ts
index b4d53c1..c009457 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -1,4 +1,4 @@
-import { type CheckStatus, type Probe, probeHealth } from "./health";
+import { type Check, type CheckStatus, type Probe, type SubtitleHealth, probeHealth } from "./health";
import {
fetchLibrary,
type LibrarySeries,
@@ -171,6 +171,52 @@ function setModule(refs: ModuleRefs, state: string, tone: Tone, word: string, de
refs.detail.textContent = detail ?? refs.defaultDetail;
}
+/** One lamp of the subtitle lane (#200): same module shape as the chain,
+ * rendered per poll — which providers are in use is a settings row, not
+ * markup. */
+function subtitleModule(name: string, role: string, check: Check): HTMLElement {
+ const article = document.createElement("article");
+ article.className = "module";
+
+ const head = document.createElement("header");
+ head.className = "module-head";
+ const lamp = document.createElement("span");
+ lamp.className = "lamp";
+ lamp.dataset.state = check.status;
+ const title = document.createElement("h2");
+ title.className = "module-name";
+ title.textContent = name;
+ const status = document.createElement("span");
+ status.className = "module-status readout";
+ status.dataset.tone = TONE_BY_STATUS[check.status];
+ status.textContent = check.status;
+ head.append(lamp, title, status);
+
+ const roleLine = document.createElement("p");
+ roleLine.className = "module-role";
+ roleLine.textContent = role;
+ const detail = document.createElement("p");
+ detail.className = "module-detail readout";
+ detail.textContent = check.detail ?? "";
+
+ article.append(head, roleLine, detail);
+ return article;
+}
+
+function renderSubtitles(subLane: HTMLElement, subs: SubtitleHealth) {
+ const entries: { name: string; role: string; check: Check }[] = subs.providers.map(
+ (provider) => ({ name: provider.id, role: "subtitles in", check: provider }),
+ );
+ if (subs.translation) {
+ entries.push({ name: "translate", role: "engine out", check: subs.translation });
+ }
+ entries.push({ name: "alass", role: "timing sync", check: subs.alass });
+ entries.push({ name: "ffmpeg", role: "track extraction", check: subs.ffmpeg });
+
+ subLane.replaceChildren(...entries.map((entry) => subtitleModule(entry.name, entry.role, entry.check)));
+ subLane.hidden = false;
+}
+
function must(selector: string): T {
const element = document.querySelector(selector);
if (!element) {
@@ -181,6 +227,7 @@ function must(selector: string): T {
function main() {
const chain = must(".chain");
+ const subLane = must("#chain-subs");
const masterLamp = must("#master-lamp");
const version = must("#version");
@@ -235,6 +282,8 @@ function main() {
for (const name of ["tmdb", "prowlarr", "master"] as const) {
setTrace(name, null);
}
+ subLane.hidden = true;
+ subLane.replaceChildren();
return;
}
@@ -259,6 +308,7 @@ function main() {
setTrace(name, TONE_BY_STATUS[check.status]);
}
}
+ renderSubtitles(subLane, report.subtitles);
}
let inFlight = false;
diff --git a/web/src/style.css b/web/src/style.css
index 34517fc..4c220c0 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -315,6 +315,17 @@ body {
/* ---- modules --------------------------------------------------------- */
+/* The subtitle lane (#200): one lamp per upstream actually in use, rendered
+ by the health poll under the main chain. Same module vocabulary, no bus —
+ these feed the reconcile loop, not each other. */
+.chain-lane {
+ margin-top: var(--space-6);
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
+ gap: var(--space-4);
+ align-items: stretch;
+}
+
.module {
background: var(--panel);
border: 1px solid var(--line);