webOS: a TV app with the views of arr-tui

A TypeScript web app for LG webOS TVs (webOS 5 / Chromium 68 and newer),
driven by the remote: Movies and Series search with the Trakt lists,
Library with status, title and file-property filters and download marks,
seasons and episodes, release search and grab, Downloads, Calendar,
Settings and Log. It talks to Radarr, Sonarr, SABnzbd and Trakt directly
(they answer cross-origin requests); the arr-tui config is baked in at
build time and can be changed on the TV.

dev/screenshots/server.py answers cross-origin requests and accepts writes
for it; webos/dev/drive.mjs drives the app in Chromium for screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 16:51:48 +01:00
co-authored by Claude Opus 5.5
parent 1015135a4b
commit 7f6d4d07b9
47 changed files with 12294 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
out/
+90
View File
@@ -0,0 +1,90 @@
# arr-tui for LG webOS TVs
The same views as the terminal app, as a TV app driven by the remote:
- **Movies** and **Series**: search Radarr / Sonarr. With an empty search box you get the Trakt lists
(trending, most watched this week, anticipated, popular, US box office). Add a title, or open
releases and other actions for library titles.
- **Library**: movies and series, each with its own status, title and file-property filters
(quality, language, codec, HDR, audio). A blue dot marks titles that are downloading. A series
opens its seasons and episodes.
- **Downloads**: the SABnzbd queue and history and the Radarr / Sonarr queues. Pause / resume,
delete, retry, remove and blocklist.
- **Calendar**: two weeks of movie releases and airing episodes.
- **Settings** and **Log**.
- **Release search** with approval and rejection reasons, sort, filters and grab.
- **Details pane** with poster, plot and cast from Trakt.
It is a plain web app (TypeScript, no framework) that talks to Radarr, Sonarr, SABnzbd and Trakt
directly: all four answer cross-origin requests, so the TV needs nothing else running.
## The remote
| Button | Does |
|---|---|
| ◀ ▶ ▲ ▼ | move; in a list ▲ ▼ move the selection, at its ends they leave it |
| OK | press; on a title, the menu of what can be done with it |
| Back | close a dialog or page; in a view, back to its tab; on the tabs, leave the app |
| 1–7 | open a view |
| CH ▲ / ▼ | page through a list |
| Red / Green | previous / next Trakt list or calendar week; Library: movies ⇄ series |
| Yellow | filter by file properties (Library); pause / resume SABnzbd (Downloads) |
| Blue | reload |
The footer shows what OK and the colour buttons do in the current view. With the Magic Remote's
pointer, hovering focuses and clicking presses. In a desktop browser, F1–F4 stand in for the
colour buttons and Escape for Back.
## Configuration
The build bakes your arr-tui config into the app: `$ARR_TUI_CONFIG`, else
`~/.config/arr-tui/config.json` (or `node build.mjs --config FILE`). On the TV, **Settings**
changes the addresses, keys, defaults, Trakt client ID and theme. Those changes are kept on the TV
until "Reset to built-in".
The package then holds your API keys. Keep it private and never publish it.
Addresses must not redirect. The terminal app follows a redirect from `http://` to `https://`; a
browser does not for cross-origin requests, so enter the final (usually `https://`) address.
## Install on the TV (Developer Mode)
1. On the TV, install **Developer Mode** from the LG Content Store and sign in with an LG
developer account (free, [webostv.developer.lge.com](https://webostv.developer.lge.com)). Turn
on **Dev Mode Status** and **Key Server**, and note the IP address and the passphrase.
2. Once, on the computer (Node.js 22 or newer):
```sh
cd webos
npm install
npx ares-setup-device -a tv -i "host=TV_IP" -i "port=9922" -i "username=prisoner"
npx ares-novacom --device tv --getkey # asks for the passphrase
```
3. Build, package, install and start:
```sh
npm run package # dist/ and out/com.stinnesbeck.arrtui_<version>_all.ipk
npm run install-tv
npm run launch
```
A Developer Mode session lasts 50 hours; the Developer Mode app extends it, otherwise the TV removes
developer apps. On a rooted TV with the Homebrew Channel the `.ipk` can be installed without that
limit.
The app targets webOS 5 (2020 TVs, Chromium 68) and newer.
## Development
```sh
npm run typecheck
npm test # unit tests (Node's test runner)
npm run demo # dist/ with the fake services of dev/screenshots
python3 ../dev/screenshots/server.py & # the fake Radarr, Sonarr, SABnzbd and Trakt
CHROMIUM=$(command -v chromium) node dev/drive.mjs SCENE OUTDIR
```
`dev/drive.mjs` opens `dist/index.html` in Chromium at 1920×1080, presses remote buttons from a
scene file (`key Down Enter Red`, `type text`, `sleep 1`, `shot name`, `log`) and takes screenshots.
The page can also be opened in any desktop browser (`dist/index.html`).
+150
View File
@@ -0,0 +1,150 @@
// Builds the webOS app into dist/: the bundled script, the page, the icons
// and config.js with the arr-tui config baked in.
//
// node build.mjs config from $ARR_TUI_CONFIG or ~/.config/arr-tui/config.json
// node build.mjs --config FILE another config file
// node build.mjs --demo the fake services of dev/screenshots (server.py)
// node build.mjs --dev not minified
//
// The baked-in config holds API keys: never publish a package built with it.
import * as esbuild from "esbuild";
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
const here = dirname(fileURLToPath(import.meta.url));
const dist = join(here, "dist");
const args = process.argv.slice(2);
const flag = (name) => args.includes(name);
const option = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
// The oldest browser engine targeted: webOS 5 (2020 TVs) has Chromium 68.
const target = "chrome68";
function configPath() {
if (option("--config")) return option("--config");
if (process.env.ARR_TUI_CONFIG) return process.env.ARR_TUI_CONFIG;
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
return join(base, "arr-tui", "config.json");
}
function demoConfig() {
const key = "0123456789abcdef0123456789abcdef";
return {
radarr: { url: "http://127.0.0.1:19891", apikey: key, default_quality_profile: "Ultra-HD", default_root_folder: "/movies" },
sonarr: { url: "http://127.0.0.1:19892", apikey: key, default_quality_profile: "HD-1080p", default_root_folder: "/tv" },
sabnzbd: { url: "http://127.0.0.1:19893", apikey: key },
trakt: { client_id: key },
ui: { theme: "dark" },
};
}
// pick keeps the settings the TV app uses, nothing else from the file.
function pick(c) {
const svc = (s = {}) => ({ url: s.url ?? "", apikey: s.apikey ?? "" });
const arr = (s = {}) => ({ ...svc(s), default_quality_profile: s.default_quality_profile, default_root_folder: s.default_root_folder });
const ui = c.ui ?? {};
return {
radarr: arr(c.radarr),
sonarr: arr(c.sonarr),
sabnzbd: svc(c.sabnzbd),
trakt: { client_id: c.trakt?.client_id },
ui: { theme: ui.theme, refresh_seconds: ui.refresh_seconds, covers: ui.covers, notify_downloads: ui.notify_downloads },
};
}
function loadConfig() {
if (flag("--demo")) return { config: demoConfig(), from: "the demo services", extra: 'window.ARR_TUI_TRAKT_URL = "http://127.0.0.1:19894";\n' };
const path = configPath();
if (!existsSync(path)) {
console.warn(`no config at ${path}: the app starts with Settings`);
return { config: pick({}), from: "nothing", extra: "" };
}
return { config: pick(JSON.parse(readFileSync(path, "utf8"))), from: path, extra: "" };
}
// --- Icons: drawn here, so no image tools are needed ---
function png(size, draw) {
const px = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) px.set(draw(x + 0.5, y + 0.5, size), (y * size + x) * 4);
const raw = Buffer.alloc((size * 4 + 1) * size);
for (let y = 0; y < size; y++) px.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
const chunk = (type, data) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(td) >>> 0);
return Buffer.concat([len, td, crc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // RGBA
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
}
function crc32(buf) {
let c = ~0;
for (const b of buf) {
c ^= b;
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
}
return ~c;
}
// The icon: a dark rounded square with three yellow list bars of different
// lengths and a blue dot (a title downloading), as in the Library.
function icon(x, y, s) {
const u = (v) => v * s;
const r = u(0.18);
const inside = (() => {
const cx = Math.min(Math.max(x, r), s - r);
const cy = Math.min(Math.max(y, r), s - r);
return (x - cx) ** 2 + (y - cy) ** 2 <= r * r;
})();
if (!inside) return [0, 0, 0, 0];
const bars = [[0.26, 0.42, 0.74], [0.45, 0.42, 0.62], [0.64, 0.42, 0.70]];
for (const [top, left, right] of bars) {
if (y >= u(top) && y <= u(top + 0.1) && x >= u(left) && x <= u(right)) return [0xf2, 0xb6, 0x32, 255];
}
const dots = [[0.31, [0x7f, 0xcc, 0x6e]], [0.50, [0x5f, 0xad, 0xf2]], [0.69, [0x7f, 0xcc, 0x6e]]];
for (const [cy, rgb] of dots) {
if ((x - u(0.3)) ** 2 + (y - u(cy)) ** 2 <= u(0.055) ** 2) return [...rgb, 255];
}
return [0x1d, 0x20, 0x27, 255];
}
// --- Build ---
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
for (const f of readdirSync(join(here, "static"))) copyFileSync(join(here, "static", f), join(dist, f));
writeFileSync(join(dist, "icon.png"), png(80, icon));
writeFileSync(join(dist, "largeIcon.png"), png(130, icon));
const { config, from, extra } = loadConfig();
writeFileSync(join(dist, "config.js"), `// Built from ${from === "nothing" || from === "the demo services" ? from : "the arr-tui config"}. Holds API keys: keep this package private.\nwindow.ARR_TUI_CONFIG = ${JSON.stringify(config, null, 2)};\n${extra}`, { mode: 0o600 });
await esbuild.build({
entryPoints: [join(here, "src", "main.ts")],
bundle: true,
outfile: join(dist, "app.js"),
format: "iife",
target,
minify: !flag("--dev"),
sourcemap: flag("--dev") ? "inline" : false,
legalComments: "none",
logLevel: "warning",
});
const services = ["radarr", "sonarr", "sabnzbd"].filter((s) => config[s].url).join(", ") || "none";
console.log(`dist/ built (config from ${from}; services: ${services}${config.trakt.client_id ? ", Trakt" : ""})`);
+94
View File
@@ -0,0 +1,94 @@
// Runs the built app (dist/) in Chromium at 1920×1080 from file://, as the
// TV loads it, presses remote buttons from a scene file and takes
// screenshots. For development and the README pictures.
//
// node dev/drive.mjs SCENE OUTDIR
//
// CHROMIUM (default: chromium on PATH). Scene lines:
// key Down [Down ...] remote buttons: Left Right Up Down Enter Back Red
// Green Yellow Blue PageUp PageDown 1-7
// type TEXT type into the focused field
// sleep SECONDS
// shot NAME OUTDIR/NAME.png
// log print the app's console and the focused element
import puppeteer from "puppeteer-core";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const [scenePath, outDir] = process.argv.slice(2);
if (!scenePath || !outDir) {
console.error("usage: node dev/drive.mjs SCENE OUTDIR");
process.exit(2);
}
mkdirSync(outDir, { recursive: true });
const codes = {
Left: 37, Up: 38, Right: 39, Down: 40, Enter: 13, Back: 461,
Red: 403, Green: 404, Yellow: 405, Blue: 406, PageUp: 33, PageDown: 34,
};
for (let i = 1; i <= 9; i++) codes[String(i)] = 48 + i;
const browser = await puppeteer.launch({
executablePath: process.env.CHROMIUM || "chromium",
headless: true,
// file:// pages may call the local fake services.
args: ["--window-size=1920,1080", "--allow-file-access-from-files", "--no-sandbox"],
defaultViewport: { width: 1920, height: 1080 },
});
const page = await browser.newPage();
const logs = [];
// Request URLs in browser messages can carry an API key (SABnzbd, posters).
const redact = (t) => t.replace(/(apikey=)[^&\s'"]+/gi, "$1…");
page.on("console", (m) => logs.push(redact(`${m.type()}: ${m.text()}`)));
page.on("pageerror", (e) => logs.push(redact(`pageerror: ${e.message}`)));
await page.goto("file://" + resolve(here, "..", "dist", "index.html"));
// press sends a remote button as the TV does: a keydown with its keyCode.
async function press(name) {
const code = codes[name];
if (!code) throw new Error("unknown key " + name);
await page.evaluate((code) => {
const target = document.activeElement || document.body;
const ev = new KeyboardEvent("keydown", { bubbles: true, cancelable: true });
Object.defineProperty(ev, "keyCode", { get: () => code });
Object.defineProperty(ev, "which", { get: () => code });
target.dispatchEvent(ev);
}, code);
await new Promise((r) => setTimeout(r, 120));
}
for (const raw of readFileSync(scenePath, "utf8").split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const [cmd, ...rest] = line.split(/\s+/);
switch (cmd) {
case "key":
for (const k of rest) await press(k);
break;
case "type":
await page.keyboard.type(line.slice(5));
break;
case "sleep":
await new Promise((r) => setTimeout(r, parseFloat(rest[0]) * 1000));
break;
case "shot":
await page.screenshot({ path: join(outDir, rest[0] + ".png") });
break;
case "log": {
const focused = await page.evaluate(() => {
const a = document.activeElement;
return a ? `${a.tagName}.${a.className} "${(a.textContent || "").slice(0, 40)}"` : "none";
});
console.log(logs.splice(0).join("\n"));
console.log("focused:", focused);
break;
}
default:
throw new Error("unknown scene command: " + line);
}
}
if (logs.length) console.log(logs.join("\n"));
await browser.close();
+5
View File
@@ -0,0 +1,5 @@
// Not Go: this file keeps webos/ (and the Go files some npm packages ship
// in node_modules) out of the arr-tui module, so `go test ./...` skips it.
module git.stinnesbeck.com/nils/arr-tui/webos
go 1.26
+7099
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "arr-tui-webos",
"private": true,
"description": "arr-tui for LG webOS TVs: Radarr, Sonarr and SABnzbd on the big screen",
"type": "module",
"scripts": {
"build": "node build.mjs",
"demo": "node build.mjs --demo",
"typecheck": "tsc --noEmit",
"test": "node --test test/*.test.ts",
"package": "node build.mjs && ares-package dist -o out",
"install-tv": "ares-install -d tv out/com.stinnesbeck.arrtui_*.ipk",
"launch": "ares-launch -d tv com.stinnesbeck.arrtui"
},
"devDependencies": {
"@types/node": "24.19.2",
"@webos-tools/cli": "3.2.6",
"esbuild": "0.28.2",
"puppeteer-core": "25.13.0",
"typescript": "7.0.2"
}
}
+260
View File
@@ -0,0 +1,260 @@
// Client and types shared by Radarr and Sonarr (their v3 API), ported from
// internal/arr.
import { HTTPError, NotConfiguredError, query, request, trimSlash } from "./http.ts";
export interface Image {
coverType: string;
url?: string;
remoteUrl?: string;
}
export interface QualityProfile {
id: number;
name: string;
}
export interface RootFolder {
id: number;
path: string;
freeSpace: number;
accessible: boolean;
}
export interface SystemStatus {
appName: string;
version: string;
}
export interface MediaFile {
size: number;
sceneName?: string;
relativePath?: string;
quality?: { quality?: { name?: string; resolution?: number } };
mediaInfo?: {
videoCodec?: string;
videoDynamicRange?: string;
videoDynamicRangeType?: string;
audioCodec?: string;
audioAdditionalFeatures?: string;
};
languages?: { name: string }[];
}
export interface QueueItem {
id: number;
title: string;
status: string;
trackedDownloadStatus?: string;
trackedDownloadState?: string;
statusMessages?: { title: string; messages: string[] }[];
errorMessage?: string;
size: number;
sizeleft: number;
timeleft?: string;
downloadClient?: string;
downloadId?: string;
indexer?: string;
protocol?: string;
movieId?: number;
seriesId?: number;
episodeId?: number;
movie?: { title: string; year: number };
series?: { title: string };
episode?: { seasonNumber: number; episodeNumber: number; title: string };
}
export interface Release {
guid: string;
title: string;
indexer: string;
indexerId: number;
protocol: string;
size: number;
ageMinutes: number;
qualityWeight: number;
customFormatScore: number;
releaseGroup?: string;
approved: boolean;
rejected: boolean;
temporarilyRejected: boolean;
rejections?: string[];
fullSeason?: boolean;
quality?: { quality?: { name?: string; resolution?: number } };
languages?: { name: string }[];
customFormats?: { name: string }[];
}
export function queueName(q: QueueItem): string {
if (q.movie?.title) return q.movie.year > 0 ? `${q.movie.title} (${q.movie.year})` : q.movie.title;
if (q.series?.title) {
if (q.episode) return `${q.series.title} S${pad2(q.episode.seasonNumber)}E${pad2(q.episode.episodeNumber)}`;
return q.series.title;
}
return q.title;
}
export function queueProgress(q: QueueItem): number {
if (q.size <= 0) return 0;
return Math.min(Math.max(((q.size - q.sizeleft) / q.size) * 100, 0), 100);
}
export function queueProblem(q: QueueItem): string {
const parts: string[] = [];
if (q.errorMessage) parts.push(q.errorMessage);
for (const m of q.statusMessages ?? []) {
parts.push(...m.messages);
if (m.messages.length === 0 && m.title) parts.push(m.title);
}
return parts.join("; ");
}
export function pad2(n: number): string {
return n < 10 ? "0" + n : String(n);
}
type Body = Record<string, unknown> | unknown[];
export class ArrClient {
name: string; // "Radarr" or "Sonarr", for messages
baseURL = "";
apiKey = "";
constructor(name: string, baseURL: string, apiKey: string) {
this.name = name;
this.configure(baseURL, apiKey);
}
configure(baseURL: string, apiKey: string): void {
this.baseURL = trimSlash(baseURL);
this.apiKey = apiKey.trim();
}
configured(): boolean {
return this.baseURL !== "";
}
// do sends a request to /api/v3/<path> and decodes the JSON answer.
async do<T>(method: string, path: string, params: Record<string, string | number | boolean | undefined> = {}, body?: Body, opts: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<T> {
if (!this.configured()) throw new NotConfiguredError(this.name);
const headers: Record<string, string> = { "X-Api-Key": this.apiKey, Accept: "application/json" };
if (body !== undefined) headers["Content-Type"] = "application/json";
const resp = await request(
this.baseURL + "/api/v3/" + path.replace(/^\/+/, "") + query(params),
{ method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: opts.signal },
opts.timeoutMs ?? 30_000,
);
const text = await resp.text();
if (!resp.ok) throw new HTTPError(resp.status, errorMessage(text));
if (!text) return undefined as T;
try {
return JSON.parse(text) as T;
} catch {
throw new Error(`${this.name}: the answer is not JSON (${text.slice(0, 80)})`);
}
}
// update reads <path>/<id>, lets mutate change the object and PUTs it back
// whole (Radarr and Sonarr replace the resource on PUT).
async update(path: string, id: number, mutate: (obj: Record<string, unknown>) => void): Promise<void> {
const obj = await this.do<Record<string, unknown>>("GET", `${path}/${id}`);
mutate(obj);
await this.do("PUT", `${path}/${id}`, {}, obj);
}
command(body: Record<string, unknown>): Promise<void> {
return this.do("POST", "command", {}, body);
}
systemStatus(): Promise<SystemStatus> {
return this.do("GET", "system/status", {}, undefined, { timeoutMs: 10_000 });
}
qualityProfiles(): Promise<QualityProfile[]> {
return this.do("GET", "qualityprofile");
}
rootFolders(): Promise<RootFolder[]> {
return this.do("GET", "rootfolder");
}
async queue(include: Record<string, string>): Promise<QueueItem[]> {
const page = await this.do<{ records: QueueItem[] }>("GET", "queue", { page: 1, pageSize: 200, ...include });
return page.records ?? [];
}
removeQueueItem(id: number, blocklist: boolean): Promise<void> {
return this.do("DELETE", `queue/${id}`, { removeFromClient: true, blocklist });
}
// releases runs an interactive search; it queries every indexer and can
// take minutes.
releases(params: Record<string, number>, signal?: AbortSignal): Promise<Release[]> {
return this.do("GET", "release", params, undefined, { signal, timeoutMs: 180_000 });
}
grab(r: Release): Promise<void> {
return this.do("POST", "release", {}, { guid: r.guid, indexerId: r.indexerId });
}
// posterURL returns an image URL for an <img>: the instance's own copy for
// library titles (with the API key as a query parameter, as an <img> cannot
// send headers), else the metadata provider's, at a size fitting the use.
posterURL(images: Image[] | undefined, inLibrary: boolean, large: boolean): string {
const img = images?.find((i) => i.coverType === "poster");
if (!img) return "";
if (inLibrary && img.url) {
let path = img.url;
if (!large) path = scaledVariant(path, 500);
return this.resolve(path) + (path.includes("?") ? "&" : "?") + "apikey=" + encodeURIComponent(this.apiKey);
}
return img.remoteUrl ? tmdbSized(img.remoteUrl, large) : "";
}
// resolve joins a server-relative path to the base URL. Radarr/Sonarr put
// their URL base into image paths, so it must not be added twice.
resolve(ref: string): string {
try {
const base = new URL(this.baseURL);
const prefix = base.pathname.replace(/\/+$/, "");
if (prefix && ref.startsWith(prefix + "/")) return base.origin + ref;
} catch {
/* fall through */
}
return this.baseURL + ref;
}
}
// errorMessage reads Radarr/Sonarr error bodies: {"message": "..."} or a list
// of validation failures.
export function errorMessage(text: string): string {
try {
const v = JSON.parse(text);
if (v && typeof v.message === "string" && v.message) return v.message;
if (Array.isArray(v) && v.length) return v.map((e: { errorMessage?: string }) => e.errorMessage ?? "").join("; ");
} catch {
/* not JSON */
}
return text.trim().slice(0, 200);
}
// scaledVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-500.jpg?x.
export function scaledVariant(p: string, height: number): string {
const [path, q] = p.split("?", 2);
if (!path.endsWith("/poster.jpg")) return p;
const scaled = path.slice(0, -4) + "-" + height + ".jpg";
return q === undefined ? scaled : scaled + "?" + q;
}
// tmdbSized asks TheMovieDB for the size shown instead of the original
// (often 2000×3000 and over 2 MB).
export function tmdbSized(raw: string, large: boolean): string {
try {
const u = new URL(raw);
if (u.host !== "image.tmdb.org" || !u.pathname.startsWith("/t/p/original/")) return raw;
u.pathname = "/t/p/" + (large ? "w780" : "w342") + u.pathname.slice("/t/p/original".length);
return u.toString();
} catch {
return raw;
}
}
+64
View File
@@ -0,0 +1,64 @@
// Shared HTTP helpers. The TV app talks to Radarr, Sonarr, SABnzbd and Trakt
// directly from the browser: all four answer cross-origin requests (CORS).
export class HTTPError extends Error {
status: number;
constructor(status: number, message: string) {
super(message ? `HTTP ${status}: ${message}` : `unexpected response: HTTP ${status}`);
this.status = status;
}
}
export class NotConfiguredError extends Error {
constructor(what: string) {
super(`${what} is not configured: set its address and API key in Settings`);
}
}
// request sends a request that gives up after timeoutMs. Network failures
// become an Error naming the host only (the URL may carry an API key).
export async function request(url: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<Response> {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
const outer = init.signal;
if (outer) {
if (outer.aborted) ctl.abort();
else outer.addEventListener("abort", () => ctl.abort());
}
try {
return await fetch(url, { ...init, signal: ctl.signal });
} catch (e) {
if (outer?.aborted) throw new DOMException("cancelled", "AbortError");
if (ctl.signal.aborted) throw new Error(`${hostOf(url)}: no answer within ${Math.round(timeoutMs / 1000)} s`);
// A browser cannot follow a redirect (e.g. http to https) of a cross-origin
// request unless the redirect itself allows it; the TUI never notices.
const hint = url.startsWith("http://") ? "; if the address redirects to https, enter the https address" : "";
throw new Error(`${hostOf(url)}: cannot connect (${(e as Error).message})${hint}`);
} finally {
clearTimeout(timer);
}
}
export function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return "server";
}
}
export function isAbort(e: unknown): boolean {
return e instanceof DOMException && e.name === "AbortError";
}
export function query(params: Record<string, string | number | boolean | undefined>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(v)));
}
return parts.length ? "?" + parts.join("&") : "";
}
export function trimSlash(u: string): string {
return u.trim().replace(/\/+$/, "");
}
+113
View File
@@ -0,0 +1,113 @@
// Radarr v3 client, ported from internal/radarr.
import { ArrClient, type Image, type MediaFile, type QueueItem, type Release } from "./arr.ts";
export interface Rating {
votes: number;
value: number;
}
export interface Movie {
id: number; // 0 when not in the library
title: string;
originalTitle?: string;
year: number;
overview?: string;
runtime: number;
genres?: string[];
studio?: string;
status?: string;
tmdbId: number;
imdbId?: string;
titleSlug?: string;
images?: Image[];
ratings?: { imdb?: Rating; tmdb?: Rating };
monitored: boolean;
hasFile: boolean;
qualityProfileId?: number;
inCinemas?: string;
physicalRelease?: string;
digitalRelease?: string;
sizeOnDisk: number;
isAvailable: boolean;
path?: string;
movieFile?: MediaFile;
}
export function movieRating(m: Movie): number {
if (m.ratings?.imdb && m.ratings.imdb.value > 0) return m.ratings.imdb.value;
return m.ratings?.tmdb?.value ?? 0;
}
export function inLibrary(m: { id: number }): boolean {
return !!m.id;
}
export interface AddMovieOptions {
qualityProfileId: number;
rootFolderPath: string;
monitored: boolean;
searchNow: boolean;
}
export class RadarrClient extends ArrClient {
constructor(baseURL: string, apiKey: string) {
super("Radarr", baseURL, apiKey);
}
lookup(term: string, signal?: AbortSignal): Promise<Movie[]> {
return this.do("GET", "movie/lookup", { term }, undefined, { signal });
}
movies(): Promise<Movie[]> {
return this.do("GET", "movie", {}, undefined, { timeoutMs: 60_000 });
}
// movieByTMDB returns the library movie with the TMDb id, or null. The
// answer is checked: a version that ignores the filter sends the library.
async movieByTMDB(tmdbId: number): Promise<Movie | null> {
const movies = await this.do<Movie[]>("GET", "movie", { tmdbId });
return movies.find((m) => m.tmdbId === tmdbId && m.id) ?? null;
}
addMovie(m: Movie, o: AddMovieOptions): Promise<Movie> {
return this.do("POST", "movie", {}, {
title: m.title,
year: m.year,
tmdbId: m.tmdbId,
titleSlug: m.titleSlug,
images: m.images,
qualityProfileId: o.qualityProfileId,
rootFolderPath: o.rootFolderPath,
monitored: o.monitored,
minimumAvailability: "released",
addOptions: { searchForMovie: o.searchNow },
});
}
searchMovie(id: number): Promise<void> {
return this.command({ name: "MoviesSearch", movieIds: [id] });
}
movieQueue(): Promise<QueueItem[]> {
return this.queue({ includeMovie: "true" });
}
movieReleases(id: number, signal?: AbortSignal): Promise<Release[]> {
return this.releases({ movieId: id }, signal);
}
setMonitored(id: number, monitored: boolean): Promise<void> {
return this.update("movie", id, (o) => {
o.monitored = monitored;
});
}
deleteMovie(id: number, deleteFiles: boolean, addExclusion: boolean): Promise<void> {
return this.do("DELETE", `movie/${id}`, { deleteFiles, addImportExclusion: addExclusion });
}
calendar(start: string, end: string, unmonitored: boolean): Promise<Movie[]> {
return this.do("GET", "calendar", { start, end, unmonitored });
}
}
+124
View File
@@ -0,0 +1,124 @@
// SABnzbd client, ported from internal/sabnzbd.
import { NotConfiguredError, query, request, trimSlash } from "./http.ts";
export interface Slot {
nzo_id: string;
filename: string;
cat: string;
status: string; // Downloading, Queued, Paused, Fetching, ...
priority: string;
size: string;
sizeleft: string;
mb: number;
mbleft: number;
percentage: number;
timeleft: string;
}
export interface Queue {
status: string;
paused: boolean;
kbpersec: number;
speedlimit_abs: number;
size: string;
sizeleft: string;
timeleft: string;
noofslots_total: number;
slots: Slot[];
}
export interface HistorySlot {
nzo_id: string;
name: string;
category: string;
status: string; // Completed, Failed, Extracting, ...
size: string;
fail_message: string;
completed: number; // unix time
}
// num reads SABnzbd's numbers, which come as numbers or numeric strings.
export function num(v: unknown): number {
if (typeof v === "number") return v;
const f = parseFloat(String(v ?? ""));
return Number.isFinite(f) ? f : 0;
}
export class SABnzbdClient {
baseURL = "";
apiKey = "";
constructor(baseURL: string, apiKey: string) {
this.configure(baseURL, apiKey);
}
configure(baseURL: string, apiKey: string): void {
this.baseURL = trimSlash(baseURL);
this.apiKey = apiKey.trim();
}
configured(): boolean {
return this.baseURL !== "";
}
// call performs an API request. SABnzbd reports failures with HTTP 200 and
// {"status": false, "error": "..."}.
async call<T>(params: Record<string, string | number>, timeoutMs = 30_000): Promise<T> {
if (!this.configured()) throw new NotConfiguredError("SABnzbd");
const resp = await request(this.baseURL + "/api" + query({ ...params, output: "json", apikey: this.apiKey }), {}, timeoutMs);
const text = await resp.text();
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.trim().slice(0, 200)}`);
let v: { status?: unknown; error?: string };
try {
v = JSON.parse(text);
} catch {
throw new Error(`unexpected response: ${text.trim().slice(0, 200)}`); // e.g. "Access denied"
}
if (v.error) throw new Error(v.error);
if (v.status === false) throw new Error("request failed");
return v as T;
}
async version(): Promise<string> {
return (await this.call<{ version: string }>({ mode: "version" }, 10_000)).version;
}
async queue(): Promise<Queue> {
const q = (await this.call<{ queue: Queue }>({ mode: "queue" })).queue;
q.kbpersec = num(q.kbpersec);
q.speedlimit_abs = num(q.speedlimit_abs);
for (const s of q.slots ?? []) {
s.mb = num(s.mb);
s.mbleft = num(s.mbleft);
s.percentage = num(s.percentage);
}
q.slots ??= [];
return q;
}
async history(limit: number): Promise<HistorySlot[]> {
const h = await this.call<{ history: { slots: HistorySlot[] } }>({ mode: "history", limit });
return h.history?.slots ?? [];
}
async pause(): Promise<void> {
await this.call({ mode: "pause" });
}
async resume(): Promise<void> {
await this.call({ mode: "resume" });
}
async deleteJob(id: string, deleteFiles: boolean): Promise<void> {
await this.call({ mode: "queue", name: "delete", value: id, del_files: deleteFiles ? 1 : 0 });
}
async deleteHistory(id: string, deleteFiles: boolean): Promise<void> {
await this.call({ mode: "history", name: "delete", value: id, del_files: deleteFiles ? 1 : 0 });
}
async retry(id: string): Promise<void> {
await this.call({ mode: "retry", value: id });
}
}
+174
View File
@@ -0,0 +1,174 @@
// Sonarr v3/v4 client, ported from internal/sonarr.
import { ArrClient, type Image, type MediaFile, type QueueItem, type Release } from "./arr.ts";
export interface Statistics {
seasonCount?: number;
episodeFileCount: number;
episodeCount: number;
totalEpisodeCount?: number;
sizeOnDisk: number;
}
export interface Season {
seasonNumber: number;
monitored: boolean;
statistics?: Statistics;
images?: Image[]; // only from getSeries
}
export interface Series {
id: number; // 0 when not in the library
title: string;
year: number;
overview?: string;
network?: string;
status?: string;
runtime: number;
genres?: string[];
tvdbId: number;
tmdbId?: number;
imdbId?: string;
titleSlug?: string;
seriesType?: string;
images?: Image[];
ratings?: { votes: number; value: number };
seasons?: Season[];
monitored: boolean;
statistics?: Statistics;
}
export interface Episode {
id: number;
seriesId: number;
seasonNumber: number;
episodeNumber: number;
title: string;
airDate?: string;
airDateUtc?: string;
overview?: string;
hasFile: boolean;
monitored: boolean;
series?: Series;
}
export function seasonCount(s: Series): number {
return (s.seasons ?? []).filter((x) => x.seasonNumber > 0).length;
}
export const monitorModes: [label: string, value: string][] = [
["All episodes", "all"],
["Future episodes", "future"],
["Missing episodes", "missing"],
["First season", "firstSeason"],
["Latest season", "latestSeason"],
["None", "none"],
];
export interface AddSeriesOptions {
qualityProfileId: number;
rootFolderPath: string;
monitored: boolean;
monitor: string;
searchNow: boolean;
}
export class SonarrClient extends ArrClient {
constructor(baseURL: string, apiKey: string) {
super("Sonarr", baseURL, apiKey);
}
lookup(term: string, signal?: AbortSignal): Promise<Series[]> {
return this.do("GET", "series/lookup", { term }, undefined, { signal });
}
allSeries(): Promise<Series[]> {
return this.do("GET", "series", {}, undefined, { timeoutMs: 60_000 });
}
async seriesByTVDB(tvdbId: number): Promise<Series | null> {
const series = await this.do<Series[]>("GET", "series", { tvdbId });
return series.find((s) => s.tvdbId === tvdbId && s.id) ?? null;
}
// getSeries fetches a library series with its season posters.
getSeries(id: number): Promise<Series> {
return this.do("GET", `series/${id}`, { includeSeasonImages: true });
}
addSeries(s: Series, o: AddSeriesOptions): Promise<Series> {
return this.do("POST", "series", {}, {
title: s.title,
year: s.year,
tvdbId: s.tvdbId,
titleSlug: s.titleSlug,
seriesType: s.seriesType,
images: s.images,
seasons: s.seasons,
qualityProfileId: o.qualityProfileId,
rootFolderPath: o.rootFolderPath,
monitored: o.monitored,
seasonFolder: true,
addOptions: { monitor: o.monitor || "all", searchForMissingEpisodes: o.searchNow },
});
}
searchSeries(id: number): Promise<void> {
return this.command({ name: "SeriesSearch", seriesId: id });
}
searchSeason(id: number, season: number): Promise<void> {
return this.command({ name: "SeasonSearch", seriesId: id, seasonNumber: season });
}
searchEpisode(episodeId: number): Promise<void> {
return this.command({ name: "EpisodeSearch", episodeIds: [episodeId] });
}
setMonitored(id: number, monitored: boolean): Promise<void> {
return this.update("series", id, (o) => {
o.monitored = monitored;
});
}
setSeasonMonitored(id: number, season: number, monitored: boolean): Promise<void> {
return this.update("series", id, (o) => {
const seasons = o.seasons as { seasonNumber: number; monitored: boolean }[] | undefined;
const s = seasons?.find((x) => x.seasonNumber === season);
if (!s) throw new Error(`season ${season} not found`);
s.monitored = monitored;
});
}
setEpisodesMonitored(ids: number[], monitored: boolean): Promise<void> {
return this.do("PUT", "episode/monitor", {}, { episodeIds: ids, monitored });
}
seriesQueue(): Promise<QueueItem[]> {
return this.queue({ includeSeries: "true", includeEpisode: "true" });
}
episodes(seriesId: number): Promise<Episode[]> {
return this.do("GET", "episode", { seriesId });
}
episodeReleases(episodeId: number, signal?: AbortSignal): Promise<Release[]> {
return this.releases({ episodeId }, signal);
}
seasonReleases(seriesId: number, season: number, signal?: AbortSignal): Promise<Release[]> {
return this.releases({ seriesId, seasonNumber: season }, signal);
}
episodeFiles(seriesId: number): Promise<MediaFile[]> {
return this.do("GET", "episodefile", { seriesId });
}
deleteSeries(id: number, deleteFiles: boolean, addExclusion: boolean): Promise<void> {
return this.do("DELETE", `series/${id}`, { deleteFiles, addImportListExclusion: addExclusion });
}
calendar(start: string, end: string, unmonitored: boolean): Promise<Episode[]> {
return this.do("GET", "calendar", { start, end, unmonitored, includeSeries: true });
}
}
+168
View File
@@ -0,0 +1,168 @@
// Trakt's public lists and casts (client ID only), ported from internal/trakt.
import { query, request, trimSlash } from "./http.ts";
export type Kind = "movie" | "show";
export interface List {
name: string;
path: string;
count: string; // field holding the list's number, "" for none
note?: (n: number) => string;
kind: Kind;
}
export interface IDs {
trakt: number;
slug?: string;
imdb?: string;
tmdb?: number;
tvdb?: number;
}
export interface Entry {
title: string;
year: number;
ids: IDs;
count: number; // the list's number for it, 0 if none
}
export interface Person {
name: string;
role: string;
}
export interface People {
cast: Person[];
leaders: Person[]; // directors of a movie, creators of a show
}
export interface Ref {
kind: Kind;
tmdb?: number;
tvdb?: number;
imdb?: string;
}
export class TraktNotFound extends Error {
constructor() {
super("trakt: not found");
}
}
const watching = (n: number) => compact(n) + " watching";
const watchers = (n: number) => compact(n) + " watched it";
const onLists = (n: number) => "on " + compact(n) + " lists";
const revenue = (n: number) => "$" + compact(n) + " last weekend";
export const movieLists: List[] = [
{ name: "Trending now", path: "/movies/trending", count: "watchers", note: watching, kind: "movie" },
{ name: "Most watched this week", path: "/movies/watched/weekly", count: "watcher_count", note: watchers, kind: "movie" },
{ name: "Anticipated", path: "/movies/anticipated", count: "list_count", note: onLists, kind: "movie" },
{ name: "Popular", path: "/movies/popular", count: "", kind: "movie" },
{ name: "Box office (US)", path: "/movies/boxoffice", count: "revenue", note: revenue, kind: "movie" },
];
export const showLists: List[] = [
{ name: "Trending now", path: "/shows/trending", count: "watchers", note: watching, kind: "show" },
{ name: "Most watched this week", path: "/shows/watched/weekly", count: "watcher_count", note: watchers, kind: "show" },
{ name: "Anticipated", path: "/shows/anticipated", count: "list_count", note: onLists, kind: "show" },
{ name: "Popular", path: "/shows/popular", count: "", kind: "show" },
];
export function listNote(l: List, n: number): string {
return l.note && n > 0 ? l.note(n) : "";
}
export function refKey(r: Ref): string {
const k = r.kind + ":";
if (r.kind === "movie" && r.tmdb) return k + "tmdb:" + r.tmdb;
if (r.kind === "show" && r.tvdb) return k + "tvdb:" + r.tvdb;
if (r.imdb) return k + "imdb:" + r.imdb;
return "";
}
export class TraktClient {
baseURL: string;
clientID: string;
constructor(clientID: string, baseURL = "https://api.trakt.tv") {
this.clientID = clientID.trim();
this.baseURL = trimSlash(baseURL);
}
configured(): boolean {
return this.clientID !== "";
}
async get(path: string, params: Record<string, string | number> = {}): Promise<unknown> {
if (!this.configured()) throw new Error("no Trakt client ID: create an API app on trakt.tv and enter its client ID in Settings");
// No User-Agent: browsers do not let pages set it.
const resp = await request(this.baseURL + path + query(params), {
headers: { "Content-Type": "application/json", "trakt-api-key": this.clientID, "trakt-api-version": "2" },
}, 20_000);
if (resp.status === 401 || resp.status === 403) throw new Error(`trakt: the client ID was not accepted (HTTP ${resp.status})`);
if (resp.status === 429) throw new Error("trakt: too many requests, try again in a few minutes");
if (resp.status === 404) throw new TraktNotFound();
if (!resp.ok) throw new Error(`trakt: HTTP ${resp.status}`);
return resp.json();
}
async entries(l: List, limit: number): Promise<Entry[]> {
return decodeList(await this.get(l.path, { page: 1, limit }), l).slice(0, limit);
}
async people(r: Ref): Promise<People> {
const id = await this.traktID(r);
const raw = (await this.get(`/${r.kind}s/${id}/people`)) as {
cast?: { character?: string; characters?: string[]; person: { name: string } }[];
crew?: Record<string, { job?: string; jobs?: string[]; person: { name: string } }[]>;
};
const p: People = { cast: [], leaders: [] };
for (const m of raw.cast ?? []) {
const role = m.characters?.length ? m.characters.join(" / ") : (m.character ?? "");
p.cast.push({ name: m.person.name, role });
}
const [key, job] = r.kind === "show" ? ["created by", ""] : ["directing", "Director"];
for (const m of raw.crew?.[key] ?? []) {
const jobs = [m.job ?? "", ...(m.jobs ?? [])];
if (!job || jobs.includes(job)) p.leaders.push({ name: m.person.name, role: m.job ?? "" });
}
return p;
}
async traktID(r: Ref): Promise<number> {
let type: string, id: string;
if (r.kind === "movie" && r.tmdb) [type, id] = ["tmdb", String(r.tmdb)];
else if (r.kind === "show" && r.tvdb) [type, id] = ["tvdb", String(r.tvdb)];
else if (r.imdb) [type, id] = ["imdb", r.imdb];
else throw new TraktNotFound();
const results = (await this.get(`/search/${type}/${encodeURIComponent(id)}`, { type: r.kind })) as Record<string, { ids?: IDs }>[];
for (const res of results) {
const t = res[r.kind]?.ids?.trakt;
if (t) return t;
}
throw new TraktNotFound();
}
}
// decodeList reads a list answer: bare titles, or items with the title under
// "movie"/"show" next to the list's number.
export function decodeList(data: unknown, l: List): Entry[] {
if (!Array.isArray(data)) throw new Error(`trakt: unexpected answer for ${l.path}`);
return data.map((item: Record<string, unknown>) => {
const t = (item[l.kind] ?? item) as { title: string; year: number; ids: IDs };
const n = l.count ? Number(item[l.count] ?? 0) : 0;
return { title: t.title, year: t.year, ids: t.ids ?? { trakt: 0 }, count: Number.isFinite(n) ? n : 0 };
});
}
// compact writes a number briefly: 950, 1.2k, 34k, 1.5M.
export function compact(n: number): string {
const trim = (s: string) => s.replace(".0", "");
if (n < 1000) return String(Math.trunc(n));
if (n < 10_000) return trim((n / 1000).toFixed(1) + "k");
if (n < 1_000_000) return Math.trunc(n / 1000) + "k";
if (n < 10_000_000) return trim((n / 1_000_000).toFixed(1) + "M");
return Math.trunc(n / 1_000_000) + "M";
}
+191
View File
@@ -0,0 +1,191 @@
// The services, shared caches and the page stack every view uses.
import { RadarrClient } from "./api/radarr.ts";
import { SonarrClient } from "./api/sonarr.ts";
import { SABnzbdClient } from "./api/sabnzbd.ts";
import { TraktClient, refKey, TraktNotFound, type People, type Ref } from "./api/trakt.ts";
import { loadConfig, type Config } from "./config.ts";
import { h } from "./ui/dom.ts";
import { focusFirst, popLayer, pushLayer } from "./ui/focus.ts";
export type ServiceName = "Radarr" | "Sonarr" | "SABnzbd" | "Trakt";
class Services {
cfg: Config;
radarr: RadarrClient;
sonarr: SonarrClient;
sab: SABnzbdClient;
trakt: TraktClient;
// Called a few seconds after a grab (the Library's download marks).
afterGrab: (() => void)[] = [];
// Called when the config changes (Settings saved).
onConfig: (() => void)[] = [];
status: Record<ServiceName, string> = { Radarr: "", Sonarr: "", SABnzbd: "", Trakt: "" }; // "" ok, else the error
onStatus: (() => void)[] = [];
constructor() {
this.cfg = loadConfig();
const c = this.cfg;
this.radarr = new RadarrClient(c.radarr.url, c.radarr.apikey);
this.sonarr = new SonarrClient(c.sonarr.url, c.sonarr.apikey);
this.sab = new SABnzbdClient(c.sabnzbd.url, c.sabnzbd.apikey);
this.trakt = new TraktClient(c.trakt.client_id ?? "", traktURL());
}
apply(c: Config): void {
this.cfg = c;
this.radarr.configure(c.radarr.url, c.radarr.apikey);
this.sonarr.configure(c.sonarr.url, c.sonarr.apikey);
this.sab.configure(c.sabnzbd.url, c.sabnzbd.apikey);
this.trakt = new TraktClient(c.trakt.client_id ?? "", traktURL());
people.failed.clear();
for (const f of this.onConfig) f();
}
setStatus(name: ServiceName, err: unknown): void {
const text = err ? (err instanceof Error ? err.message : String(err)) : "";
if (this.status[name] === text) return;
this.status[name] = text;
for (const f of this.onStatus) f();
}
covers(): boolean {
return this.cfg.ui.covers !== false;
}
}
// traktURL: another Trakt API for development (?trakt=http://127.0.0.1:19894).
function traktURL(): string | undefined {
return new URLSearchParams(location.search).get("trakt") ?? (window as unknown as { ARR_TUI_TRAKT_URL?: string }).ARR_TUI_TRAKT_URL;
}
export const app = new Services();
// --- Pages: full-screen views opened on top (releases, episodes) ---
export interface Page {
el: HTMLElement;
close: () => void;
}
export function openPage(el: HTMLElement, onClose?: () => void): Page {
el.classList.add("page");
document.getElementById("overlays")!.appendChild(el);
let closed = false;
const page: Page = {
el,
close() {
if (closed) return;
closed = true;
popLayer(el);
el.remove();
onClose?.();
},
};
pushLayer(el, page.close);
focusFirst(el);
return page;
}
export function pageHeader(title: string, ...extra: (Node | string)[]): HTMLElement {
return h("div.page-header", h("h1", title), ...extra);
}
// --- Stores kept on the TV (local storage), like arr-tui's disk cache ---
interface Saved<T> {
saved: number; // ms
data: T;
}
class Store<T> {
private key: string;
private ttl: number;
private cap: number;
private entries: Record<string, Saved<T>> | null = null;
private timer = 0;
constructor(key: string, ttlDays: number, cap: number) {
this.key = key;
this.ttl = ttlDays * 86400_000;
this.cap = cap;
}
private load(): Record<string, Saved<T>> {
if (!this.entries) {
try {
this.entries = JSON.parse(localStorage.getItem(this.key) ?? "{}");
} catch {
this.entries = {};
}
}
return this.entries!;
}
get(k: string): T | undefined {
const e = this.load()[k];
return e && Date.now() - e.saved < this.ttl ? e.data : undefined;
}
put(k: string, data: T): void {
const all = this.load();
all[k] = { saved: Date.now(), data };
const keys = Object.keys(all);
if (keys.length > this.cap) {
keys.sort((a, b) => all[a].saved - all[b].saved);
for (const old of keys.slice(0, keys.length - this.cap)) delete all[old];
}
clearTimeout(this.timer);
this.timer = window.setTimeout(() => this.save(), 3000);
}
save(): void {
if (!this.entries) return;
const now = Date.now();
for (const [k, e] of Object.entries(this.entries)) if (now - e.saved >= this.ttl) delete this.entries[k];
try {
localStorage.setItem(this.key, JSON.stringify(this.entries));
} catch {
/* full: kept for this run only */
}
}
}
// Lookups of Trakt list titles that are not in the library (3 days).
export const lookups = new Store<unknown>("arr-tui-lookups", 3, 300);
// Casts from Trakt (7 days).
const peopleStore = new Store<People>("arr-tui-people", 7, 1000);
export const people = {
failed: new Map<string, number>(),
waiting: new Map<string, Promise<People>>(),
// load returns the cast of a title, from the store or Trakt. A title Trakt
// does not know has no cast.
load(r: Ref): Promise<People> {
const key = refKey(r);
const known = peopleStore.get(key);
if (known) return Promise.resolve(known);
const failedAt = this.failed.get(key);
if (failedAt && Date.now() - failedAt < 120_000) return Promise.reject(new Error("cast not available"));
let p = this.waiting.get(key);
if (!p) {
p = app.trakt.people(r).catch((e) => {
if (e instanceof TraktNotFound) return { cast: [], leaders: [] };
this.failed.set(key, Date.now());
throw e;
}).then((ppl) => {
peopleStore.put(key, ppl);
return ppl;
}).finally(() => this.waiting.delete(key));
this.waiting.set(key, p);
}
return p;
},
};
window.addEventListener("beforeunload", () => {
lookups.save();
peopleStore.save();
});
+86
View File
@@ -0,0 +1,86 @@
// The configuration: the arr-tui config file baked in at build time
// (config.js sets window.ARR_TUI_CONFIG), with what was changed in Settings
// on the TV kept in local storage on top.
export interface Service {
url: string;
apikey: string;
}
export interface Arr extends Service {
default_quality_profile?: string;
default_root_folder?: string;
}
export interface Config {
radarr: Arr;
sonarr: Arr;
sabnzbd: Service;
trakt: { client_id?: string };
ui: {
refresh_seconds?: number;
covers?: boolean;
notify_downloads?: boolean;
theme?: string; // auto, dark, light
};
}
const storageKey = "arr-tui-settings";
export function emptyConfig(): Config {
return { radarr: { url: "", apikey: "" }, sonarr: { url: "", apikey: "" }, sabnzbd: { url: "", apikey: "" }, trakt: {}, ui: {} };
}
// builtIn is the config the app was built with.
export function builtIn(): Config {
const raw = (window as unknown as { ARR_TUI_CONFIG?: Partial<Config> }).ARR_TUI_CONFIG;
return merge(emptyConfig(), raw ?? {});
}
export function loadConfig(): Config {
let saved: Partial<Config> = {};
try {
saved = JSON.parse(localStorage.getItem(storageKey) ?? "{}");
} catch {
/* unreadable: the built-in config */
}
return merge(builtIn(), saved);
}
export function saveConfig(c: Config): void {
try {
localStorage.setItem(storageKey, JSON.stringify(c));
} catch {
/* storage full or blocked: the change lasts until the app closes */
}
}
export function resetConfig(): void {
try {
localStorage.removeItem(storageKey);
} catch {
/* nothing stored */
}
}
export function hasLocalChanges(): boolean {
try {
return localStorage.getItem(storageKey) !== null;
} catch {
return false;
}
}
function merge(base: Config, over: Partial<Config>): Config {
return {
radarr: { ...base.radarr, ...over.radarr },
sonarr: { ...base.sonarr, ...over.sonarr },
sabnzbd: { ...base.sabnzbd, ...over.sabnzbd },
trakt: { ...base.trakt, ...over.trakt },
ui: { ...base.ui, ...over.ui },
};
}
export function refreshSeconds(c: Config): number {
return c.ui.refresh_seconds && c.ui.refresh_seconds > 0 ? c.ui.refresh_seconds : 3;
}
+82
View File
@@ -0,0 +1,82 @@
// How movies and series are described in lists and the details pane.
import { app } from "./app.ts";
import { pad2 } from "./api/arr.ts";
import { movieRating, type Movie } from "./api/radarr.ts";
import { seasonCount, type Series } from "./api/sonarr.ts";
import { formatSize, plural, runtime } from "./format.ts";
import type { Detail, Line } from "./ui/detail.ts";
export function titleYear(t: { title: string; year: number }): string {
return t.year > 0 ? `${t.title} (${t.year})` : t.title;
}
export function movieDetail(m: Movie, large = true): Detail {
const facts: string[] = [];
const r = movieRating(m);
if (r > 0) facts.push("★ " + r.toFixed(1));
if (m.runtime > 0) facts.push(runtime(m.runtime));
if (m.genres?.length) facts.push(m.genres.join(", "));
const lines: Line[] = [];
if (m.studio) lines.push({ text: "Studio: " + m.studio });
if (m.status) lines.push({ text: "Status: " + m.status });
if (m.hasFile) lines.push({ text: "In library, downloaded", cls: "ok" });
else if (m.id) lines.push({ text: "In library, not downloaded", cls: "warn" });
return {
title: titleYear(m),
facts,
lines,
plot: m.overview ?? "",
poster: app.radarr.posterURL(m.images, !!m.id, large),
ref: { kind: "movie", tmdb: m.tmdbId, imdb: m.imdbId },
};
}
export function seriesDetail(s: Series, large = true): Detail {
const facts: string[] = [];
if (s.ratings && s.ratings.value > 0) facts.push("★ " + s.ratings.value.toFixed(1));
const n = seasonCount(s);
if (n > 0) facts.push(plural(n, "season"));
if (s.runtime > 0) facts.push(s.runtime + " min");
if (s.genres?.length) facts.push(s.genres.join(", "));
const lines: Line[] = [];
if (s.network) lines.push({ text: "Network: " + s.network });
if (s.status) lines.push({ text: "Status: " + s.status });
if (s.id) {
const st = s.statistics;
if (st && st.episodeCount > 0 && st.episodeFileCount >= st.episodeCount) lines.push({ text: "In library, all episodes downloaded", cls: "ok" });
else lines.push({ text: `In library, ${st?.episodeFileCount ?? 0}/${st?.episodeCount ?? 0} episodes downloaded`, cls: "warn" });
}
return {
title: titleYear(s),
facts,
lines,
plot: s.overview ?? "",
poster: app.sonarr.posterURL(s.images, !!s.id, large),
ref: { kind: "show", tvdb: s.tvdbId, imdb: s.imdbId },
};
}
export function monitoredLine(m: boolean): Line {
return m ? { text: "Monitored: yes" } : { text: "Monitored: no", cls: "muted" };
}
export function sizeLine(b: number): Line | null {
return b > 0 ? { text: "Size on disk: " + formatSize(b) } : null;
}
// Download state of a title in the Radarr/Sonarr queue.
export interface DlInfo {
count: number;
progress: number;
timeLeft: string;
}
export function dlLine(d: DlInfo): Line {
if (d.count > 1) return { text: `Downloading ${d.count} episodes`, cls: "dl" };
return { text: `Downloading ${Math.round(d.progress)}%` + (d.timeLeft ? ` · ${d.timeLeft} left` : ""), cls: "dl" };
}
export function episodeCode(season: number, episode: number): string {
return `S${pad2(season)}E${pad2(episode)}`;
}
+65
View File
@@ -0,0 +1,65 @@
// Display helpers, ported from internal/ui/format.go and releases.go.
export function formatSize(b: number): string {
const k = 1024;
if (b >= k * k * k) return (b / (k * k * k)).toFixed(1) + " GB";
if (b >= k * k) return Math.round(b / (k * k)) + " MB";
if (b > 0) return Math.round(b / k) + " KB";
return "–";
}
export function formatReleaseAge(minutes: number): string {
if (minutes < 60) return Math.trunc(minutes) + "m";
if (minutes < 48 * 60) return Math.trunc(minutes / 60) + "h";
return Math.trunc(minutes / 1440) + "d";
}
export function formatSpeed(kbPerSec: number): string {
if (kbPerSec >= 1024) return (kbPerSec / 1024).toFixed(1) + " MB/s";
if (kbPerSec >= 1) return Math.round(kbPerSec) + " KB/s";
return "0 KB/s";
}
// formatAge renders a unix time relative to now ("5m ago", "3d ago").
export function formatAge(unix: number, now = Date.now()): string {
if (unix <= 0) return "";
const s = now / 1000 - unix;
if (s < 60) return "just now";
if (s < 3600) return Math.trunc(s / 60) + "m ago";
if (s < 48 * 3600) return Math.trunc(s / 3600) + "h ago";
return Math.trunc(s / 86400) + "d ago";
}
export function runtime(min: number): string {
return `${Math.trunc(min / 60)}h${String(min % 60).padStart(2, "0")}m`;
}
export function plural(n: number, word: string): string {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
// isoDay is the local date as YYYY-MM-DD.
export function isoDay(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function addDays(d: Date, n: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + n);
return out;
}
// dayLabel: "Today", "Tomorrow", "Yesterday" or "Mon 26 Oct".
export function dayLabel(day: string, today: string): string {
const d = new Date(day + "T12:00:00");
const t = new Date(today + "T12:00:00");
const diff = Math.round((d.getTime() - t.getTime()) / 86400000);
if (diff === 0) return "Today";
if (diff === 1) return "Tomorrow";
if (diff === -1) return "Yesterday";
return d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
}
export function capitalize(s: string): string {
return s ? s[0].toUpperCase() + s.slice(1) : s;
}
+135
View File
@@ -0,0 +1,135 @@
// arr-tui for webOS: the shell with the views, the status of the services,
// the remote's hints and its buttons.
import { app } from "./app.ts";
import { applyTheme } from "./theme.ts";
import { h, replace } from "./ui/dom.ts";
import { current, focus, focusFirst, initFocus, Keys } from "./ui/focus.ts";
import { button, confirmDialog, toast } from "./ui/widgets.ts";
import { CalendarView } from "./views/calendar.ts";
import { DownloadsView, watchDownloads } from "./views/downloads.ts";
import { LibraryView } from "./views/library.ts";
import { LogView } from "./views/log.ts";
import { SearchView } from "./views/search.ts";
import { SettingsView } from "./views/settings.ts";
import type { Hint, View } from "./views/view.ts";
interface Entry {
name: string;
view: View;
tab: HTMLButtonElement;
}
function start(): void {
applyTheme(app.cfg.ui.theme);
const content = h("main.content");
const hints = h("div.footer-hints");
const dots = h("div.status-dots");
const tabs = h("nav.tabs.fgroup");
const entries: Entry[] = [];
let cur: Entry | null = null;
let switchTimer = 0;
const show = (e: Entry, focusView: boolean) => {
if (cur === e) {
if (focusView) focusFirst(e.view.el);
return;
}
cur?.view.hide();
cur?.tab.classList.remove("on");
cur = e;
e.tab.classList.add("on");
replace(content, e.view.el);
renderHints(e.view.hints);
e.view.show();
if (!focusView && current() !== e.tab) focus(e.tab);
};
const views: [string, View][] = [
["Movies", new SearchView("movie")],
["Series", new SearchView("series")],
["Library", new LibraryView()],
["Downloads", new DownloadsView()],
["Calendar", new CalendarView()],
["Settings", new SettingsView()],
["Log", new LogView()],
];
views.forEach(([name, view], i) => {
const tab = button(`${i + 1} ${name}`, () => show(entry, true), "tab");
const entry: Entry = { name, view, tab };
// Moving along the tabs switches views once the focus rests there.
tab.addEventListener("focus", () => {
clearTimeout(switchTimer);
switchTimer = window.setTimeout(() => {
if (document.activeElement === tab) show(entry, false);
}, 250);
});
entries.push(entry);
tabs.appendChild(tab);
});
function renderHints(list: Hint[]): void {
replace(hints, ...list.map(([b, text]) => h("span.hint", h(`span.key.${b}` as "span", b === "ok" ? "OK" : b === "back" ? "Back" : ""), text)),
h("span.hint", h("span.key.back", "Back"), "back"));
}
function renderDots(): void {
replace(dots, ...(["Radarr", "Sonarr", "SABnzbd", "Trakt"] as const).map((name) => {
const configured = name === "Radarr" ? app.radarr.configured() : name === "Sonarr" ? app.sonarr.configured()
: name === "SABnzbd" ? app.sab.configured() : app.trakt.configured();
const err = app.status[name];
return h(`span.status-dot.${!configured ? "off" : err ? "bad" : "ok"}` as "span", { title: err || name }, name);
}));
}
app.onStatus.push(renderDots);
app.onConfig.push(renderDots);
renderDots();
const root = h("div.app",
h("header.topbar", h("div.brand", "arr-tui"), tabs, dots),
content,
h("footer.footer", hints),
);
document.getElementById("root")!.appendChild(root);
// Back: from a view to its tab; on the tabs, ask before leaving the app.
const back = () => {
const el = current();
if (cur && el && !tabs.contains(el)) {
focus(cur.tab);
return;
}
confirmDialog("Leave arr-tui?", "Leave", () => window.close());
};
const keys = (ev: KeyboardEvent): boolean => {
// 1-7 open a view.
const n = ev.keyCode >= 49 && ev.keyCode <= 57 ? ev.keyCode - 49 : ev.keyCode >= 97 && ev.keyCode <= 105 ? ev.keyCode - 97 : -1;
if (n >= 0 && n < entries.length) {
show(entries[n], true);
return true;
}
// Desktop browsers: F1-F4 stand in for the colour buttons.
const code = ({ 112: Keys.red, 113: Keys.green, 114: Keys.yellow, 115: Keys.blue } as Record<number, number>)[ev.keyCode] ?? ev.keyCode;
return !!cur?.view.keys?.(code);
};
initFocus(root, back, keys);
const first = app.radarr.configured() || app.sonarr.configured() || app.sab.configured() ? 0 : 5; // nothing set up: Settings
show(entries[first], false);
watchDownloads();
if (first === 5) toast("Nothing is configured yet: enter the addresses and API keys of your services.", "warn");
// Check the services once at start, for the status dots.
for (const [name, check] of [
["Radarr", () => app.radarr.systemStatus()],
["Sonarr", () => app.sonarr.systemStatus()],
["SABnzbd", () => app.sab.version()],
] as const) {
const configured = name === "Radarr" ? app.radarr.configured() : name === "Sonarr" ? app.sonarr.configured() : app.sab.configured();
if (configured) check().then(() => app.setStatus(name, null), (e) => app.setStatus(name, e));
}
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
else start();
+200
View File
@@ -0,0 +1,200 @@
// File and release properties and the property filter, ported from
// internal/arr/mediafile.go, internal/arr/release.go and internal/ui/libfilter.go.
import type { MediaFile, Release } from "./api/arr.ts";
export interface FileAttrs {
resolution: number; // 2160, 1080, 720, 480 ... or 0
codec: string; // x265, x264, AV1 or ""
hdr: boolean;
dv: boolean;
audio: string[];
languages: string[];
}
export const resOptions = ["2160p", "1080p", "720p", "SD"];
export const codecOptions = ["x265", "x264", "AV1"];
export const hdrOptions = ["HDR", "DV", "SDR"];
export const audioOptions = ["Atmos", "TrueHD", "DTS:X", "DTS-HD", "DTS", "DD+", "DD", "AAC", "FLAC", "Opus"];
const reRes = /\b(2160|1080|720|576|480)p\b/i;
const re265 = /(x\.?265|h\.?265|hevc)/i;
const re264 = /(x\.?264|h\.?264|\bavc\b)/i;
const reAV1 = /\bav1\b/i;
const reDV = /(\bdv\b|dovi|dolby[ .]?vision)/i;
const reAtmos = /(atmos|\bddpa\b)/i;
const reTrueHD = /true[ .-]?hd/i;
const reDTSX = /\bdts[ .:-]?x\b/i;
const reDTSHD = /dts[ .-]?(hd|ma|hra|es)/i;
const reDTS = /\bdts([^a-z]|$)/i;
const reDDPlus = /(e[ .-]?ac[ .-]?3|\bddp|dd\+|dolby digital plus)/i;
const reDD = /(\bac[ .-]?3\b|\bdd[ .]?[0-9]|dolby digital\b)/i;
const reAAC = /\baac/i;
const reFLAC = /\bflac\b/i;
const reOpus = /\bopus\b/i;
const reHDR = /\b(hdr(10(\+|plus)?)?|hlg|pq)\b/i;
export function codecOf(s: string): string {
if (re265.test(s)) return "x265";
if (re264.test(s)) return "x264";
if (reAV1.test(s)) return "AV1";
return "";
}
export function audioTags(s: string): string[] {
const tags: string[] = [];
if (reAtmos.test(s)) tags.push("Atmos");
if (reTrueHD.test(s)) tags.push("TrueHD");
const dtsx = reDTSX.test(s);
const dtshd = reDTSHD.test(s);
if (dtsx) tags.push("DTS:X");
if (dtshd) tags.push("DTS-HD");
if (dtsx || dtshd || reDTS.test(s)) tags.push("DTS");
if (reDDPlus.test(s)) tags.push("DD+");
else if (reDD.test(s)) tags.push("DD");
if (reAAC.test(s)) tags.push("AAC");
if (reFLAC.test(s)) tags.push("FLAC");
if (reOpus.test(s)) tags.push("Opus");
return tags;
}
function resolutionIn(text: string): number {
const m = reRes.exec(text);
return m ? parseInt(m[1], 10) : 0;
}
// fileAttrs uses Radarr/Sonarr's media info where present, else the names.
export function fileAttrs(f: MediaFile): FileAttrs {
const q = f.quality?.quality;
const names = [f.sceneName ?? "", f.relativePath ?? "", q?.name ?? ""].join(" ");
const mi = f.mediaInfo ?? {};
let dr = `${mi.videoDynamicRangeType ?? ""} ${mi.videoDynamicRange ?? ""}`;
if (!dr.trim()) dr = names;
let audio = `${mi.audioCodec ?? ""} ${mi.audioAdditionalFeatures ?? ""}`;
if (!audio.trim()) audio = names;
return {
resolution: q?.resolution || resolutionIn(names),
codec: mi.videoCodec ? codecOf(mi.videoCodec) : codecOf(names),
dv: reDV.test(dr),
hdr: reHDR.test(dr),
audio: audioTags(audio),
languages: (f.languages ?? []).map((l) => l.name).filter(Boolean),
};
}
// releaseAttrs reads what a release announces: title, quality, custom formats.
export function releaseAttrs(r: Release): FileAttrs {
const q = r.quality?.quality;
const text = [r.title, q?.name ?? "", ...(r.customFormats ?? []).map((c) => c.name)].join(" ");
return {
resolution: q?.resolution || resolutionIn(text),
codec: codecOf(text),
dv: reDV.test(text),
hdr: reHDR.test(text),
audio: audioTags(text),
languages: (r.languages ?? []).map((l) => l.name).filter(Boolean),
};
}
// AttrFilter: "" means any. All fields must match the same file.
export interface AttrFilter {
res: string;
lang: string;
codec: string;
hdr: string;
audio: string;
}
export const noFilter = (): AttrFilter => ({ res: "", lang: "", codec: "", hdr: "", audio: "" });
export function filterActive(f: AttrFilter): boolean {
return !!(f.res || f.lang || f.codec || f.hdr || f.audio);
}
export function filterLabel(f: AttrFilter): string {
return [f.res, f.lang, f.codec, f.hdr, f.audio].filter(Boolean).join(" ");
}
export function matchFile(f: AttrFilter, a: FileAttrs): boolean {
if (f.res === "SD") {
if (a.resolution <= 0 || a.resolution > 576) return false;
} else if (f.res && f.res !== a.resolution + "p") return false;
if (f.codec && f.codec !== a.codec) return false;
if (f.hdr === "HDR" && !a.hdr) return false;
if (f.hdr === "DV" && !a.dv) return false;
if (f.hdr === "SDR" && (a.hdr || a.dv)) return false;
if (f.audio && !a.audio.includes(f.audio)) return false;
if (f.lang && !a.languages.some((l) => l.toLowerCase() === f.lang.toLowerCase())) return false;
return true;
}
// matchAny: any of an item's files (a series has several). None: no match.
export function matchAny(f: AttrFilter, files: FileAttrs[]): boolean {
return files.some((a) => matchFile(f, a));
}
// Pool is what the filter dialog works on: the files of every item that
// passes the other filters. unknown: some are not read yet, nothing can be
// ruled out.
export interface Pool {
files: FileAttrs[][];
unknown: boolean;
}
export function poolHas(p: Pool, f: AttrFilter): boolean {
return p.unknown || p.files.some((files) => matchAny(f, files));
}
export function poolLanguages(p: Pool): string[] {
const seen = new Set<string>();
for (const files of p.files) for (const a of files) for (const l of a.languages) seen.add(l);
return [...seen].sort();
}
export interface Facet {
label: string;
key: keyof AttrFilter;
candidates: (p: Pool) => string[];
}
// Most used first.
export const facets: Facet[] = [
{ label: "Quality", key: "res", candidates: () => resOptions },
{ label: "Language", key: "lang", candidates: poolLanguages },
{ label: "Codec", key: "codec", candidates: () => codecOptions },
{ label: "HDR", key: "hdr", candidates: () => hdrOptions },
{ label: "Audio", key: "audio", candidates: () => audioOptions },
];
// listedValues: the options a facet offers, those that together with the
// other choices still leave a result, plus the current choice; "" first (any).
export function listedValues(fc: Facet, state: AttrFilter, pool: Pool): string[] {
const cur = state[fc.key];
let cands = fc.candidates(pool);
if (cur && !cands.includes(cur)) cands = [cur, ...cands];
const vals = [""];
for (const o of cands) {
if (o === cur || poolHas(pool, { ...state, [fc.key]: o })) vals.push(o);
}
return vals;
}
// --- Releases ---
export type SortMode = "best" | "size" | "age";
export function isRejected(r: Release): boolean {
return r.rejected || (r.rejections?.length ?? 0) > 0;
}
export function visibleReleases(all: Release[], hideRejected: boolean, mode: SortMode, af: AttrFilter): Release[] {
const out = all.filter((r) => !(hideRejected && isRejected(r)) && (!filterActive(af) || matchFile(af, releaseAttrs(r))));
const cmp = (a: number, b: number) => (a < b ? -1 : a > b ? 1 : 0);
// Array.prototype.sort is stable in Chrome 70+; older TVs may reorder ties.
return out.sort((a, b) => {
if (mode === "size") return cmp(b.size, a.size);
if (mode === "age") return cmp(a.ageMinutes, b.ageMinutes);
return cmp(+isRejected(a), +isRejected(b)) || cmp(b.customFormatScore, a.customFormatScore) ||
cmp(b.qualityWeight, a.qualityWeight) || cmp(a.ageMinutes, b.ageMinutes);
});
}
+33
View File
@@ -0,0 +1,33 @@
// Seasons of a series: counts, labels and episode states.
import type { Episode } from "./api/sonarr.ts";
export interface SeasonSummary {
number: number;
have: number;
total: number;
}
// summarizeSeasons counts the episodes per season, newest season first,
// specials last.
export function summarizeSeasons(eps: Episode[]): SeasonSummary[] {
const by = new Map<number, SeasonSummary>();
for (const e of eps) {
const s = by.get(e.seasonNumber) ?? { number: e.seasonNumber, have: 0, total: 0 };
s.total++;
if (e.hasFile) s.have++;
by.set(e.seasonNumber, s);
}
return [...by.values()].sort((a, b) => (a.number === 0 ? 1 : b.number === 0 ? -1 : b.number - a.number));
}
export function seasonLabel(n: number): string {
return n === 0 ? "Specials" : "Season " + n;
}
export function episodeStatus(e: Episode, today: string): [string, string] {
if (e.hasFile) return ["✓ downloaded", "ok"];
if (!e.airDate || e.airDate > today) return ["unaired", "muted"];
if (!e.monitored) return ["unmonitored", "muted"];
return ["missing", "warn"];
}
+10
View File
@@ -0,0 +1,10 @@
// Dark and light themes (CSS variables in style.css). "auto" follows the
// system's preference where the platform reports one, else dark.
export function applyTheme(setting: string | undefined): void {
let theme = setting === "light" || setting === "dark" ? setting : "dark";
if (!setting || setting === "auto") {
theme = window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
document.documentElement.setAttribute("data-theme", theme);
}
+106
View File
@@ -0,0 +1,106 @@
// The details pane: poster, title, facts, state, plot and the cast.
import { app, people } from "../app.ts";
import { refKey, type People, type Ref } from "../api/trakt.ts";
import { h, replace } from "./dom.ts";
export interface Line {
text: string;
cls?: string; // ok, warn, muted, dl (downloading), bold
}
export interface Detail {
title: string;
sub?: string; // a second heading line (an episode)
facts: string[];
lines: Line[];
plot: string;
poster: string; // URL, "" for none
ref?: Ref; // for the cast
}
export class DetailPane {
el: HTMLElement;
private poster: HTMLImageElement;
private text: HTMLElement;
private cast: HTMLElement;
private castKey = "";
private settle = 0;
private posterSettle = 0;
constructor() {
this.poster = h("img.detail-poster", { alt: "" });
this.poster.addEventListener("error", () => this.poster.classList.add("none"));
this.poster.addEventListener("load", () => this.poster.classList.remove("loading"));
this.text = h("div.detail-text");
this.cast = h("div.detail-cast");
this.el = h("div.detail", this.poster, this.text, this.cast);
}
show(d: Detail | null): void {
if (!d) {
replace(this.text);
replace(this.cast);
this.poster.classList.add("none");
this.castKey = "";
return;
}
replace(this.text,
h("h2.detail-title", d.title),
d.sub ? h("div.detail-sub", d.sub) : null,
d.facts.length ? h("div.detail-facts", d.facts.join(" · ")) : null,
...d.lines.map((l) => h(`div.detail-line${l.cls ? "." + l.cls : ""}` as "div", l.text)),
d.plot ? h("p.detail-plot", d.plot) : null,
);
this.showPoster(app.covers() ? d.poster : "");
this.showCast(d.ref);
}
// showPoster waits until the selection rests (a held arrow key must not
// start a download per step).
private showPoster(url: string): void {
clearTimeout(this.posterSettle);
if (!url) {
this.poster.classList.add("none");
this.poster.removeAttribute("src");
return;
}
if (this.poster.getAttribute("src") === url) return;
this.poster.classList.remove("none");
this.poster.classList.add("loading");
this.posterSettle = window.setTimeout(() => {
this.poster.src = url;
}, 150);
}
private showCast(ref: Ref | undefined): void {
const key = ref ? refKey(ref) : "";
if (key === this.castKey) return;
this.castKey = key;
replace(this.cast);
clearTimeout(this.settle);
if (!ref || !key || !app.trakt.configured()) return;
this.settle = window.setTimeout(() => {
people.load(ref).then((p) => {
if (this.castKey === key) replace(this.cast, ...castLines(p, ref.kind));
}, () => {
/* no cast: nothing to show */
});
}, 300);
}
}
function castLines(p: People, kind: "movie" | "show"): HTMLElement[] {
const out: HTMLElement[] = [];
if (p.leaders.length) {
out.push(h("div.cast-head", kind === "show" ? "Created by" : "Director"), h("div.cast-names", p.leaders.map((l) => l.name).join(", ")));
}
if (p.cast.length) {
out.push(h("div.cast-head", "Cast"));
for (const c of p.cast.slice(0, 8)) {
out.push(h("div.cast-person", h("span", c.name), c.role ? h("span.muted", " as " + c.role) : null));
}
if (p.cast.length > 8) out.push(h("div.muted", `and ${p.cast.length - 8} more`));
}
return out;
}
+42
View File
@@ -0,0 +1,42 @@
// Tiny DOM helpers: h("div.card", {onclick}, child, "text").
type Child = Node | string | number | null | undefined | false;
type Attrs = Record<string, unknown>;
export function h<K extends keyof HTMLElementTagNameMap>(spec: K | `${K}.${string}` | `${K}#${string}`, attrs?: Attrs | Child, ...children: Child[]): HTMLElementTagNameMap[K] {
const [tagAndId, ...classes] = spec.split(".");
const [tag, id] = tagAndId.split("#");
const el = document.createElement(tag) as HTMLElementTagNameMap[K];
if (id) el.id = id;
if (classes.length) el.className = classes.join(" ");
if (attrs && typeof attrs === "object" && !(attrs instanceof Node)) {
for (const [k, v] of Object.entries(attrs)) {
if (v === undefined || v === null || v === false) continue;
if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v as EventListener);
else if (k === "class") el.className += (el.className ? " " : "") + v;
else if (k === "style") el.setAttribute("style", String(v));
else if (k in el && typeof v !== "string") (el as unknown as Record<string, unknown>)[k] = v;
else el.setAttribute(k, v === true ? "" : String(v));
}
} else if (attrs !== undefined) {
children.unshift(attrs as Child);
}
append(el, ...children);
return el;
}
export function append(el: Element, ...children: Child[]): void {
for (const c of children) {
if (c === null || c === undefined || c === false) continue;
el.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export function clear(el: Element): void {
while (el.firstChild) el.removeChild(el.firstChild);
}
export function replace(el: Element, ...children: Child[]): void {
clear(el);
append(el, ...children);
}
+222
View File
@@ -0,0 +1,222 @@
// Remote-control focus: the arrow keys move between focusable elements by
// their position on screen (spatial navigation), OK presses, Back goes back.
//
// Focusable elements carry the class "f". Dialogs open a layer: navigation
// stays inside the topmost layer, and closing it returns the focus to where
// it was. Elements can handle keys themselves (a list moves its selection
// with up and down) by setting onKey; returning true stops the navigation.
// A group (class "fgroup") remembers its last focused element, so moving back
// into a group continues where one left it.
export const Keys = {
left: 37,
up: 38,
right: 39,
down: 40,
enter: 13,
back: 461, // webOS remote
escape: 27,
backspace: 8,
red: 403,
green: 404,
yellow: 405,
blue: 406,
pageUp: 33, // also channel up on the remote
pageDown: 34,
} as const;
export type Dir = "left" | "right" | "up" | "down";
export interface KeyHandling {
onKey?: (ev: KeyboardEvent) => boolean;
}
interface Layer {
root: HTMLElement;
back: () => void;
restore: HTMLElement | null; // focused when the layer opened
}
const layers: Layer[] = [];
let globalKeys: (ev: KeyboardEvent) => boolean = () => false;
export function initFocus(root: HTMLElement, back: () => void, keys: (ev: KeyboardEvent) => boolean): void {
layers.length = 0;
layers.push({ root, back, restore: null });
globalKeys = keys;
document.addEventListener("keydown", onKeyDown, true);
// The Magic Remote's pointer: hovering focuses, like moving there.
document.addEventListener("mouseover", (ev) => {
const el = (ev.target as Element | null)?.closest?.(".f") as HTMLElement | null;
if (el && inLayer(el) && el !== document.activeElement && !isTextInput(document.activeElement)) focus(el, false);
});
}
export function pushLayer(root: HTMLElement, back: () => void): void {
layers.push({ root, back, restore: current() });
}
// popLayer closes the topmost layer (root must be it) and refocuses.
export function popLayer(root: HTMLElement): void {
const i = layers.findIndex((l) => l.root === root);
if (i <= 0) return;
const [layer] = layers.splice(i, 1);
if (i === layers.length) {
const back = layer.restore;
if (back && back.isConnected && visible(back)) focus(back);
else focusFirst(top().root);
}
}
export function top(): Layer {
return layers[layers.length - 1];
}
export function current(): HTMLElement | null {
const a = document.activeElement as HTMLElement | null;
return a && a !== document.body && a.classList?.contains("f") ? a : null;
}
export function focus(el: HTMLElement, scroll = true): void {
if (!el.hasAttribute("tabindex") && !isTextInput(el)) el.tabIndex = -1;
el.focus({ preventScroll: true });
if (scroll) el.scrollIntoView({ block: "nearest", inline: "nearest" });
const g = el.closest(".fgroup") as (HTMLElement & { lastFocus?: HTMLElement }) | null;
if (g) g.lastFocus = el;
}
// focusFirst focuses the first focusable element in root (or a remembered
// one of its group).
export function focusFirst(root: HTMLElement): boolean {
const g = root as HTMLElement & { lastFocus?: HTMLElement };
if (g.lastFocus && g.lastFocus.isConnected && visible(g.lastFocus) && root.contains(g.lastFocus)) {
focus(g.lastFocus);
return true;
}
const el = focusables(root)[0];
if (el) focus(el);
return !!el;
}
export function focusables(root: Element): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(".f")).filter(visible);
}
function visible(el: HTMLElement): boolean {
if (el.classList.contains("f-off") || (el as HTMLButtonElement).disabled) return false;
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
}
function inLayer(el: Element): boolean {
return top().root.contains(el);
}
function isTextInput(el: Element | null): el is HTMLInputElement {
return !!el && el.tagName === "INPUT" && ["text", "search", "password", "url", ""].includes((el as HTMLInputElement).type);
}
export function isBackKey(ev: KeyboardEvent): boolean {
if (ev.keyCode === Keys.back || ev.keyCode === Keys.escape) return true;
return ev.keyCode === Keys.backspace && !isTextInput(document.activeElement);
}
function onKeyDown(ev: KeyboardEvent): void {
const el = current();
if (el && inLayer(el) && (el as KeyHandling).onKey?.(ev)) {
ev.preventDefault();
return;
}
if (isBackKey(ev)) {
if (isTextInput(document.activeElement) && ev.keyCode === Keys.escape && document.activeElement.value) {
return; // let Escape do nothing special in a field with text
}
ev.preventDefault();
top().back();
return;
}
const dir = ({ 37: "left", 38: "up", 39: "right", 40: "down" } as Record<number, Dir>)[ev.keyCode];
if (dir) {
if (isTextInput(el) && (dir === "left" || dir === "right") && caretInside(el, dir)) return;
ev.preventDefault();
if (!el || !inLayer(el)) {
focusFirst(top().root);
return;
}
const next = neighbour(el, dir, focusables(top().root));
if (next) focus(enterGroup(el, next));
return;
}
if (ev.keyCode === Keys.enter && el && !isTextInput(el)) {
ev.preventDefault();
el.click();
return;
}
// View keys (numbers, colour buttons) only while no dialog or page is open.
if (layers.length === 1 && !isTextInput(document.activeElement) && globalKeys(ev)) ev.preventDefault();
}
function caretInside(input: HTMLInputElement, dir: Dir): boolean {
const pos = input.selectionStart ?? 0;
return dir === "left" ? pos > 0 : pos < input.value.length;
}
// enterGroup: moving into another group goes to the element remembered there.
function enterGroup(from: HTMLElement, to: HTMLElement): HTMLElement {
const g = to.closest(".fgroup") as (HTMLElement & { lastFocus?: HTMLElement }) | null;
if (!g || g.contains(from)) return to;
const last = g.lastFocus;
return last && last.isConnected && visible(last) && g.contains(last) ? last : to;
}
// neighbour finds the element to move to: the nearest one in the direction,
// preferring those that overlap the current one across the direction (the
// card right below, not the one diagonally closer).
export function neighbour(from: HTMLElement, dir: Dir, candidates: HTMLElement[]): HTMLElement | null {
const c = from.getBoundingClientRect();
let best: HTMLElement | null = null;
let bestScore = Infinity;
for (const el of candidates) {
if (el === from || el.contains(from) || from.contains(el)) continue;
const r = el.getBoundingClientRect();
const slack = 4;
let gap: number, cross: number;
switch (dir) {
case "right":
if (r.left < c.right - slack) continue;
gap = r.left - c.right;
cross = intervalGap(c.top, c.bottom, r.top, r.bottom);
break;
case "left":
if (r.right > c.left + slack) continue;
gap = c.left - r.right;
cross = intervalGap(c.top, c.bottom, r.top, r.bottom);
break;
case "down":
if (r.top < c.bottom - slack) continue;
gap = r.top - c.bottom;
cross = intervalGap(c.left, c.right, r.left, r.right);
break;
case "up":
if (r.bottom > c.top + slack) continue;
gap = c.top - r.bottom;
cross = intervalGap(c.left, c.right, r.left, r.right);
break;
}
// Among overlapping ones, the one whose start is nearest wins (a list
// of columns under a wide bar: the left-most below it, not a random one).
const align = dir === "up" || dir === "down" ? Math.abs(r.left - c.left) : Math.abs(r.top - c.top);
const score = Math.max(gap, 0) + (cross > 0 ? 10000 + cross * 3 : align * 0.01);
if (score < bestScore) {
bestScore = score;
best = el;
}
}
return best;
}
function intervalGap(a1: number, a2: number, b1: number, b2: number): number {
if (b2 <= a1) return a1 - b2;
if (b1 >= a2) return b1 - a2;
return 0;
}
+188
View File
@@ -0,0 +1,188 @@
// ListView: a focusable list with its own selection, like the TUI's tables.
// Up and down move the selection (and leave the list at its ends), channel
// up / down page, OK activates. Only the rows in view are in the DOM, so a
// library of thousands of titles stays quick on TV hardware.
import { h, replace } from "./dom.ts";
import { Keys, type KeyHandling } from "./focus.ts";
export interface Row<T> {
item?: T; // undefined: a heading (not selectable)
key: string;
}
export interface ListOptions<T> {
rowHeight: number; // pixels
render: (item: T, selected: boolean) => HTMLElement;
heading?: (key: string) => HTMLElement;
onSelect?: (item: T | undefined) => void;
onActivate?: (item: T) => void;
placeholder?: string;
header?: HTMLElement; // column titles, above the rows
}
export class ListView<T> {
el: HTMLElement & KeyHandling;
private body: HTMLElement;
private rows: Row<T>[] = [];
private sel = -1;
private top = 0;
private opts: ListOptions<T>;
private placeholder: string;
constructor(cls: string, opts: ListOptions<T>) {
this.opts = opts;
this.placeholder = opts.placeholder ?? "";
this.body = h("div.list-body");
opts.header?.classList.add("list-row");
this.el = h(`div.list.f.${cls}`, {}, opts.header ?? null, this.body) as HTMLElement & KeyHandling;
this.el.tabIndex = -1;
this.el.onKey = (ev) => this.key(ev);
this.el.addEventListener("focus", () => this.draw());
this.el.addEventListener("blur", () => this.draw());
this.el.addEventListener("wheel", (ev) => {
ev.preventDefault();
this.move(ev.deltaY > 0 ? 3 : -3);
}, { passive: false });
this.body.addEventListener("click", (ev) => {
const rowEl = (ev.target as Element).closest("[data-i]") as HTMLElement | null;
if (!rowEl) return;
const i = Number(rowEl.dataset.i);
if (i === this.sel) this.activate();
else this.select(i);
});
new ResizeObserverLike(this.body, () => this.draw());
}
get selected(): T | undefined {
return this.rows[this.sel]?.item;
}
get length(): number {
return this.rows.filter((r) => r.item !== undefined).length;
}
setPlaceholder(text: string): void {
this.placeholder = text;
if (!this.rows.length) this.draw();
}
// setRows replaces the rows, keeping the selection on the same key, or at
// the same place if that row is gone (removed: the next one moves up).
setRows(rows: Row<T>[], keepPlace = true): void {
const oldKey = this.rows[this.sel]?.key;
const oldIndex = this.sel;
this.rows = rows;
let sel = oldKey !== undefined ? rows.findIndex((r) => r.key === oldKey) : -1;
if (sel < 0 && keepPlace && oldIndex >= 0) sel = Math.min(oldIndex, rows.length - 1);
if (sel < 0) sel = 0;
this.sel = this.nearestItem(sel, 1);
if (this.sel < 0) this.sel = this.nearestItem(rows.length - 1, -1);
this.draw();
this.opts.onSelect?.(this.selected);
}
setItems(items: T[], key: (t: T) => string): void {
this.setRows(items.map((item) => ({ item, key: key(item) })));
}
// redraw re-renders the visible rows (an item changed in place).
redraw(): void {
this.draw();
}
select(i: number): void {
const n = this.nearestItem(i, i >= this.sel ? 1 : -1);
if (n < 0 || n === this.sel) return;
this.sel = n;
this.draw();
this.opts.onSelect?.(this.selected);
}
selectKey(key: string): void {
const i = this.rows.findIndex((r) => r.key === key);
if (i >= 0) this.select(i);
}
private nearestItem(i: number, step: number): number {
for (let j = i; j >= 0 && j < this.rows.length; j += step) if (this.rows[j].item !== undefined) return j;
return -1;
}
private activate(): void {
const it = this.selected;
if (it !== undefined) this.opts.onActivate?.(it);
}
private move(delta: number): boolean {
const target = this.nearestItem(Math.max(0, Math.min(this.rows.length - 1, this.sel + delta)), delta > 0 ? 1 : -1);
const fallback = this.nearestItem(Math.max(0, Math.min(this.rows.length - 1, this.sel + delta)), delta > 0 ? -1 : 1);
const n = target >= 0 ? target : fallback;
if (n < 0 || n === this.sel) return false;
this.select(n);
return true;
}
private key(ev: KeyboardEvent): boolean {
switch (ev.keyCode) {
case Keys.up:
return this.move(-1);
case Keys.down:
return this.move(1);
case Keys.pageUp:
this.move(-this.visibleRows());
return true;
case Keys.pageDown:
this.move(this.visibleRows());
return true;
case Keys.enter:
this.activate();
return true;
}
return false;
}
private visibleRows(): number {
return Math.max(1, Math.floor(this.body.clientHeight / this.opts.rowHeight));
}
private draw(): void {
const n = this.visibleRows();
if (this.sel < this.top) this.top = this.sel;
if (this.sel >= this.top + n) this.top = this.sel - n + 1;
this.top = Math.max(0, Math.min(this.top, Math.max(0, this.rows.length - n)));
const focused = document.activeElement === this.el;
const out: HTMLElement[] = [];
if (!this.rows.length && this.placeholder) out.push(h("div.list-placeholder", this.placeholder));
for (let i = this.top; i < Math.min(this.rows.length, this.top + n); i++) {
const row = this.rows[i];
let el: HTMLElement;
if (row.item === undefined) {
el = this.opts.heading ? this.opts.heading(row.key) : h("div.list-heading", row.key);
} else {
el = this.opts.render(row.item, i === this.sel);
el.dataset.i = String(i);
if (i === this.sel) el.classList.add(focused ? "sel" : "sel-idle");
}
el.classList.add("list-row");
el.style.height = this.opts.rowHeight + "px";
out.push(el);
}
if (this.rows.length > n) {
const pct = this.top / (this.rows.length - n);
const bar = h("div.list-scroll", h("div.list-thumb", { style: `height:${Math.max(8, (n / this.rows.length) * 100)}%;top:${pct * (100 - Math.max(8, (n / this.rows.length) * 100))}%` }));
out.push(bar);
}
replace(this.body, ...out);
}
}
// ResizeObserverLike redraws when the element changes size: ResizeObserver
// (Chrome 64+), else on window resizes.
class ResizeObserverLike {
constructor(el: HTMLElement, cb: () => void) {
const RO = (window as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
if (RO) new RO(() => cb()).observe(el);
else window.addEventListener("resize", cb);
}
}
+206
View File
@@ -0,0 +1,206 @@
// Dialogs, action menus, option pickers, buttons and toasts.
import { append, h, replace } from "./dom.ts";
import { focus, focusFirst, Keys, popLayer, pushLayer, type KeyHandling } from "./focus.ts";
export function button(label: string, onClick: () => void, cls = ""): HTMLButtonElement {
return h(`button.btn.f${cls ? "." + cls : ""}` as "button", { onclick: onClick, tabindex: -1 }, label);
}
// Dialog: a centred box over everything; Back closes it.
export class Dialog {
el: HTMLElement;
box: HTMLElement;
private onClose?: () => void;
private closed = false;
constructor(title: string, cls = "", onClose?: () => void) {
this.onClose = onClose;
this.box = h("div.dialog-box", h("h2", title));
if (cls) this.box.classList.add(cls);
this.el = h("div.dialog", this.box);
}
add(...children: (Node | string | null)[]): this {
append(this.box, ...children);
return this;
}
// open shows the dialog; called again (content changed), it only moves
// the focus.
open(focusEl?: HTMLElement): this {
if (this.closed) return this;
if (!this.el.isConnected) {
document.getElementById("overlays")!.appendChild(this.el);
pushLayer(this.el, () => this.close());
}
if (focusEl) focus(focusEl);
else focusFirst(this.el);
return this;
}
close(): void {
if (this.closed) return;
this.closed = true;
popLayer(this.el);
this.el.remove();
this.onClose?.();
}
}
export interface Action {
label: string;
run: () => void;
danger?: boolean;
hint?: string;
}
// actionSheet lists what can be done with an item; OK runs one.
export function actionSheet(title: string, actions: Action[]): void {
const d = new Dialog(title, "sheet");
for (const a of actions) {
const b = button(a.label, () => {
d.close();
a.run();
}, a.danger ? "danger" : "");
if (a.hint) b.appendChild(h("span.btn-hint", a.hint));
d.add(b);
}
d.add(button("Cancel", () => d.close(), "ghost"));
d.open();
}
export function confirmDialog(message: string, yes: string, run: () => void, danger = false): void {
const d = new Dialog("Are you sure?");
d.add(h("p.dialog-text", message));
const no = button("Cancel", () => d.close(), "ghost");
d.add(h("div.row-buttons", button(yes, () => {
d.close();
run();
}, danger ? "danger" : ""), no));
d.open(no);
}
// Choice: a value picked from options with left / right (or OK for the
// next one), the TV-friendly dropdown.
export class Choice<V> {
el: HTMLElement & KeyHandling;
private valueEl: HTMLElement;
options: { label: string; value: V }[] = [];
index = 0;
onChange?: (v: V) => void;
constructor(label: string, options: { label: string; value: V }[], value?: V, onChange?: (v: V) => void) {
this.valueEl = h("span.choice-value");
this.el = h("div.choice.f", { tabindex: -1 }, h("span.choice-label", label), h("span.choice-arrows", "‹", this.valueEl, "›")) as HTMLElement & KeyHandling;
this.onChange = onChange;
this.setOptions(options, value);
this.el.onKey = (ev) => {
if (ev.keyCode !== Keys.left && ev.keyCode !== Keys.right) return false;
this.step(ev.keyCode === Keys.left ? -1 : 1);
return true;
};
this.el.addEventListener("click", (ev) => {
const r = this.el.getBoundingClientRect();
const x = (ev as MouseEvent).clientX;
// A pointer click on the left third goes back; OK and other clicks forward.
this.step(x > 0 && x < r.left + r.width / 3 ? -1 : 1);
});
}
get value(): V {
return this.options[this.index]?.value;
}
setOptions(options: { label: string; value: V }[], value?: V): void {
this.options = options;
const i = value === undefined ? -1 : options.findIndex((o) => o.value === value);
this.index = i >= 0 ? i : Math.min(this.index, Math.max(0, options.length - 1));
this.show();
}
private step(d: number): void {
if (this.options.length < 2) return;
this.index = (this.index + d + this.options.length) % this.options.length;
this.show();
this.onChange?.(this.value);
}
private show(): void {
replace(this.valueEl, this.options[this.index]?.label ?? "–");
}
}
// Toggle: a checkbox; OK flips it.
export class Toggle {
el: HTMLElement;
value: boolean;
onChange?: (v: boolean) => void;
constructor(label: string, value: boolean, onChange?: (v: boolean) => void) {
this.value = value;
this.onChange = onChange;
this.el = h("div.toggle.f", { tabindex: -1, onclick: () => this.set(!this.value, true) }, h("span.toggle-box"), h("span", label));
this.set(value, false);
}
set(v: boolean, notify: boolean): void {
this.value = v;
this.el.classList.toggle("on", v);
if (notify) this.onChange?.(v);
}
}
export function textField(label: string, value: string, opts: { secret?: boolean; placeholder?: string; onInput?: (v: string) => void } = {}): { el: HTMLElement; input: HTMLInputElement } {
const input = h("input.f", { type: opts.secret ? "password" : "text", value, placeholder: opts.placeholder ?? "", spellcheck: false, autocomplete: "off" });
if (opts.onInput) input.addEventListener("input", () => opts.onInput!(input.value));
return { el: h("label.field", h("span.field-label", label), input), input };
}
// --- Toasts and the log ---
export type Level = "info" | "ok" | "warn" | "error";
export interface LogEntry {
at: Date;
level: Level;
text: string;
}
export const log: LogEntry[] = [];
const logListeners: (() => void)[] = [];
export function onLog(f: () => void): void {
logListeners.push(f);
}
// toast shows a message for a few seconds and keeps it in the log.
export function toast(text: string, level: Level = "info"): void {
log.push({ at: new Date(), level, text });
if (log.length > 500) log.splice(0, log.length - 500);
for (const f of logListeners) f();
const box = document.getElementById("toasts");
if (!box) return;
const el = h(`div.toast.toast-${level}` as "div", text);
box.appendChild(el);
while (box.children.length > 4) box.firstElementChild!.remove();
setTimeout(() => el.classList.add("gone"), level === "error" ? 7000 : 4000);
setTimeout(() => el.remove(), level === "error" ? 7600 : 4600);
}
export function errorText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
// run executes an action with a "…" toast and a result toast.
export async function run(doing: string, done: string, f: () => Promise<unknown>): Promise<boolean> {
toast(doing + "…");
try {
await f();
toast(done, "ok");
return true;
} catch (e) {
toast(`${doing} failed: ${errorText(e)}`, "error");
return false;
}
}
+74
View File
@@ -0,0 +1,74 @@
// What can be done with a library title, shared by Movies, Series, Library
// and Calendar.
import { app } from "../app.ts";
import type { Movie } from "../api/radarr.ts";
import type { Series } from "../api/sonarr.ts";
import { titleYear } from "../describe.ts";
import { h } from "../ui/dom.ts";
import { actionSheet, button, Dialog, run, Toggle, type Action } from "../ui/widgets.ts";
import { openEpisodes } from "./episodes.ts";
import { openReleases } from "./releases.ts";
export function movieReleases(m: Movie): void {
openReleases("Releases for " + titleYear(m), (signal) => app.radarr.movieReleases(m.id, signal), (r) => app.radarr.grab(r));
}
// movieActions: changed runs after something changed the movie (reload).
export function movieActions(m: Movie, changed: () => void, removed?: () => void): Action[] {
const name = titleYear(m);
return [
{ label: "Search releases", hint: "interactive", run: () => movieReleases(m) },
{ label: "Automatic search", run: () => void run("Searching " + name, "Search started: " + name, () => app.radarr.searchMovie(m.id)) },
{
label: m.monitored ? "Unmonitor" : "Monitor",
run: () => void run(name + ": " + (m.monitored ? "unmonitoring" : "monitoring"), "Done: " + name, () => app.radarr.setMonitored(m.id, !m.monitored)).then((ok) => ok && changed()),
},
{ label: "Remove from library…", danger: true, run: () => removeDialog(name, false, (files, excl) => app.radarr.deleteMovie(m.id, files, excl), removed ?? changed) },
];
}
export function seriesActions(s: Series, changed: () => void, removed?: () => void): Action[] {
const name = titleYear(s);
return [
{ label: "Seasons and episodes", run: () => openEpisodes(s, changed) },
{
label: "Search all missing episodes",
run: () => actionSheet(`Search all missing episodes of ${name}?`, [
{ label: "Search", run: () => void run("Searching " + name, "Search started: " + name, () => app.sonarr.searchSeries(s.id)) },
]),
},
{
label: s.monitored ? "Unmonitor" : "Monitor",
run: () => void run(name + ": " + (s.monitored ? "unmonitoring" : "monitoring"), "Done: " + name, () => app.sonarr.setMonitored(s.id, !s.monitored)).then((ok) => ok && changed()),
},
{ label: "Remove from library…", danger: true, run: () => removeDialog(name, true, (files, excl) => app.sonarr.deleteSeries(s.id, files, excl), removed ?? changed) },
];
}
export function showMovieActions(m: Movie, changed: () => void, removed?: () => void): void {
actionSheet(titleYear(m), movieActions(m, changed, removed));
}
export function showSeriesActions(s: Series, changed: () => void, removed?: () => void): void {
actionSheet(titleYear(s), seriesActions(s, changed, removed));
}
// removeDialog asks before removing; files and the import exclusion are
// separate choices, both off.
function removeDialog(name: string, series: boolean, remove: (files: boolean, exclusion: boolean) => Promise<void>, removed: () => void): void {
const d = new Dialog("Remove " + name);
const files = new Toggle(series ? "Delete all episode files" : "Delete the movie's files", false);
const excl = new Toggle("Do not add again from import lists", false);
const cancel = button("Cancel", () => d.close(), "ghost");
d.add(
h("p.dialog-text", "The title leaves the library. Files stay on disk unless you tick the box."),
files.el,
excl.el,
h("div.row-buttons", button("Remove", () => {
d.close();
void run("Removing " + name, "Removed " + name, () => remove(files.value, excl.value)).then((ok) => ok && removed());
}, "danger"), cancel),
);
d.open(cancel);
}
+70
View File
@@ -0,0 +1,70 @@
// The add dialog: quality profile, root folder, monitoring and an optional
// automatic search, with the defaults from the config.
import { app } from "../app.ts";
import type { QualityProfile, RootFolder } from "../api/arr.ts";
import type { Movie } from "../api/radarr.ts";
import { monitorModes, type Series } from "../api/sonarr.ts";
import { titleYear } from "../describe.ts";
import { formatSize } from "../format.ts";
import { h } from "../ui/dom.ts";
import { button, Choice, Dialog, errorText, toast, Toggle } from "../ui/widgets.ts";
export type AddTarget = { kind: "movie"; movie: Movie } | { kind: "series"; series: Series };
// openAdd asks for the options and adds the title; added gets the new id.
export function openAdd(t: AddTarget, added: (id: number) => void): void {
const client = t.kind === "movie" ? app.radarr : app.sonarr;
const defaults = t.kind === "movie" ? app.cfg.radarr : app.cfg.sonarr;
const name = t.kind === "movie" ? titleYear(t.movie) : titleYear(t.series);
const d = new Dialog("Add " + name);
const loading = h("p.dialog-text", "Loading quality profiles and root folders…");
d.add(loading).open();
Promise.all([client.qualityProfiles(), client.rootFolders()]).then(([profiles, folders]) => {
loading.remove();
build(profiles, folders);
}, (e) => {
loading.textContent = `${client.name}: ${errorText(e)}`;
d.add(button("Close", () => d.close()));
d.open();
});
function build(profiles: QualityProfile[], folders: RootFolder[]): void {
if (!profiles.length || !folders.length) {
d.add(h("p.dialog-text", `${client.name} has no ${profiles.length ? "root folders" : "quality profiles"} set up.`), button("Close", () => d.close()));
d.open();
return;
}
const profile = new Choice("Quality profile", profiles.map((p) => ({ label: p.name, value: p.id })),
profiles.find((p) => p.name === defaults.default_quality_profile)?.id);
const folder = new Choice("Root folder", folders.map((f) => ({ label: `${f.path} (${formatSize(f.freeSpace)} free)`, value: f.path })),
folders.find((f) => f.path === defaults.default_root_folder)?.path);
const monitored = new Toggle("Monitored", true);
const mode = t.kind === "series" ? new Choice("Monitor", monitorModes.map(([label, value]) => ({ label, value })), "all") : null;
const search = new Toggle("Search now", true);
// Radarr/Sonarr skip unmonitored items when searching.
const sync = () => {
const nothing = !monitored.value || mode?.value === "none";
search.el.classList.toggle("f-off", nothing);
if (nothing) search.set(false, false);
};
monitored.onChange = sync;
if (mode) mode.onChange = sync;
const addBtn = button("Add", () => {
addBtn.disabled = true;
const opts = { qualityProfileId: profile.value, rootFolderPath: folder.value, monitored: monitored.value, searchNow: search.value };
const req = t.kind === "movie" ? app.radarr.addMovie(t.movie, opts) : app.sonarr.addSeries(t.series, { ...opts, monitor: mode!.value });
req.then((created) => {
d.close();
toast(`Added ${name}` + (opts.searchNow ? ", searching" : ""), "ok");
added(created?.id ?? 0);
}, (e) => {
addBtn.disabled = false;
toast("Adding failed: " + errorText(e), "error");
});
});
d.add(profile.el, folder.el, monitored.el, mode?.el ?? null, search.el, h("div.row-buttons", addBtn, button("Cancel", () => d.close(), "ghost")));
d.open(addBtn);
}
}
+218
View File
@@ -0,0 +1,218 @@
// Calendar: two weeks of movie releases and airing episodes, by day.
// Ported from internal/ui/calendar.go.
import { app } from "../app.ts";
import type { Movie } from "../api/radarr.ts";
import type { Episode } from "../api/sonarr.ts";
import { episodeCode, movieDetail, titleYear } from "../describe.ts";
import { addDays, dayLabel, isoDay } from "../format.ts";
import { DetailPane, type Detail } from "../ui/detail.ts";
import { h, replace } from "../ui/dom.ts";
import { focus, Keys } from "../ui/focus.ts";
import { ListView, type Row } from "../ui/list.ts";
import { actionSheet, button, errorText, run, Toggle } from "../ui/widgets.ts";
import { movieReleases } from "./actions.ts";
import { openReleases } from "./releases.ts";
import type { Hint, View } from "./view.ts";
const days = 14;
const step = 7;
interface Entry {
key: string;
day: string; // YYYY-MM-DD, local
at: number; // ms, 0 for date-only entries (movie releases)
title: string;
sub: string;
have: boolean;
monitored: boolean;
missing: boolean;
detail: () => Detail;
movie?: Movie;
episode?: Episode;
}
// releaseDay reads a movie release date: midnight UTC meaning "this date",
// taken as written rather than shifted into the local zone.
export function releaseDay(s: string | undefined): string {
return s && /^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : "";
}
export function movieEntries(m: Movie, from: string, to: string, today: string): Entry[] {
const out: Entry[] = [];
for (const [name, date] of [["In cinemas", m.inCinemas], ["Digital release", m.digitalRelease], ["Physical release", m.physicalRelease]] as const) {
const day = releaseDay(date);
if (!day || day < from || day > to) continue;
out.push({
key: `m${m.id}/${name}/${day}`, day, at: 0, title: titleYear(m), sub: name,
have: m.hasFile, monitored: m.monitored, missing: m.monitored && !m.hasFile && day <= today,
detail: () => {
const d = movieDetail(m);
d.lines.push({ text: `Release: ${name}, ${new Date(day + "T12:00:00").toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric" })}` });
return d;
},
movie: m,
});
}
return out;
}
export function episodeEntry(e: Episode, from: string, to: string, now: Date): Entry | null {
let day = "";
let at = 0;
const t = e.airDateUtc ? Date.parse(e.airDateUtc) : NaN;
if (!isNaN(t)) {
at = t;
day = isoDay(new Date(t));
} else if (e.airDate) day = e.airDate;
if (!day || day < from || day > to) return null;
const series = e.series?.title ?? "Unknown series";
const sub = `${episodeCode(e.seasonNumber, e.episodeNumber)} ${e.title}`;
const aired = at ? at < now.getTime() : day < isoDay(now);
return {
key: "e" + e.id, day, at, title: series, sub,
have: e.hasFile, monitored: e.monitored, missing: e.monitored && !e.hasFile && aired,
detail: () => {
const when = at ? new Date(at).toLocaleString("en-GB", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })
: new Date(day + "T12:00:00").toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
return {
title: series,
sub,
facts: [],
lines: [
{ text: "Airs " + when },
e.hasFile ? { text: "Downloaded", cls: "ok" } : !e.monitored ? { text: "Not monitored", cls: "muted" } : { text: "Not downloaded", cls: "warn" },
],
plot: e.overview || e.series?.overview || "",
poster: e.series ? app.sonarr.posterURL(e.series.images, true, true) : "",
ref: e.series ? { kind: "show", tvdb: e.series.tvdbId, imdb: e.series.imdbId } : undefined,
};
},
episode: e,
};
}
export class CalendarView implements View {
el: HTMLElement;
hints: Hint[] = [["ok", "releases and search"], ["red", "week back"], ["green", "week forward"], ["blue", "reload"]];
private offset = 0;
private unmonitored = false;
private range: HTMLElement;
private list: ListView<Entry>;
private detail = new DetailPane();
private errors: string[] = [];
constructor() {
this.range = h("div.cal-range");
const unmon = new Toggle("Unmonitored too", false, (v) => {
this.unmonitored = v;
this.reload();
});
this.list = new ListView<Entry>("calendar", {
rowHeight: 50,
heading: (key) => h("div.cal-day", key.slice(2)),
render: (e) => h("div.cal-row",
h("span.muted", e.at ? new Date(e.at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) : ""),
h(`span.dot${e.monitored ? ".on" : ""}` as "span", e.monitored ? "●" : "○"),
h("span.ellipsis.bold", e.title),
h("span.ellipsis", e.sub),
h(`span.${e.have ? "ok" : e.missing ? "warn" : "muted"}` as "span", e.have ? "✓ downloaded" : e.missing ? "missing" : e.monitored ? "" : "unmonitored"),
),
onSelect: (e) => this.detail.show(e ? e.detail() : null),
onActivate: (e) => this.activate(e),
placeholder: "Loading…",
});
this.el = h("div.view.calendar-view",
h("div.cal-main",
h("div.toolbar.fgroup",
button("◀ Week", () => this.move(-step)), button("Today", () => this.move(-this.offset)), button("Week ▶", () => this.move(step)),
unmon.el, this.range),
this.list.el),
this.detail.el,
);
}
show(): void {
this.reload();
if (!this.el.contains(document.activeElement)) focus(this.list.el);
}
hide(): void {}
keys(code: number): boolean {
switch (code) {
case Keys.red:
this.move(-step);
return true;
case Keys.green:
this.move(step);
return true;
case Keys.blue:
this.reload();
return true;
}
return false;
}
private move(d: number): void {
this.offset += d;
this.reload();
}
private reload(): void {
const now = new Date();
const today = isoDay(now);
const from = isoDay(addDays(now, this.offset));
const to = isoDay(addDays(now, this.offset + days - 1));
replace(this.range, `${new Date(from + "T12:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "short" })} – ${new Date(to + "T12:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}`);
// Radarr's window is by release date; a day on either side catches
// dates that the zone moves.
const qFrom = isoDay(addDays(now, this.offset - 1));
const qTo = isoDay(addDays(now, this.offset + days));
this.errors = [];
const movies = app.radarr.configured()
? app.radarr.calendar(qFrom, qTo, this.unmonitored).then((ms) => ([] as Entry[]).concat(...ms.map((m) => movieEntries(m, from, to, today))), (e) => {
this.errors.push("Radarr: " + errorText(e));
return [] as Entry[];
})
: Promise.resolve([] as Entry[]);
const episodes = app.sonarr.configured()
? app.sonarr.calendar(qFrom, qTo, this.unmonitored).then((es) => es.map((e) => episodeEntry(e, from, to, now)).filter((x): x is Entry => !!x), (e) => {
this.errors.push("Sonarr: " + errorText(e));
return [] as Entry[];
})
: Promise.resolve([] as Entry[]);
Promise.all([movies, episodes]).then(([ms, es]) => {
const all = ms.concat(es).sort((a, b) =>
a.day.localeCompare(b.day) || a.at - b.at || a.title.toLowerCase().localeCompare(b.title.toLowerCase()) || a.sub.localeCompare(b.sub));
const rows: Row<Entry>[] = [];
let lastDay = "";
for (const e of all) {
if (e.day !== lastDay) {
rows.push({ key: "d:" + dayLabel(e.day, today) + (["Today", "Tomorrow", "Yesterday"].includes(dayLabel(e.day, today)) ? " · " + new Date(e.day + "T12:00:00").toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }) : "") });
lastDay = e.day;
}
rows.push({ item: e, key: e.key });
}
this.list.setPlaceholder(this.errors.join(" · ") || "Nothing in these two weeks.");
this.list.setRows(rows, false);
});
}
private activate(e: Entry): void {
if (e.movie) {
const m = e.movie;
actionSheet(titleYear(m), [
{ label: "Search releases", run: () => movieReleases(m) },
{ label: "Automatic search", run: () => void run("Searching " + m.title, "Search started: " + m.title, () => app.radarr.searchMovie(m.id)) },
]);
return;
}
const ep = e.episode!;
const title = `${e.title} ${episodeCode(ep.seasonNumber, ep.episodeNumber)}`;
actionSheet(title, [
{ label: "Search releases", run: () => openReleases("Releases for " + title, (sig) => app.sonarr.episodeReleases(ep.id, sig), (r) => app.sonarr.grab(r)) },
{ label: "Automatic search", run: () => void run("Searching " + title, "Search started: " + title, () => app.sonarr.searchEpisode(ep.id)) },
]);
}
}
+239
View File
@@ -0,0 +1,239 @@
// Downloads: the SABnzbd queue and history and the Radarr/Sonarr queues,
// refreshed every few seconds while visible; pause / resume, delete, retry,
// remove and blocklist. Also the background watch that announces finished
// downloads. Ported from internal/ui/downloads.go and watch.go.
import { app } from "../app.ts";
import { queueName, queueProblem, queueProgress, type ArrClient, type QueueItem } from "../api/arr.ts";
import type { HistorySlot, Queue, Slot } from "../api/sabnzbd.ts";
import { refreshSeconds } from "../config.ts";
import { capitalize, formatAge, formatSpeed } from "../format.ts";
import { h, replace } from "../ui/dom.ts";
import { focus, Keys } from "../ui/focus.ts";
import { ListView } from "../ui/list.ts";
import { actionSheet, button, confirmDialog, errorText, run, toast } from "../ui/widgets.ts";
import type { Hint, View } from "./view.ts";
interface ArrRow {
source: ArrClient;
item: QueueItem;
}
const stateLabels: Record<string, string> = {
downloading: "Downloading",
importPending: "Import pending",
importBlocked: "Import blocked",
importing: "Importing",
imported: "Imported",
failedPending: "Failed (pending)",
failed: "Failed",
ignored: "Ignored",
};
export function arrState(q: QueueItem): [string, string] {
const label = stateLabels[q.trackedDownloadState ?? ""] || capitalize(q.status ?? "");
const st = q.trackedDownloadState;
if (q.trackedDownloadStatus === "error" || st === "failed" || st === "failedPending" || st === "importBlocked") return [label, "bad"];
if (q.trackedDownloadStatus === "warning") return [label, "warn"];
return [label, ""];
}
function bar(pct: number): HTMLElement {
return h("span.bar", h("span.bar-fill", { style: `width:${Math.max(0, Math.min(100, pct))}%` }));
}
export class DownloadsView implements View {
el: HTMLElement;
hints: Hint[] = [["ok", "what to do"], ["yellow", "pause / resume SABnzbd"], ["blue", "refresh"]];
private sabHead: HTMLElement;
private pauseBtn: HTMLButtonElement;
private queue: ListView<Slot>;
private arr: ListView<ArrRow>;
private history: ListView<HistorySlot>;
private paused = false;
private timer = 0;
constructor() {
this.sabHead = h("div.sab-head");
this.pauseBtn = button("Pause", () => this.togglePause());
this.queue = new ListView<Slot>("dl-queue", {
rowHeight: 50,
header: h("div.dl-row.list-cols", h("span", "SABnzbd queue"), h("span", "Status"), h("span", "Progress"), h("span.num", "Left"), h("span.num", "Time")),
render: (s) => h("div.dl-row",
h("span.ellipsis", s.filename),
h(`span.${s.status === "Downloading" ? "dl" : "muted"}` as "span", s.status),
h("span.progress", bar(s.percentage), h("span.num", Math.round(s.percentage) + "%")),
h("span.num", s.sizeleft),
h("span.num", s.status === "Downloading" ? s.timeleft : ""),
),
onActivate: (s) => this.slotActions(s),
placeholder: "Loading…",
});
this.arr = new ListView<ArrRow>("dl-arr", {
rowHeight: 50,
header: h("div.dl-row.list-cols", h("span", "Radarr / Sonarr queue"), h("span", "State"), h("span", "Progress"), h("span", "Client"), h("span.num", "")),
render: (r) => {
const [label, cls] = arrState(r.item);
const problem = queueProblem(r.item);
return h("div.dl-row",
h("span.ellipsis", queueName(r.item), problem ? h("span.dl-problem", " — " + problem) : null),
h(`span.${cls || "plain"}` as "span", label),
h("span.progress", bar(queueProgress(r.item)), h("span.num", Math.round(queueProgress(r.item)) + "%")),
h("span.ellipsis.muted", r.item.downloadClient ?? ""),
h("span"),
);
},
onActivate: (r) => this.arrActions(r),
placeholder: "Loading…",
});
this.history = new ListView<HistorySlot>("dl-history", {
rowHeight: 50,
header: h("div.dl-row.list-cols", h("span", "SABnzbd history"), h("span", "Status"), h("span", ""), h("span.num", "Size"), h("span.num", "When")),
render: (s) => h("div.dl-row",
h("span.ellipsis", s.name, s.fail_message ? h("span.dl-problem", " — " + s.fail_message) : null),
h(`span.${s.status === "Failed" ? "bad" : s.status === "Completed" ? "ok" : "plain"}` as "span", s.status),
h("span"),
h("span.num", s.size),
h("span.num.muted", formatAge(s.completed)),
),
onActivate: (s) => this.historyActions(s),
placeholder: "Loading…",
});
this.el = h("div.view.downloads-view",
h("div.toolbar.fgroup", this.pauseBtn, this.sabHead),
this.queue.el, this.arr.el, this.history.el,
);
}
show(): void {
this.refresh();
this.timer = window.setInterval(() => this.refresh(), refreshSeconds(app.cfg) * 1000);
if (!this.el.contains(document.activeElement)) focus(this.queue.el);
}
hide(): void {
clearInterval(this.timer);
}
keys(code: number): boolean {
if (code === Keys.yellow) {
this.togglePause();
return true;
}
if (code === Keys.blue) {
this.refresh();
return true;
}
return false;
}
refresh(): void {
if (app.sab.configured()) {
app.sab.queue().then((q) => {
app.setStatus("SABnzbd", null);
this.showQueue(q);
}, (e) => {
app.setStatus("SABnzbd", e);
this.queue.setPlaceholder("SABnzbd: " + errorText(e));
this.queue.setItems([], (s) => s.nzo_id);
replace(this.sabHead);
});
app.sab.history(30).then((hs) => {
this.history.setPlaceholder("No finished downloads.");
this.history.setItems(hs, (s) => s.nzo_id);
}, (e) => {
this.history.setPlaceholder("SABnzbd: " + errorText(e));
this.history.setItems([], (s) => s.nzo_id);
});
} else {
this.queue.setPlaceholder("SABnzbd is not configured.");
this.history.setPlaceholder("");
this.queue.setItems([], (s) => s.nzo_id);
}
const sources = [app.radarr, app.sonarr].filter((c) => c.configured());
Promise.all(sources.map((c) => (c === app.radarr ? app.radarr.movieQueue() : app.sonarr.seriesQueue()).then(
(items) => items.map((item) => ({ source: c as ArrClient, item })),
(e) => {
this.arr.setPlaceholder(`${c.name}: ${errorText(e)}`);
return [] as ArrRow[];
},
))).then((parts) => {
const rows = ([] as ArrRow[]).concat(...parts);
if (rows.length) this.arr.setPlaceholder("");
else if (!this.arr.length) this.arr.setPlaceholder("Nothing in the Radarr or Sonarr queue.");
this.arr.setItems(rows, (r) => r.source.name + r.item.id);
});
}
private showQueue(q: Queue): void {
this.paused = q.paused;
this.pauseBtn.textContent = q.paused ? "Resume" : "Pause";
const parts = [q.paused ? "Paused" : q.status || "Idle"];
if (!q.paused && q.kbpersec > 0) parts.push(formatSpeed(q.kbpersec));
if (q.slots.length) parts.push(`${q.sizeleft} left`, q.timeleft);
replace(this.sabHead, h(`span.${q.paused ? "warn" : "plain"}` as "span", parts.join(" · ")));
this.queue.setPlaceholder("The SABnzbd queue is empty.");
this.queue.setItems(q.slots, (s) => s.nzo_id);
}
private togglePause(): void {
const resume = this.paused;
void run(resume ? "Resuming SABnzbd" : "Pausing SABnzbd", resume ? "SABnzbd resumed" : "SABnzbd paused",
() => (resume ? app.sab.resume() : app.sab.pause())).then(() => this.refresh());
}
private slotActions(s: Slot): void {
actionSheet(s.filename, [
{ label: "Delete from the queue", danger: true, run: () => confirmDialog(`Delete ${s.filename} from the SABnzbd queue?`, "Delete", () => {
void run("Deleting", "Deleted " + s.filename, () => app.sab.deleteJob(s.nzo_id, false)).then(() => this.refresh());
}, true) },
]);
}
private historyActions(s: HistorySlot): void {
const actions = [
{ label: "Remove from the history", danger: true, run: () => confirmDialog(`Remove ${s.name} from the history?`, "Remove", () => {
void run("Removing", "Removed " + s.name, () => app.sab.deleteHistory(s.nzo_id, false)).then(() => this.refresh());
}, true) },
];
if (s.status === "Failed") actions.unshift({ label: "Retry", danger: false, run: () => void run("Retrying " + s.name, "Queued again: " + s.name, () => app.sab.retry(s.nzo_id)).then(() => this.refresh()) });
actionSheet(s.name, actions);
}
private arrActions(r: ArrRow): void {
const name = queueName(r.item);
const remove = (blocklist: boolean) => confirmDialog(
blocklist ? `Remove ${name} from the ${r.source.name} queue and the download client, and blocklist the release? ${r.source.name} then searches for another one.` : `Remove ${name} from the ${r.source.name} queue and the download client?`,
blocklist ? "Remove and blocklist" : "Remove",
() => void run("Removing " + name, "Removed " + name, () => r.source.removeQueueItem(r.item.id, blocklist)).then(() => this.refresh()),
true,
);
actionSheet(name, [
{ label: "Remove", danger: true, run: () => remove(false) },
{ label: "Remove and blocklist", danger: true, run: () => remove(true) },
]);
}
}
// watchDownloads checks SABnzbd's history every 15 s and announces
// downloads that finished or failed since the app started.
export function watchDownloads(): void {
const seen = new Map<string, string>();
let baseline = true;
const poll = () => {
if (!app.sab.configured() || app.cfg.ui.notify_downloads === false) return;
app.sab.history(30).then((slots) => {
for (const s of slots) {
const prev = seen.get(s.nzo_id);
seen.set(s.nzo_id, s.status);
const final = s.status === "Completed" || s.status === "Failed";
if (baseline || !final || prev === s.status) continue;
if (s.status === "Failed") toast("Download failed: " + s.name + (s.fail_message ? ` (${s.fail_message.slice(0, 60)})` : ""), "error");
else toast("Download finished: " + s.name, "ok");
}
baseline = false;
}, () => undefined);
};
poll();
setInterval(poll, 15_000);
}
+197
View File
@@ -0,0 +1,197 @@
// A library series' seasons and episodes, with download marks, release and
// automatic searches and monitoring. Ported from internal/ui/episodes.go.
import { app, openPage, pageHeader } from "../app.ts";
import { queueProgress } from "../api/arr.ts";
import type { Episode, Series } from "../api/sonarr.ts";
import { dlLine, episodeCode, monitoredLine, seriesDetail, titleYear, type DlInfo } from "../describe.ts";
import { isoDay } from "../format.ts";
import { DetailPane, type Line } from "../ui/detail.ts";
import { h, replace } from "../ui/dom.ts";
import { focus } from "../ui/focus.ts";
import { ListView, type Row } from "../ui/list.ts";
import { actionSheet, errorText, run } from "../ui/widgets.ts";
import { openReleases } from "./releases.ts";
import { episodeStatus, seasonLabel, summarizeSeasons } from "../seasons.ts";
interface EpRow {
season: number;
ep?: Episode;
}
export function openEpisodes(start: Series, changed?: () => void): void {
let series = start;
let episodes: Episode[] = [];
let loaded = false;
const expanded = new Set<number>();
let downloading = new Map<number, DlInfo>();
const detail = new DetailPane();
const header = h("div.page-status");
const list = new ListView<EpRow>("episodes", {
rowHeight: 52,
render: (r) => (r.ep ? episodeRow(r.ep) : seasonRow(r.season)),
onSelect: (r) => showDetail(r),
onActivate: (r) => activate(r),
placeholder: "Loading…",
});
const el = h("div.episodes-page", pageHeader(titleYear(series), header), h("div.split", list.el, detail.el), h("div.hints", "OK: expand, releases and more · Back return"));
const page = openPage(el, () => clearInterval(watch));
focus(list.el);
const watch = window.setInterval(loadQueue, 10_000);
reload();
loadQueue();
function reload(): void {
Promise.all([app.sonarr.getSeries(series.id), app.sonarr.episodes(series.id)]).then(([s, eps]) => {
if (!loaded) {
const newest = summarizeSeasons(eps)[0];
if (newest) expanded.add(newest.number); // open the newest season first
}
series = s;
episodes = eps;
loaded = true;
app.setStatus("Sonarr", null);
render();
}, (e) => {
app.setStatus("Sonarr", e);
list.setPlaceholder("Sonarr: " + errorText(e));
});
}
// loadQueue marks the episodes in the download queue; when one has left
// it, the episodes are loaded again so its status follows.
function loadQueue(): void {
app.sonarr.seriesQueue().then((items) => {
const now = new Map<number, DlInfo>();
for (const q of items) {
if (q.seriesId === series.id && q.episodeId && !now.has(q.episodeId)) {
now.set(q.episodeId, { count: 1, progress: queueProgress(q), timeLeft: q.timeleft ?? "" });
}
}
const left = [...downloading.keys()].some((id) => !now.has(id));
downloading = now;
if (left) reload();
else render();
}, () => undefined);
}
function seasonMonitored(n: number): boolean {
return series.seasons?.find((s) => s.seasonNumber === n)?.monitored ?? false;
}
function seasonDownloads(n: number): number {
return episodes.filter((e) => e.seasonNumber === n && downloading.has(e.id)).length;
}
function mark(monitored: boolean, dl: boolean): HTMLElement {
return h(`span.dot${dl ? ".dl" : monitored ? ".on" : ""}` as "span", monitored ? "●" : "○");
}
function seasonRow(n: number): HTMLElement {
const s = summarizeSeasons(episodes).find((x) => x.number === n) ?? { number: n, have: 0, total: 0 };
const cls = s.have >= s.total ? "ok" : seasonMonitored(n) ? "warn" : "muted";
return h("div.ep-row.season",
mark(seasonMonitored(n), seasonDownloads(n) > 0),
h("span", expanded.has(n) ? "▼" : "▶"),
h("span.bold", seasonLabel(n)),
h("span"),
h(`span.${cls}` as "span", `${s.have}/${s.total}`),
);
}
function episodeRow(e: Episode): HTMLElement {
const dl = downloading.get(e.id);
let [status, cls] = episodeStatus(e, isoDay(new Date()));
if (dl && !e.hasFile) [status, cls] = [`downloading ${Math.round(dl.progress)}%`, "dl"];
return h("div.ep-row",
mark(e.monitored, !!dl),
h("span.muted", "E" + String(e.episodeNumber).padStart(2, "0")),
h("span.ellipsis", e.title),
h("span.muted", e.airDate ?? ""),
h(`span.${cls}` as "span", status),
);
}
function render(): void {
const rows: Row<EpRow>[] = [];
for (const s of summarizeSeasons(episodes)) {
rows.push({ item: { season: s.number }, key: "s" + s.number });
if (!expanded.has(s.number)) continue;
for (const e of episodes.filter((x) => x.seasonNumber === s.number).sort((a, b) => a.episodeNumber - b.episodeNumber)) {
rows.push({ item: { season: s.number, ep: e }, key: "e" + e.id });
}
}
if (loaded) list.setPlaceholder("Sonarr has no episodes for this series");
list.setRows(rows);
const st = series.statistics;
replace(header, `${series.monitored ? "monitored" : "unmonitored"} · ${st?.episodeFileCount ?? 0}/${st?.episodeCount ?? 0} episodes`);
}
function seasonPoster(n: number): string {
const img = series.seasons?.find((s) => s.seasonNumber === n)?.images?.find((i) => i.coverType === "poster" && i.remoteUrl);
return img?.remoteUrl ?? app.sonarr.posterURL(series.images, true, true);
}
function showDetail(r: EpRow | undefined): void {
const d = seriesDetail(series);
if (!r) return detail.show(d);
const lines: Line[] = [];
const s = summarizeSeasons(episodes).find((x) => x.number === r.season);
if (!r.ep) {
d.sub = seasonLabel(r.season);
if (s) lines.push({ text: `${s.have} of ${s.total} episodes downloaded` });
const n = seasonDownloads(r.season);
if (n === 1) lines.push({ text: "Downloading 1 episode", cls: "dl" });
else if (n > 1) lines.push({ text: `Downloading ${n} episodes`, cls: "dl" });
lines.push(monitoredLine(seasonMonitored(r.season)));
} else {
const e = r.ep;
d.sub = `${episodeCode(e.seasonNumber, e.episodeNumber)} ${e.title}`;
if (e.airDate) lines.push({ text: "Aired: " + e.airDate });
const [status, cls] = episodeStatus(e, isoDay(new Date()));
lines.push({ text: "Status: " + status, cls });
const dl = downloading.get(e.id);
if (dl) lines.push(dlLine(dl));
lines.push(monitoredLine(e.monitored));
if (e.overview) d.plot = e.overview;
}
d.lines = [...d.lines.filter((l) => l.text.startsWith("Network")), ...lines];
d.poster = seasonPoster(r.season);
detail.show(d);
}
function done(): void {
reload();
changed?.();
}
function activate(r: EpRow): void {
const id = series.id;
if (!r.ep) {
const n = r.season;
const want = !seasonMonitored(n);
actionSheet(seasonLabel(n), [
{ label: expanded.has(n) ? "Collapse" : "Expand", run: () => {
if (expanded.has(n)) expanded.delete(n);
else expanded.add(n);
render();
} },
{ label: "Search releases", hint: "season packs and episodes", run: () => openReleases(`Releases for ${series.title}, ${seasonLabel(n)}`, (sig) => app.sonarr.seasonReleases(id, n, sig), (rel) => app.sonarr.grab(rel)) },
{ label: "Automatic search", run: () => void run("Searching " + seasonLabel(n), "Search started: " + seasonLabel(n), () => app.sonarr.searchSeason(id, n)) },
{ label: want ? "Monitor the season" : "Unmonitor the season", run: () => void run(`${seasonLabel(n)}: ${want ? "monitoring" : "unmonitoring"}`, "Done", () => app.sonarr.setSeasonMonitored(id, n, want)).then((ok) => ok && done()) },
]);
return;
}
const e = r.ep;
const code = episodeCode(e.seasonNumber, e.episodeNumber);
actionSheet(`${code} ${e.title}`, [
{ label: "Search releases", run: () => openReleases(`Releases for ${series.title} ${code}`, (sig) => app.sonarr.episodeReleases(e.id, sig), (rel) => app.sonarr.grab(rel)) },
{ label: "Automatic search", run: () => void run("Searching " + code, "Search started: " + code, () => app.sonarr.searchEpisode(e.id)) },
{ label: e.monitored ? "Unmonitor" : "Monitor", run: () => void run(`${code}: ${e.monitored ? "unmonitoring" : "monitoring"}`, "Done", () => app.sonarr.setEpisodesMonitored([e.id], !e.monitored)).then((ok) => ok && done()) },
]);
}
void page;
}
+49
View File
@@ -0,0 +1,49 @@
// The property filter dialog: quality, language, codec, HDR and audio. Every
// change applies at once (the list behind follows); Cancel restores the
// filter it started with. Options that would leave nothing are not offered.
import { facets, listedValues, type AttrFilter, type Pool } from "../media.ts";
import { h } from "../ui/dom.ts";
import { button, Choice, Dialog } from "../ui/widgets.ts";
export function openFilterDialog(cur: AttrFilter, pool: () => Pool, set: (f: AttrFilter) => void): { refresh: () => void } {
const orig = { ...cur };
let state = { ...cur };
let done = false;
const d = new Dialog("Filter by file properties", "filter", () => {
if (!done) set(orig); // Back: undo
});
const choices: Choice<string>[] = [];
const refresh = () => {
const p = pool();
facets.forEach((fc, i) => {
const vals = listedValues(fc, state, p);
choices[i].setOptions(vals.map((v) => ({ label: v || "any", value: v })), state[fc.key]);
});
};
for (const fc of facets) {
const c = new Choice<string>(fc.label, [], undefined, (v) => {
state = { ...state, [fc.key]: v };
set(state);
refresh();
});
choices.push(c);
d.add(c.el);
}
refresh();
d.add(h("div.row-buttons",
button("Done", () => {
done = true;
d.close();
}),
button("Clear", () => {
state = { res: "", lang: "", codec: "", hdr: "", audio: "" };
set(state);
refresh();
}, "ghost"),
button("Cancel", () => d.close(), "ghost"),
));
d.add(h("p.dialog-hint", "◀ ▶ change a value · the list behind follows along"));
d.open();
return { refresh };
}
+349
View File
@@ -0,0 +1,349 @@
// Library: the movies of Radarr and the series of Sonarr with their status,
// each with its own status, text and file-property filters, and blue marks
// for titles in the download queue. Ported from internal/ui/library.go.
import { app } from "../app.ts";
import { queueProgress, type QueueItem } from "../api/arr.ts";
import type { Movie } from "../api/radarr.ts";
import type { Series } from "../api/sonarr.ts";
import { dlLine, monitoredLine, movieDetail, seriesDetail, sizeLine, titleYear, type DlInfo } from "../describe.ts";
import { formatSize } from "../format.ts";
import { fileAttrs, filterActive, filterLabel, matchAny, noFilter, type AttrFilter, type FileAttrs, type Pool } from "../media.ts";
import { DetailPane, type Detail } from "../ui/detail.ts";
import { h, replace } from "../ui/dom.ts";
import { focus, Keys } from "../ui/focus.ts";
import { ListView } from "../ui/list.ts";
import { button, Choice, errorText, textField } from "../ui/widgets.ts";
import { showMovieActions, showSeriesActions } from "./actions.ts";
import { openFilterDialog } from "./filter.ts";
import type { Hint, View } from "./view.ts";
type Kind = "movies" | "series";
interface Item {
id: number;
title: string;
label: string;
monitored: boolean;
have: boolean;
missing: boolean;
status: [string, string]; // text, class
size: string;
detail: () => Detail;
movie?: Movie;
series?: Series;
attrs: FileAttrs[];
attrsKnown: boolean;
}
const statusFilters: [string, (i: Item) => boolean][] = [
["all", () => true],
["missing", (i) => i.missing],
["downloaded", (i) => i.have],
["monitored", (i) => i.monitored],
["unmonitored", (i) => !i.monitored],
];
function movieItem(m: Movie): Item {
let status: [string, string];
if (m.hasFile) status = ["✓ " + (m.movieFile?.quality?.quality?.name ?? ""), "ok"];
else if (!m.monitored) status = ["unmonitored", "muted"];
else if (!m.isAvailable) status = ["not released", "muted"];
else status = ["missing", "warn"];
return {
id: m.id, title: m.title, label: titleYear(m), monitored: m.monitored, have: m.hasFile,
missing: m.monitored && !m.hasFile && m.isAvailable, status, size: m.sizeOnDisk > 0 ? formatSize(m.sizeOnDisk) : "",
detail: () => {
const d = movieDetail(m);
d.lines.push(monitoredLine(m.monitored));
const s = sizeLine(m.sizeOnDisk);
if (s) d.lines.push(s);
if (m.path) d.lines.push({ text: "Path: " + m.path, cls: "muted" });
return d;
},
movie: m, attrs: m.hasFile && m.movieFile ? [fileAttrs(m.movieFile)] : [], attrsKnown: true,
};
}
function seriesItem(s: Series): Item {
const st = s.statistics ?? { episodeFileCount: 0, episodeCount: 0, sizeOnDisk: 0 };
const counts = `${st.episodeFileCount}/${st.episodeCount}`;
let status: [string, string];
if (!s.monitored) status = [counts + " unmonitored", "muted"];
else if (st.episodeFileCount >= st.episodeCount) status = [counts, "ok"];
else status = [counts, "warn"];
return {
id: s.id, title: s.title, label: titleYear(s), monitored: s.monitored, have: st.episodeFileCount > 0,
missing: s.monitored && st.episodeFileCount < st.episodeCount, status, size: st.sizeOnDisk > 0 ? formatSize(st.sizeOnDisk) : "",
detail: () => {
const d = seriesDetail(s);
d.lines.push(monitoredLine(s.monitored));
const sz = sizeLine(st.sizeOnDisk);
if (sz) d.lines.push(sz);
return d;
},
series: s, attrs: [], attrsKnown: st.episodeFileCount === 0,
};
}
function queueByTitle(items: QueueItem[], series: boolean): Map<number, DlInfo> {
const out = new Map<number, DlInfo>();
for (const q of items) {
const id = series ? q.seriesId : q.movieId;
if (!id) continue;
const d = out.get(id);
if (d) d.count++;
else out.set(id, { count: 1, progress: queueProgress(q), timeLeft: q.timeleft ?? "" });
}
return out;
}
interface Pane {
kind: Kind;
all: Item[];
loaded: boolean;
error: string;
status: number;
text: string;
attr: AttrFilter;
}
export class LibraryView implements View {
el: HTMLElement;
hints: Hint[] = [["ok", "what to do"], ["red", "movies / series"], ["yellow", "filter by file properties"], ["blue", "reload"]];
private panes: Record<Kind, Pane> = {
movies: { kind: "movies", all: [], loaded: false, error: "", status: 0, text: "", attr: noFilter() },
series: { kind: "series", all: [], loaded: false, error: "", status: 0, text: "", attr: noFilter() },
};
private cur: Kind = "movies";
private tabs: Record<Kind, HTMLButtonElement>;
private statusChoice: Choice<number>;
private textInput: HTMLInputElement;
private filterBtn: HTMLButtonElement;
private count: HTMLElement;
private list: ListView<Item>;
private detail = new DetailPane();
private downloading: Record<Kind, Map<number, DlInfo>> = { movies: new Map(), series: new Map() };
private watch = 0;
private seriesFiles = new Map<number, { count: number; size: number; attrs: FileAttrs[] }>();
private filesPending = 0;
private dialog: { refresh: () => void } | null = null;
constructor() {
this.tabs = {
movies: button("Movies", () => this.switchTo("movies"), "tab"),
series: button("Series", () => this.switchTo("series"), "tab"),
};
this.statusChoice = new Choice("Show", statusFilters.map(([name], i) => ({ label: name, value: i })), 0, (v) => {
this.pane().status = v;
this.render();
});
const text = textField("", "", { placeholder: "Filter titles…", onInput: (v) => {
this.pane().text = v;
this.render();
} });
this.textInput = text.input;
this.textInput.addEventListener("keydown", (ev) => {
if (ev.keyCode === Keys.enter) focus(this.list.el);
});
this.filterBtn = button("File properties…", () => this.openFilter());
this.count = h("div.lib-count");
this.list = new ListView<Item>("library", {
rowHeight: 50,
header: h("div.lib-row.list-cols", h("span"), h("span", "Title"), h("span", "Status"), h("span.num", "Size")),
render: (it) => {
const dl = this.downloading[this.cur].get(it.id);
return h("div.lib-row",
h(`span.dot${dl ? ".dl" : it.monitored ? ".on" : ""}` as "span", it.monitored ? "●" : "○"),
h("span.ellipsis", it.label),
h(`span.ellipsis.${it.status[1]}` as "span", it.status[0]),
h("span.num", it.size),
);
},
onSelect: (it) => this.showDetail(it),
onActivate: (it) => this.activate(it),
placeholder: "Loading…",
});
this.el = h("div.view.library-view",
h("div.lib-main",
h("div.toolbar.fgroup", this.tabs.movies, this.tabs.series, this.statusChoice.el, text.el, this.filterBtn, this.count),
this.list.el),
this.detail.el,
);
app.afterGrab.push(() => this.loadQueue());
app.onConfig.push(() => {
this.panes.movies.loaded = this.panes.series.loaded = false;
if (this.el.isConnected) this.reload();
});
}
private pane(): Pane {
return this.panes[this.cur];
}
show(): void {
this.syncControls();
this.reload();
this.loadQueue();
this.watch = window.setInterval(() => this.loadQueue(), 10_000);
if (!this.el.contains(document.activeElement)) focus(this.list.el);
}
hide(): void {
clearInterval(this.watch);
}
keys(code: number): boolean {
switch (code) {
case Keys.red:
this.switchTo(this.cur === "movies" ? "series" : "movies");
return true;
case Keys.yellow:
this.openFilter();
return true;
case Keys.blue:
this.reload();
return true;
}
return false;
}
private switchTo(k: Kind): void {
this.cur = k;
this.syncControls();
this.render();
if (!this.pane().loaded) this.reload();
}
private syncControls(): void {
const p = this.pane();
this.tabs.movies.classList.toggle("on", this.cur === "movies");
this.tabs.series.classList.toggle("on", this.cur === "series");
this.statusChoice.setOptions(statusFilters.map(([name], i) => ({ label: name, value: i })), p.status);
this.textInput.value = p.text;
}
reload(): void {
for (const kind of ["movies", "series"] as Kind[]) {
const p = this.panes[kind];
const load: Promise<Item[]> = kind === "movies"
? app.radarr.movies().then((ms) => ms.map(movieItem))
: app.sonarr.allSeries().then((ss) => ss.map(seriesItem));
load.then((items) => {
p.all = items.map((it) => this.withCachedFiles(it));
p.loaded = true;
p.error = "";
app.setStatus(kind === "movies" ? "Radarr" : "Sonarr", null);
if (kind === this.cur) this.render();
if (filterActive(this.panes.series.attr) && kind === "series") this.loadSeriesFiles();
}, (e) => {
p.error = errorText(e);
p.loaded = true;
app.setStatus(kind === "movies" ? "Radarr" : "Sonarr", e);
if (kind === this.cur) this.render();
});
}
}
private loadQueue(): void {
Promise.all([app.radarr.movieQueue().catch(() => null), app.sonarr.seriesQueue().catch(() => null)]).then(([m, s]) => {
if (m) this.downloading.movies = queueByTitle(m, false);
if (s) this.downloading.series = queueByTitle(s, true);
if (m || s) {
this.list.redraw();
this.showDetail(this.list.selected);
}
});
}
private shown(p: Pane, status = p.status, attr = p.attr): Item[] {
const text = p.text.trim().toLowerCase();
return p.all
.filter((it) => statusFilters[status][1](it) && it.title.toLowerCase().includes(text) && (!filterActive(attr) || matchAny(attr, it.attrs)))
.sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
}
private render(): void {
const p = this.pane();
const items = this.shown(p);
if (p.error) this.list.setPlaceholder((this.cur === "movies" ? "Radarr: " : "Sonarr: ") + p.error);
else if (!p.loaded) this.list.setPlaceholder("Loading…");
else if (this.filesPending && filterActive(p.attr)) this.list.setPlaceholder("Reading the series' files…");
else this.list.setPlaceholder("Nothing matches the filters.");
this.list.setItems(items, (it) => String(it.id));
replace(this.count, `${items.length} of ${p.all.length}`);
this.filterBtn.textContent = filterActive(p.attr) ? "Files: " + filterLabel(p.attr) : "File properties…";
this.dialog?.refresh();
}
private showDetail(it: Item | undefined): void {
if (!it) return this.detail.show(null);
const d = it.detail();
const dl = this.downloading[this.cur].get(it.id);
if (dl) d.lines.splice(d.lines.findIndex((l) => l.text.startsWith("Monitored")), 0, dlLine(dl));
this.detail.show(d);
}
private activate(it: Item): void {
const reload = () => this.reload();
if (it.movie) showMovieActions(it.movie, reload);
else if (it.series) showSeriesActions(it.series, reload);
}
private openFilter(): void {
const p = this.pane();
if (p.kind === "series") this.loadSeriesFiles();
this.dialog = openFilterDialog(p.attr, () => this.pool(p), (f) => {
p.attr = f;
this.render();
});
// The dialog's refresh stays valid until it closes; a stale one is harmless.
}
// pool: the files of the items that pass the other filters.
private pool(p: Pane): Pool {
const items = this.shown(p, p.status, noFilter());
return { files: items.map((it) => it.attrs), unknown: items.some((it) => !it.attrsKnown) };
}
private withCachedFiles(it: Item): Item {
const st = it.series?.statistics;
const cached = it.series && this.seriesFiles.get(it.id);
if (cached && st && cached.count === st.episodeFileCount && cached.size === st.sizeOnDisk) {
return { ...it, attrs: cached.attrs, attrsKnown: true };
}
return it;
}
// loadSeriesFiles reads the files of every series whose properties are not
// known yet (four at a time), for the property filter.
private loadSeriesFiles(): void {
const todo = this.panes.series.all.filter((it) => !it.attrsKnown);
if (!todo.length || this.filesPending) return;
this.filesPending = todo.length;
let next = 0;
const worker = async () => {
while (next < todo.length) {
const it = todo[next++];
try {
const files = await app.sonarr.episodeFiles(it.id);
const attrs = files.map(fileAttrs);
const st = it.series!.statistics!;
this.seriesFiles.set(it.id, { count: st.episodeFileCount, size: st.sizeOnDisk, attrs });
const cur = this.panes.series.all.find((x) => x.id === it.id);
if (cur) {
cur.attrs = attrs;
cur.attrsKnown = true;
}
} catch {
/* stays unknown */
}
this.filesPending--;
}
};
Promise.all([worker(), worker(), worker(), worker()]).then(() => {
this.filesPending = 0;
if (this.cur === "series") this.render();
});
}
}
+43
View File
@@ -0,0 +1,43 @@
// Log: everything the toasts said, newest first.
import { h } from "../ui/dom.ts";
import { focus, Keys } from "../ui/focus.ts";
import { ListView } from "../ui/list.ts";
import { log, onLog, type LogEntry } from "../ui/widgets.ts";
import type { Hint, View } from "./view.ts";
export class LogView implements View {
el: HTMLElement;
hints: Hint[] = [["blue", "clear the log"]];
private list: ListView<LogEntry>;
constructor() {
this.list = new ListView<LogEntry>("log", {
rowHeight: 44,
render: (e) => h("div.log-row", h("span.muted", e.at.toLocaleTimeString("en-GB")), h(`span.ellipsis.${e.level === "error" ? "bad" : e.level === "warn" ? "warn" : e.level === "ok" ? "ok" : "plain"}` as "span", e.text)),
placeholder: "Nothing logged yet.",
});
this.el = h("div.view.log-view", this.list.el);
onLog(() => {
if (this.el.isConnected) this.render();
});
}
show(): void {
this.render();
focus(this.list.el);
}
hide(): void {}
keys(code: number): boolean {
if (code !== Keys.blue) return false;
log.length = 0;
this.render();
return true;
}
private render(): void {
this.list.setItems(log.slice().reverse(), (e) => e.at.getTime() + e.text);
}
}
+126
View File
@@ -0,0 +1,126 @@
// The interactive release search: indexer results with approval and
// rejection reasons, sort, filters, and grab. Ported from internal/ui/releases.go.
import { app, openPage, pageHeader } from "../app.ts";
import type { Release } from "../api/arr.ts";
import { isAbort } from "../api/http.ts";
import { formatReleaseAge, formatSize } from "../format.ts";
import { filterActive, filterLabel, isRejected, noFilter, releaseAttrs, visibleReleases, type Pool, type SortMode } from "../media.ts";
import { h, replace } from "../ui/dom.ts";
import { ListView } from "../ui/list.ts";
import { button, Choice, confirmDialog, errorText, toast, Toggle } from "../ui/widgets.ts";
import { openFilterDialog } from "./filter.ts";
export function openReleases(title: string, fetch: (signal: AbortSignal) => Promise<Release[]>, grab: (r: Release) => Promise<void>): void {
const ctl = new AbortController();
let all: Release[] = [];
let torrents = 0;
let searched = false;
let failed = "";
let sortMode: SortMode = "best";
let hideRejected = false;
let attr = noFilter();
const status = h("div.page-status");
const info = h("div.release-info");
const list = new ListView<Release>("releases", {
rowHeight: 52,
header: h("div.release-row.list-cols", h("span"), h("span", "Title"), h("span", "Quality"), h("span.num", "Size"), h("span.num", "Age"), h("span", "Indexer"), h("span.num", "Score")),
render: (r) => h("div.release-row",
h(`span.mark.${!isRejected(r) ? "ok" : r.temporarilyRejected ? "warn" : "bad"}` as "span", !isRejected(r) ? "✓" : r.temporarilyRejected ? "~" : "✗"),
h("span.ellipsis", r.title),
h("span.ellipsis", r.quality?.quality?.name ?? ""),
h("span.num", formatSize(r.size)),
h("span.num", formatReleaseAge(r.ageMinutes)),
h("span.ellipsis", r.indexer),
h("span.num", String(r.customFormatScore)),
),
onSelect: (r) => showInfo(r),
onActivate: (r) => grabRelease(r),
placeholder: "Searching indexers… (Back cancels)",
});
const sort = new Choice<SortMode>("Sort", [{ label: "best", value: "best" }, { label: "size", value: "size" }, { label: "age", value: "age" }], "best", (v) => {
sortMode = v;
render();
});
const hide = new Toggle("Hide rejected", false, (v) => {
hideRejected = v;
render();
});
const filterBtn = button("Filter…", () => {
openFilterDialog(attr, pool, (f) => {
attr = f;
render();
});
});
const el = h("div.releases",
pageHeader(title, status),
h("div.toolbar.fgroup", sort.el, hide.el, filterBtn),
list.el,
info,
h("div.hints", "OK grab · Back return"),
);
const page = openPage(el, () => ctl.abort());
render();
fetch(ctl.signal).then((releases) => {
// Downloads go to SABnzbd, which handles Usenet only.
all = releases.filter((r) => r.protocol !== "torrent");
torrents = releases.length - all.length;
searched = true;
render();
list.el.focus();
}, (e) => {
if (isAbort(e)) return;
failed = errorText(e);
searched = true;
render();
});
function pool(): Pool {
return { files: all.filter((r) => !(hideRejected && isRejected(r))).map((r) => [releaseAttrs(r)]), unknown: false };
}
function render(): void {
const shown = visibleReleases(all, hideRejected, sortMode, attr);
if (failed) list.setPlaceholder("Search failed: " + failed);
else if (!searched) list.setPlaceholder("Searching indexers… (Back cancels)");
else if (all.length) list.setPlaceholder("All releases are hidden by the filters");
else list.setPlaceholder("No releases found. Are indexers configured?");
list.setItems(shown, (r) => r.guid);
const parts: string[] = [];
if (searched && !failed) parts.push(`${shown.length} of ${all.length} releases`);
if (filterActive(attr)) parts.push(filterLabel(attr));
if (torrents) parts.push(`${torrents} torrent release${torrents === 1 ? "" : "s"} hidden`);
replace(status, parts.join(" · "));
filterBtn.textContent = filterActive(attr) ? `Filter: ${filterLabel(attr)}` : "Filter…";
}
function showInfo(r: Release | undefined): void {
if (!r) return replace(info);
const facts = [r.releaseGroup ? "group " + r.releaseGroup : "", (r.languages ?? []).map((l) => l.name).join(", ")].filter(Boolean);
replace(info,
r.rejections?.length ? h("div.bad", "Rejected: " + r.rejections.join("; ")) : r.approved ? h("div.ok", "Approved") : null,
facts.length ? h("div.muted", facts.join(" · ")) : null,
h("div.release-title", r.title),
);
}
function grabRelease(r: Release): void {
if (!isRejected(r)) return doGrab(r);
const reasons = r.rejections ?? [];
const more = reasons.length > 3 ? `\n+${reasons.length - 3} more` : "";
confirmDialog(`This release was rejected:\n${reasons.slice(0, 3).join("\n")}${more}\n\nGrab it anyway?`, "Grab anyway", () => doGrab(r));
}
function doGrab(r: Release): void {
toast("Grabbing " + r.title + "…");
grab(r).then(() => {
toast("Sent to the download client: " + r.title, "ok");
page.close(); // back to where the search started
for (const f of app.afterGrab) setTimeout(f, 3000);
}, (e) => toast("Grab failed: " + errorText(e), "error"));
}
}
+322
View File
@@ -0,0 +1,322 @@
// Movies and Series: search Radarr/Sonarr as you type; with an empty search
// box the Trakt lists (trending, most watched, anticipated, popular, box
// office). Titles not in the library can be added; library titles offer
// releases, automatic search and more.
import { app, lookups } from "../app.ts";
import { isAbort } from "../api/http.ts";
import type { Movie } from "../api/radarr.ts";
import type { Series } from "../api/sonarr.ts";
import { listNote, movieLists, showLists, type Entry, type List } from "../api/trakt.ts";
import { movieDetail, seriesDetail } from "../describe.ts";
import { DetailPane, type Detail } from "../ui/detail.ts";
import { h, replace } from "../ui/dom.ts";
import { focus, focusFirst, Keys } from "../ui/focus.ts";
import { actionSheet, errorText } from "../ui/widgets.ts";
import { openAdd } from "./add.ts";
import { movieActions, seriesActions } from "./actions.ts";
import type { Hint, View } from "./view.ts";
export type SearchKind = "movie" | "series";
interface Result {
key: string;
title: string;
year: number;
inLibrary: boolean;
note: string; // the Trakt list's number, "" for search results
poster: string; // small
detail: () => Detail;
movie?: Movie;
series?: Series;
}
const listTTL = 15 * 60_000;
const listLimit = 20;
const resolveLimit = 4;
function movieResult(m: Movie, note = ""): Result {
return { key: "tmdb:" + m.tmdbId, title: m.title, year: m.year, inLibrary: !!m.id, note, poster: app.radarr.posterURL(m.images, !!m.id, false), detail: () => movieDetail(m), movie: m };
}
function seriesResult(s: Series, note = ""): Result {
return { key: "tvdb:" + s.tvdbId, title: s.title, year: s.year, inLibrary: !!s.id, note, poster: app.sonarr.posterURL(s.images, !!s.id, false), detail: () => seriesDetail(s), series: s };
}
// resolve looks a list title up: in the library (quick, always asked), else
// the lookup kept from an earlier run, else Radarr/Sonarr's lookup (slow: it
// asks the metadata provider).
async function resolveEntry(kind: SearchKind, e: Entry): Promise<Result | null> {
if (kind === "movie") {
const id = e.ids.tmdb;
if (!id) return null;
const lib = await app.radarr.movieByTMDB(id);
if (lib) return movieResult(lib);
const key = "tmdb:" + id;
const kept = lookups.get(key) as Movie | undefined;
if (kept) return movieResult(kept);
const found = (await app.radarr.lookup(key))[0];
if (!found) return null;
if (!found.id) lookups.put(key, found);
return movieResult(found);
}
const id = e.ids.tvdb;
if (!id) return null;
const lib = await app.sonarr.seriesByTVDB(id);
if (lib) return seriesResult(lib);
const key = "tvdb:" + id;
const kept = lookups.get(key) as Series | undefined;
if (kept) return seriesResult(kept);
const found = (await app.sonarr.lookup(key))[0];
if (!found) return null;
if (!found.id) lookups.put(key, found);
return seriesResult(found);
}
async function mapLimit<T, R>(items: T[], limit: number, f: (t: T) => Promise<R>): Promise<{ value?: R; error?: unknown }[]> {
const out: { value?: R; error?: unknown }[] = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
try {
out[i] = { value: await f(items[i]) };
} catch (error) {
out[i] = { error };
}
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
interface Fetched {
results: Result[];
error: string;
at: number;
}
export class SearchView implements View {
el: HTMLElement;
hints: Hint[] = [["ok", "add, or what to do"], ["red", "previous list"], ["green", "next list"], ["blue", "reload"]];
private kind: SearchKind;
private input: HTMLInputElement;
private chips: HTMLElement;
private grid: HTMLElement;
private status: HTMLElement;
private detail = new DetailPane();
private lists: List[];
private listIdx = 0;
private fetched = new Map<number, Fetched>();
private loading = new Set<number>();
private searchCtl: AbortController | null = null;
private timer = 0;
private results: Result[] = [];
constructor(kind: SearchKind) {
this.kind = kind;
this.lists = kind === "movie" ? movieLists : showLists;
const noun = kind === "movie" ? "movies" : "series";
const ids = kind === "movie" ? "tmdb:603, imdb:tt0133093" : "tvdb:81189, imdb:tt0903747";
this.input = h("input.search.f", { type: "search", placeholder: `Search ${noun} (title, ${ids})…`, spellcheck: false, autocomplete: "off" });
this.input.addEventListener("input", () => {
clearTimeout(this.timer);
this.timer = window.setTimeout(() => this.update(), 600);
});
this.input.addEventListener("keydown", (ev) => {
if (ev.keyCode === Keys.enter) {
clearTimeout(this.timer);
this.update();
(ev.target as HTMLInputElement).blur?.();
const first = this.grid.querySelector<HTMLElement>(".card");
if (first) focus(first);
}
});
this.chips = h("div.chips.fgroup");
this.status = h("div.grid-status");
this.grid = h("div.grid.fgroup");
this.el = h("div.view.search-view",
h("div.search-main", h("div.search-bar", this.input), this.chips, this.status, h("div.grid-scroll", this.grid)),
this.detail.el,
);
app.onConfig.push(() => {
this.fetched.clear();
if (this.browsing()) this.update();
});
}
show(): void {
this.renderChips();
if (!this.results.length) this.update();
if (!this.el.contains(document.activeElement)) focusFirst(this.el);
}
hide(): void {}
keys(code: number): boolean {
if (!this.browsing()) return false;
switch (code) {
case Keys.red:
this.switchList(-1);
return true;
case Keys.green:
this.switchList(1);
return true;
case Keys.blue:
this.fetched.delete(this.listIdx);
this.update();
return true;
}
return false;
}
private browsing(): boolean {
return this.input.value.trim() === "";
}
private switchList(step: number): void {
this.listIdx = (this.listIdx + step + this.lists.length) % this.lists.length;
this.renderChips();
this.update();
}
private renderChips(): void {
if (!app.trakt.configured()) return replace(this.chips);
replace(this.chips, ...this.lists.map((l, i) => {
const chip = h(`button.chip.f${i === this.listIdx ? ".on" : ""}` as "button", {
tabindex: -1,
onclick: () => {
this.input.value = "";
this.listIdx = i;
this.renderChips();
this.update();
},
}, l.name);
return chip;
}));
this.chips.classList.toggle("hidden", !this.browsing());
}
// update shows the Trakt list (empty box) or searches.
private update(): void {
this.searchCtl?.abort();
this.chips.classList.toggle("hidden", !this.browsing());
if (this.browsing()) return this.showList();
const term = this.input.value.trim();
const ctl = new AbortController();
this.searchCtl = ctl;
this.setStatus("Searching…");
const client = this.kind === "movie" ? app.radarr : app.sonarr;
const search: Promise<Result[]> = this.kind === "movie"
? app.radarr.lookup(term, ctl.signal).then((ms) => ms.map((m) => movieResult(m)))
: app.sonarr.lookup(term, ctl.signal).then((ss) => ss.map((s) => seriesResult(s)));
search.then((rs) => {
if (ctl.signal.aborted) return;
app.setStatus(client.name as "Radarr" | "Sonarr", null);
this.setResults(rs.slice(0, 40), rs.length ? "" : `Nothing found for “${term}”.`);
}, (e) => {
if (isAbort(e) || ctl.signal.aborted) return;
app.setStatus(client.name as "Radarr" | "Sonarr", e);
this.setResults([], `${client.name}: ${errorText(e)}`);
});
}
private showList(): void {
const l = this.lists[this.listIdx];
if (!app.trakt.configured()) {
return this.setResults([], `Trending, most watched and anticipated ${this.kind === "movie" ? "movies" : "series"} appear here once a Trakt client ID is set in Settings.`);
}
const f = this.fetched.get(this.listIdx);
if (f && Date.now() - f.at < listTTL) return this.setResults(f.results, f.error || (f.results.length ? "" : "Nothing on this list right now."));
this.setResults([], `Loading ${l.name}…`);
const idx = this.listIdx;
if (this.loading.has(idx)) return;
this.loading.add(idx);
this.fetchList(l).then((fetched) => {
this.loading.delete(idx);
this.fetched.set(idx, fetched);
if (this.browsing() && this.listIdx === idx) this.showList();
});
}
private async fetchList(l: List): Promise<Fetched> {
const at = Date.now();
let entries: Entry[];
try {
entries = await app.trakt.entries(l, listLimit);
app.setStatus("Trakt", null);
} catch (e) {
app.setStatus("Trakt", e);
return { results: [], error: errorText(e), at };
}
const found = await mapLimit(entries, resolveLimit, (e) => resolveEntry(this.kind, e));
const results: Result[] = [];
let lastErr: unknown = null;
found.forEach((r, i) => {
if (r.value) results.push({ ...r.value, note: listNote(l, entries[i].count) });
else if (r.error) lastErr = r.error;
});
const name = this.kind === "movie" ? "Radarr" : "Sonarr";
return { results, error: !results.length && lastErr ? `${name}: ${errorText(lastErr)}` : "", at };
}
private setStatus(text: string): void {
replace(this.status, text);
}
private setResults(rs: Result[], message: string): void {
const hadFocus = this.grid.contains(document.activeElement);
this.results = rs;
this.setStatus(message);
replace(this.grid, ...rs.map((r) => this.card(r)));
if (hadFocus) {
const first = this.grid.querySelector<HTMLElement>(".card");
focus(first ?? this.input);
}
this.detail.show(rs[0]?.detail() ?? null);
}
private card(r: Result): HTMLElement {
const img = h("img.card-poster", { alt: "", loading: "lazy" });
if (r.poster && app.covers()) img.src = r.poster;
img.addEventListener("error", () => img.classList.add("none"));
const el = h("div.card.f", { tabindex: -1, onclick: () => this.activate(r) },
h("div.card-art", img, h("div.card-fallback", r.title), r.inLibrary ? h("div.card-badge", "✓") : null),
h("div.card-title", r.title),
h("div.card-sub", h("span", r.year > 0 ? String(r.year) : ""), r.note ? h("span.card-note", r.note) : null),
);
el.addEventListener("focus", () => this.detail.show(r.detail()));
return el;
}
private activate(r: Result): void {
const refresh = () => {
this.fetched.delete(this.listIdx);
this.update();
};
if (!r.inLibrary) {
const target = r.movie ? { kind: "movie" as const, movie: r.movie } : { kind: "series" as const, series: r.series! };
actionSheet(r.title, [{ label: "Add…", run: () => openAdd(target, () => this.markAdded(r)) }]);
return;
}
actionSheet(r.title, r.movie ? movieActions(r.movie, refresh, refresh) : seriesActions(r.series!, refresh, refresh));
}
// markAdded ticks an added title; its library copy is fetched again so
// releases and the rest work on it.
private markAdded(r: Result): void {
this.fetched.delete(this.listIdx);
const reload = r.movie ? app.radarr.movieByTMDB(r.movie.tmdbId).then((m) => m && movieResult(m, r.note))
: app.sonarr.seriesByTVDB(r.series!.tvdbId).then((s) => s && seriesResult(s, r.note));
reload.then((fresh) => {
const i = this.results.indexOf(r);
if (!fresh || i < 0) return;
this.results[i] = fresh;
const cards = this.grid.querySelectorAll<HTMLElement>(".card");
const old = cards[i];
const card = this.card(fresh);
old.replaceWith(card);
if (document.activeElement === document.body || !document.activeElement) focus(card);
}, () => undefined);
}
}
+179
View File
@@ -0,0 +1,179 @@
// Settings: the addresses, API keys, add defaults, Trakt client ID and
// interface options. The app starts with the config it was built with;
// changes made here are kept on the TV (local storage) until "Reset".
import { app } from "../app.ts";
import type { ArrClient } from "../api/arr.ts";
import { RadarrClient } from "../api/radarr.ts";
import { SABnzbdClient } from "../api/sabnzbd.ts";
import { SonarrClient } from "../api/sonarr.ts";
import { TraktClient, movieLists } from "../api/trakt.ts";
import { builtIn, hasLocalChanges, resetConfig, saveConfig, type Config } from "../config.ts";
import { h, replace } from "../ui/dom.ts";
import { focusFirst } from "../ui/focus.ts";
import { button, Choice, confirmDialog, errorText, textField, toast, Toggle } from "../ui/widgets.ts";
import { applyTheme } from "../theme.ts";
import type { Hint, View } from "./view.ts";
interface ArrFields {
url: HTMLInputElement;
key: HTMLInputElement;
profile: Choice<string>;
folder: Choice<string>;
timer: number;
}
export class SettingsView implements View {
el: HTMLElement;
hints: Hint[] = [["ok", "edit, change or press"]];
private form: HTMLElement;
private result: HTMLElement;
private radarr!: ArrFields;
private sonarr!: ArrFields;
private sab!: { url: HTMLInputElement; key: HTMLInputElement };
private trakt!: HTMLInputElement;
private theme!: Choice<string>;
private refresh!: Choice<number>;
private covers!: Toggle;
private notify!: Toggle;
constructor() {
this.form = h("div.settings-form.fgroup");
this.result = h("div.settings-result");
this.el = h("div.view.settings-view", h("div.settings-scroll", this.form, this.result));
}
show(): void {
this.build(app.cfg);
focusFirst(this.form);
}
hide(): void {}
private arrSection(name: string, c: Config["radarr"], make: (url: string, key: string) => ArrClient): ArrFields {
const url = textField("Address", c.url, { placeholder: "https://radarr.example.com" });
const key = textField("API key", c.apikey, { secret: true });
const f: ArrFields = {
url: url.input,
key: key.input,
profile: new Choice("Default quality profile", [{ label: c.default_quality_profile || "(first)", value: c.default_quality_profile ?? "" }], c.default_quality_profile ?? ""),
folder: new Choice("Default root folder", [{ label: c.default_root_folder || "(first)", value: c.default_root_folder ?? "" }], c.default_root_folder ?? ""),
timer: 0,
};
// The lists come from the server named in the fields, shortly after either changes.
const load = () => {
const client = make(f.url.value, f.key.value);
if (!client.configured() || !f.key.value.trim()) return;
Promise.all([client.qualityProfiles(), client.rootFolders()]).then(([ps, fs]) => {
const pick = (names: string[], cur: string) => {
const opts = names.map((n) => ({ label: n, value: n }));
if (cur && !names.includes(cur)) opts.unshift({ label: cur + " (not on the server)", value: cur });
if (!cur) opts.unshift({ label: "(first)", value: "" });
return opts;
};
f.profile.setOptions(pick(ps.map((p) => p.name), f.profile.value), f.profile.value);
f.folder.setOptions(pick(fs.map((x) => x.path), f.folder.value), f.folder.value);
}, () => undefined);
};
for (const input of [f.url, f.key]) input.addEventListener("input", () => {
clearTimeout(f.timer);
f.timer = window.setTimeout(load, 700);
});
load();
this.form.append(h("h2", name), url.el, key.el, f.profile.el, f.folder.el);
return f;
}
private build(c: Config): void {
replace(this.form);
replace(this.result);
this.radarr = this.arrSection("Radarr", c.radarr, (u, k) => new RadarrClient(u, k));
this.sonarr = this.arrSection("Sonarr", c.sonarr, (u, k) => new SonarrClient(u, k));
const sabURL = textField("Address", c.sabnzbd.url, { placeholder: "https://sabnzbd.example.com" });
const sabKey = textField("API key", c.sabnzbd.apikey, { secret: true });
this.sab = { url: sabURL.input, key: sabKey.input };
const trakt = textField("Client ID", c.trakt.client_id ?? "", { secret: true, placeholder: "from trakt.tv/oauth/applications" });
this.trakt = trakt.input;
this.theme = new Choice("Theme", [{ label: "auto", value: "auto" }, { label: "dark", value: "dark" }, { label: "light", value: "light" }], c.ui.theme || "auto",
(v) => applyTheme(v)); // live, saved with Save
this.refresh = new Choice("Downloads refresh", [1, 2, 3, 5, 10, 30].map((n) => ({ label: n + " s", value: n })), c.ui.refresh_seconds || 3);
this.covers = new Toggle("Show posters", c.ui.covers !== false);
this.notify = new Toggle("Announce finished downloads", c.ui.notify_downloads !== false);
this.form.append(
h("h2", "SABnzbd"), sabURL.el, sabKey.el,
h("h2", "Trakt"), h("p.muted", "For the trending, most watched and anticipated lists and the cast. Optional."), trakt.el,
h("h2", "Interface"), this.theme.el, this.refresh.el, this.covers.el, this.notify.el,
h("div.row-buttons",
button("Save", () => this.save()),
button("Test connections", () => this.test()),
button("Reset to built-in", () => confirmDialog("Forget the changes made on this TV and use the config the app was built with?", "Reset", () => {
resetConfig();
app.apply(builtIn());
applyTheme(app.cfg.ui.theme);
this.build(app.cfg);
toast("Settings reset to the built-in config", "ok");
}), "ghost"),
),
h("p.muted", hasLocalChanges() ? "Changed on this TV (kept in its storage)." : "As built into the app."),
);
}
private collect(): Config {
const arr = (f: ArrFields) => ({ url: f.url.value.trim(), apikey: f.key.value.trim(), default_quality_profile: f.profile.value || undefined, default_root_folder: f.folder.value || undefined });
return {
radarr: arr(this.radarr),
sonarr: arr(this.sonarr),
sabnzbd: { url: this.sab.url.value.trim(), apikey: this.sab.key.value.trim() },
trakt: { client_id: this.trakt.value.trim() || undefined },
ui: { theme: this.theme.value, refresh_seconds: this.refresh.value, covers: this.covers.value, notify_downloads: this.notify.value },
};
}
private save(): void {
const c = this.collect();
for (const [name, s] of [["Radarr", c.radarr], ["Sonarr", c.sonarr], ["SABnzbd", c.sabnzbd]] as const) {
if (!!s.url !== !!s.apikey) {
toast(`${name}: enter both the address and the API key, or neither`, "error");
return;
}
}
saveConfig(c);
app.apply(c);
applyTheme(c.ui.theme);
toast("Settings saved", "ok");
this.build(c);
}
// test checks every service with the values in the fields.
private test(): void {
const c = this.collect();
replace(this.result, h("h2", "Test"));
const line = (name: string) => {
const el = h("div.test-line", name + ": testing…");
this.result.appendChild(el);
return (ok: boolean, text: string) => {
el.className = "test-line " + (ok ? "ok" : "bad");
el.textContent = `${name}: ${text}`;
};
};
const arrTest = (name: string, client: ArrClient) => {
if (!client.configured()) return line(name)(true, "not configured");
const done = line(name);
client.systemStatus().then((s) => done(true, `${s.appName || name} ${s.version}`), (e) => done(false, errorText(e)));
};
arrTest("Radarr", new RadarrClient(c.radarr.url, c.radarr.apikey));
arrTest("Sonarr", new SonarrClient(c.sonarr.url, c.sonarr.apikey));
const sab = new SABnzbdClient(c.sabnzbd.url, c.sabnzbd.apikey);
if (!sab.configured()) line("SABnzbd")(true, "not configured");
else {
const done = line("SABnzbd");
sab.version().then((v) => done(true, "SABnzbd " + v), (e) => done(false, errorText(e)));
}
const trakt = new TraktClient(c.trakt.client_id ?? "", app.trakt.baseURL);
if (!trakt.configured()) line("Trakt")(true, "not configured");
else {
const done = line("Trakt");
trakt.entries(movieLists[0], 1).then(() => done(true, "client ID accepted"), (e) => done(false, errorText(e)));
}
}
}
+13
View File
@@ -0,0 +1,13 @@
// A top-level view (Movies, Series, Library, Downloads, Calendar, Settings, Log).
// A hint names a remote button and what it does here: "ok", "back", or a
// colour button ("red", "green", "yellow", "blue").
export type Hint = [button: string, text: string];
export interface View {
el: HTMLElement;
hints: Hint[];
show(): void; // the view became visible
hide(): void;
keys?(keyCode: number): boolean; // colour buttons and the like
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "com.stinnesbeck.arrtui",
"version": "0.1.0",
"vendor": "Nils Stinnesbeck",
"type": "web",
"main": "index.html",
"title": "arr-tui",
"icon": "icon.png",
"largeIcon": "largeIcon.png",
"bgColor": "#14161b",
"resolution": "1920x1080",
"disableBackHistoryAPI": true,
"requiredACG": []
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1920, height=1080">
<title>arr-tui</title>
<link rel="stylesheet" href="style.css">
<script src="config.js"></script>
</head>
<body>
<div id="root"></div>
<div id="overlays"></div>
<div id="toasts"></div>
<script src="app.js"></script>
</body>
</html>
+250
View File
@@ -0,0 +1,250 @@
/* arr-tui for webOS: 1920×1080, read from the couch. Kept to CSS that
webOS 5 (Chromium 68) supports: no flex gap, aspect-ratio or inset. */
:root, :root[data-theme="dark"] {
--bg: #14161b;
--panel: #1d2027;
--panel-2: #262a33;
--line: #333844;
--text: #e9eaee;
--muted: #8f94a0;
--accent: #f2b632;
--accent-text: #14161b;
--sel: #2f3542;
--ok: #7fcc6e;
--warn: #e8b552;
--bad: #f07272;
--dl: #5fadf2;
--shadow: rgba(0, 0, 0, 0.6);
}
:root[data-theme="light"] {
--bg: #f1f2f4;
--panel: #ffffff;
--panel-2: #e8eaee;
--line: #d3d6dd;
--text: #1c1e23;
--muted: #6a6f7b;
--accent: #c98a00;
--accent-text: #ffffff;
--sel: #dfe3ea;
--ok: #2e8b2e;
--warn: #a86b00;
--bad: #c43a3a;
--dl: #1f6fc4;
--shadow: rgba(0, 0, 0, 0.25);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--bg);
color: var(--text);
font: 26px/1.35 "LG Smart UI", "Museo Sans", Roboto, "Segoe UI", Arial, sans-serif;
}
button, input { font: inherit; color: inherit; }
h1, h2 { margin: 0; font-weight: 600; }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.bad { color: var(--bad); }
.dl { color: var(--dl); }
.muted { color: var(--muted); }
.plain { color: var(--text); }
.bold { font-weight: 600; }
.hidden { display: none !important; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.ellipsis { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.f { outline: none; }
/* --- Shell --- */
.app { display: flex; flex-direction: column; height: 1080px; }
.topbar { display: flex; align-items: center; height: 88px; padding: 0 48px; background: var(--panel); border-bottom: 2px solid var(--line); }
.brand { font-size: 34px; font-weight: 700; color: var(--accent); margin-right: 40px; }
.tabs { display: flex; flex: 1; }
.tabs .tab { margin-right: 6px; }
.status-dots { display: flex; }
.status-dot { margin-left: 22px; font-size: 22px; color: var(--muted); }
.status-dot::before { content: "●"; margin-right: 8px; }
.status-dot.ok::before { color: var(--ok); }
.status-dot.bad::before { color: var(--bad); }
.status-dot.off::before { color: var(--line); }
.content { flex: 1; min-height: 0; padding: 28px 48px 16px; }
.footer { height: 60px; padding: 0 48px; display: flex; align-items: center; background: var(--panel); border-top: 2px solid var(--line); color: var(--muted); font-size: 22px; }
.footer-hints { display: flex; align-items: center; }
.hint { margin-right: 36px; display: inline-flex; align-items: center; }
.key { display: inline-block; min-width: 26px; height: 26px; border-radius: 13px; margin-right: 10px; font-size: 16px; line-height: 26px; text-align: center; font-weight: 700; }
.key.red { background: #e04848; }
.key.green { background: #3fae4a; }
.key.yellow { background: #e3c02c; }
.key.blue { background: #3d7fe0; }
.key.ok, .key.back { background: var(--panel-2); color: var(--text); padding: 0 10px; border-radius: 6px; }
/* --- Buttons, chips, choices, toggles, fields --- */
.btn, .chip, .tab {
border: 2px solid transparent; background: var(--panel-2); border-radius: 10px;
padding: 10px 24px; cursor: pointer; white-space: nowrap;
}
.tab { background: transparent; color: var(--muted); }
.tab.on { color: var(--text); border-bottom-color: var(--accent); border-radius: 10px 10px 0 0; }
.btn.danger { color: var(--bad); }
.btn.ghost { background: transparent; border-color: var(--line); }
.btn:focus, .chip:focus, .tab:focus { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
.btn:disabled { opacity: 0.5; }
.btn-hint { margin-left: 14px; color: var(--muted); font-size: 20px; }
.btn:focus .btn-hint { color: var(--accent-text); }
.chip { border-radius: 30px; margin-right: 12px; font-size: 22px; padding: 8px 22px; }
.chip.on { border-color: var(--accent); }
.toolbar { display: flex; align-items: center; margin-bottom: 18px; flex-wrap: wrap; }
.toolbar > * { margin-right: 14px; }
.choice, .toggle {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 20px; background: var(--panel-2); border: 2px solid transparent; border-radius: 10px; cursor: pointer;
}
.choice-label { color: var(--muted); margin-right: 18px; }
.choice-arrows { color: var(--muted); }
.choice-value { color: var(--text); margin: 0 12px; }
.choice:focus, .toggle:focus { border-color: var(--accent); }
.choice:focus .choice-arrows { color: var(--accent); }
.toggle-box { width: 28px; height: 28px; border: 2px solid var(--muted); border-radius: 6px; margin-right: 16px; position: relative; }
.toggle.on .toggle-box { background: var(--accent); border-color: var(--accent); }
.toggle.on .toggle-box::after { content: "✓"; position: absolute; left: 4px; top: -4px; color: var(--accent-text); font-weight: 700; }
.toggle { justify-content: flex-start; }
.toggle.f-off { opacity: 0.4; }
.field { display: flex; align-items: center; }
.field-label { color: var(--muted); margin-right: 16px; white-space: nowrap; }
input.f, input.search {
background: var(--panel-2); border: 2px solid transparent; border-radius: 10px; padding: 10px 18px; min-width: 260px;
}
input.f:focus { border-color: var(--accent); }
input.search { width: 100%; font-size: 30px; padding: 14px 24px; }
/* --- Lists --- */
.list { display: flex; flex-direction: column; flex: 1; min-height: 0; background: var(--panel); border: 2px solid var(--line); border-radius: 12px; position: relative; }
.list:focus { border-color: var(--accent); }
.list-body { flex: 1; min-height: 0; position: relative; overflow: hidden; padding: 6px 0; }
.list-cols { color: var(--accent); font-size: 22px; border-bottom: 2px solid var(--line); padding: 10px 20px !important; flex-shrink: 0; }
.list-row { display: grid; align-items: center; padding: 0 20px; white-space: nowrap; }
.list-row > * { margin-right: 18px; min-width: 0; }
.list-row.sel-idle { background: var(--panel-2); }
.list-row.sel { background: var(--sel); box-shadow: inset 6px 0 0 var(--accent); }
.list-heading, .cal-day { display: flex; align-items: flex-end; padding: 0 20px 6px !important; color: var(--accent); font-weight: 600; }
.list-placeholder { padding: 24px; color: var(--muted); }
.list-scroll { position: absolute; right: 4px; top: 8px; bottom: 8px; width: 6px; }
.list-thumb { position: absolute; left: 0; right: 0; background: var(--line); border-radius: 3px; }
.dot { color: var(--muted); }
.dot.on { color: var(--ok); }
.dot.dl { color: var(--dl); }
/* --- Views with a details pane on the right --- */
.view { display: flex; height: 100%; }
.search-main, .lib-main, .cal-main { flex: 1; min-width: 0; display: flex; flex-direction: column; margin-right: 32px; }
.detail { width: 600px; flex-shrink: 0; overflow: hidden; background: var(--panel); border-radius: 12px; padding: 28px; position: relative; }
.detail-poster { float: right; width: 220px; margin: 0 0 18px 22px; border-radius: 8px; background: var(--panel-2); min-height: 330px; }
.detail-poster.none { display: none; }
.detail-poster.loading { opacity: 0.3; }
.detail-title { font-size: 34px; line-height: 1.2; margin-bottom: 10px; }
.detail-sub { font-weight: 600; margin-bottom: 8px; }
.detail-facts { color: var(--muted); margin-bottom: 10px; }
.detail-line { font-size: 24px; }
.detail-plot { font-size: 24px; color: var(--text); margin: 16px 0 0; }
.detail-cast { clear: both; padding-top: 16px; font-size: 22px; }
.cast-head { font-weight: 600; margin-top: 8px; }
/* Movies / Series */
.search-bar { margin-bottom: 18px; }
.chips { display: flex; margin-bottom: 14px; }
.grid-status { color: var(--muted); min-height: 36px; }
.grid-scroll { flex: 1; min-height: 0; overflow: hidden; padding: 12px 8px; margin: 0 -8px; }
.grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); grid-column-gap: 22px; grid-row-gap: 26px; }
.card { cursor: pointer; border-radius: 10px; transition: transform 0.12s; }
.card:focus { transform: scale(1.06); }
.card:focus .card-art { box-shadow: 0 0 0 5px var(--accent), 0 10px 24px var(--shadow); }
.card-art { position: relative; padding-top: 150%; background: var(--panel-2); border-radius: 10px; overflow: hidden; }
.card-poster { position: absolute; left: 0; top: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1; }
.card-poster.none { display: none; }
.card-fallback { position: absolute; left: 0; top: 0; right: 0; bottom: 0; padding: 16px; display: flex; align-items: center; justify-content: center; text-align: center; color: var(--muted); }
.card-badge { position: absolute; z-index: 2; right: 10px; top: 10px; width: 40px; height: 40px; border-radius: 20px; background: var(--ok); color: #10200d; font-weight: 700; text-align: center; line-height: 40px; }
.card-title { margin-top: 10px; font-size: 22px; font-weight: 600; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.card-sub { display: flex; justify-content: space-between; font-size: 20px; color: var(--muted); }
.card-note { color: var(--muted); }
/* Library */
.lib-count { color: var(--muted); margin-left: auto; margin-right: 0 !important; }
.library-view .field input { min-width: 0; width: 230px; }
.library-view .field-label:empty { display: none; }
.lib-row { grid-template-columns: 30px 1fr 300px 140px; }
/* Releases, episodes, calendar, downloads, log */
.release-row { grid-template-columns: 30px 1fr 220px 130px 80px 200px 90px; font-size: 22px; }
.mark.ok { color: var(--ok); } .mark.warn { color: var(--warn); } .mark.bad { color: var(--bad); }
.release-info { min-height: 110px; padding: 12px 4px 0; font-size: 22px; }
.release-title { color: var(--muted); word-break: break-all; }
.ep-row { grid-template-columns: 30px 60px 1fr 160px 260px; }
.cal-row { grid-template-columns: 80px 30px 360px 1fr 200px; }
.cal-range { color: var(--muted); margin-left: auto; }
.downloads-view { flex-direction: column; }
.downloads-view .list { margin-bottom: 16px; }
.dl-queue { flex: 3; } .dl-arr { flex: 2; } .dl-history { flex: 3; }
.dl-row { grid-template-columns: 1fr 210px 280px 150px 120px; font-size: 22px; }
.dl-problem { color: var(--warn); }
.progress { display: flex; align-items: center; }
.bar { flex: 1; height: 10px; background: var(--panel-2); border-radius: 5px; overflow: hidden; margin-right: 12px; }
.bar-fill { display: block; height: 100%; background: var(--dl); }
.sab-head { font-size: 24px; }
.log-row { grid-template-columns: 140px 1fr; font-size: 22px; }
/* Settings */
.settings-view { display: block; }
.settings-scroll { height: 100%; overflow: hidden; }
.settings-form { max-width: 1100px; }
.settings-form h2 { color: var(--accent); margin: 26px 0 12px; font-size: 28px; }
.settings-form h2:first-child { margin-top: 0; }
.settings-form > .field, .settings-form > .choice, .settings-form > .toggle { margin-bottom: 10px; }
.settings-form .field input { flex: 1; }
.settings-result { max-width: 1100px; margin-top: 20px; }
.test-line { font-size: 24px; }
/* --- Pages (releases, episodes): full screen over the shell --- */
.page { position: fixed; left: 0; top: 0; width: 1920px; height: 1080px; background: var(--bg); z-index: 10; display: flex; flex-direction: column; padding: 36px 48px 24px; }
.page-header { display: flex; align-items: baseline; margin-bottom: 20px; }
.page-header h1 { font-size: 38px; margin-right: 28px; }
.page-status { color: var(--muted); }
.split { flex: 1; min-height: 0; display: flex; }
.split .list { margin-right: 32px; }
.hints { color: var(--muted); font-size: 22px; padding-top: 12px; }
/* --- Dialogs --- */
.dialog { position: fixed; left: 0; top: 0; width: 1920px; height: 1080px; background: rgba(0, 0, 0, 0.6); z-index: 20; display: flex; align-items: center; justify-content: center; }
.dialog-box { width: 900px; max-height: 960px; overflow: hidden; background: var(--panel); border: 2px solid var(--line); border-radius: 16px; padding: 36px; box-shadow: 0 20px 60px var(--shadow); }
.dialog-box h2 { margin-bottom: 20px; font-size: 32px; }
.dialog-box > .btn, .dialog-box > .choice, .dialog-box > .toggle { display: flex; align-items: baseline; width: 100%; margin-bottom: 10px; text-align: left; }
.dialog-box > .choice, .dialog-box > .toggle { align-items: center; }
.dialog-text { white-space: pre-line; margin: 0 0 20px; }
.dialog-hint { color: var(--muted); font-size: 20px; margin: 16px 0 0; }
.row-buttons { display: flex; margin-top: 18px; }
.row-buttons .btn { margin-right: 14px; }
/* --- Toasts --- */
#toasts { position: fixed; right: 48px; bottom: 84px; width: 760px; z-index: 30; }
.toast { background: var(--panel-2); border-left: 6px solid var(--muted); border-radius: 8px; padding: 14px 20px; margin-top: 10px; box-shadow: 0 8px 24px var(--shadow); transition: opacity 0.5s; font-size: 22px; }
.toast-ok { border-left-color: var(--ok); }
.toast-warn { border-left-color: var(--warn); }
.toast-error { border-left-color: var(--bad); }
.toast.gone { opacity: 0; }
+56
View File
@@ -0,0 +1,56 @@
import { deepStrictEqual, strictEqual } from "node:assert";
import { test } from "node:test";
import { errorMessage, queueName, queueProgress, scaledVariant, tmdbSized, type QueueItem } from "../src/api/arr.ts";
import { compact, decodeList, movieLists, refKey } from "../src/api/trakt.ts";
import { formatReleaseAge, formatSize } from "../src/format.ts";
import { episodeStatus, summarizeSeasons } from "../src/seasons.ts";
import type { Episode } from "../src/api/sonarr.ts";
test("poster URLs", () => {
strictEqual(scaledVariant("/MediaCover/1/poster.jpg?lastWrite=5", 500), "/MediaCover/1/poster-500.jpg?lastWrite=5");
strictEqual(scaledVariant("/MediaCover/1/fanart.jpg", 500), "/MediaCover/1/fanart.jpg");
strictEqual(tmdbSized("https://image.tmdb.org/t/p/original/abc.jpg", true), "https://image.tmdb.org/t/p/w780/abc.jpg");
strictEqual(tmdbSized("https://image.tmdb.org/t/p/original/abc.jpg", false), "https://image.tmdb.org/t/p/w342/abc.jpg");
strictEqual(tmdbSized("https://artworks.thetvdb.com/banners/1.jpg", true), "https://artworks.thetvdb.com/banners/1.jpg");
});
test("error messages", () => {
strictEqual(errorMessage('{"message":"Movie exists"}'), "Movie exists");
strictEqual(errorMessage('[{"propertyName":"Path","errorMessage":"Bad path"},{"errorMessage":"Other"}]'), "Bad path; Other");
strictEqual(errorMessage(" plain "), "plain");
});
test("queue items", () => {
const q = { id: 1, title: "rel", status: "", size: 100, sizeleft: 25, series: { title: "Show" }, episode: { seasonNumber: 1, episodeNumber: 2, title: "x" } } as QueueItem;
strictEqual(queueName(q), "Show S01E02");
strictEqual(queueProgress(q), 75);
});
test("trakt", () => {
strictEqual(compact(950), "950");
strictEqual(compact(1234), "1.2k");
strictEqual(compact(2000), "2k");
strictEqual(compact(34567), "34k");
strictEqual(compact(1_500_000), "1.5M");
strictEqual(compact(48_000_000), "48M");
const entries = decodeList([{ watchers: 1234, movie: { title: "Dune", year: 2021, ids: { trakt: 1, tmdb: 438631 } } }], movieLists[0]);
deepStrictEqual(entries, [{ title: "Dune", year: 2021, ids: { trakt: 1, tmdb: 438631 }, count: 1234 }]);
const bare = decodeList([{ title: "Heat", year: 1995, ids: { trakt: 2, tmdb: 949 } }], movieLists[3]);
strictEqual(bare[0].title, "Heat");
strictEqual(refKey({ kind: "show", tvdb: 81189 }), "show:tvdb:81189");
});
test("formatting", () => {
strictEqual(formatSize(1.5 * 1024 ** 3), "1.5 GB");
strictEqual(formatSize(0), "–");
strictEqual(formatReleaseAge(30), "30m");
strictEqual(formatReleaseAge(300), "5h");
strictEqual(formatReleaseAge(5000), "3d");
});
test("seasons", () => {
const ep = (s: number, n: number, hasFile: boolean) => ({ id: s * 100 + n, seriesId: 1, seasonNumber: s, episodeNumber: n, title: "", hasFile, monitored: true }) as Episode;
deepStrictEqual(summarizeSeasons([ep(0, 1, false), ep(1, 1, true), ep(2, 1, false), ep(2, 2, true)]).map((s) => [s.number, s.have, s.total]), [[2, 1, 2], [1, 1, 1], [0, 0, 1]]);
deepStrictEqual(episodeStatus({ ...ep(1, 1, false), airDate: "2026-01-01" }, "2026-02-01"), ["missing", "warn"]);
deepStrictEqual(episodeStatus({ ...ep(1, 1, false), airDate: "2026-03-01" }, "2026-02-01"), ["unaired", "muted"]);
});
+100
View File
@@ -0,0 +1,100 @@
// Ported from internal/arr/mediafile_test.go and the filter tests.
import { deepStrictEqual, strictEqual, ok } from "node:assert";
import { test } from "node:test";
import type { MediaFile, Release } from "../src/api/arr.ts";
import { audioTags, facets, fileAttrs, listedValues, matchFile, noFilter, releaseAttrs, visibleReleases, type FileAttrs } from "../src/media.ts";
const attrs = (f: Partial<MediaFile>) => fileAttrs({ size: 0, ...f });
test("attributes from media info", () => {
deepStrictEqual(attrs({
quality: { quality: { name: "Bluray-2160p", resolution: 2160 } },
mediaInfo: { videoCodec: "x265", videoDynamicRange: "HDR", videoDynamicRangeType: "DV HDR10" },
languages: [{ name: "English" }, { name: "German" }],
}), { resolution: 2160, codec: "x265", dv: true, hdr: true, audio: [], languages: ["English", "German"] });
});
test("attribute variants", () => {
const cases: [string, Partial<MediaFile>, (a: FileAttrs) => boolean][] = [
["hevc", { mediaInfo: { videoCodec: "HEVC" } }, (a) => a.codec === "x265"],
["h264", { mediaInfo: { videoCodec: "h264" } }, (a) => a.codec === "x264"],
["avc", { mediaInfo: { videoCodec: "AVC" } }, (a) => a.codec === "x264"],
["av1", { mediaInfo: { videoCodec: "AV1" } }, (a) => a.codec === "AV1"],
["unoffered codec not guessed", { sceneName: "x264.Movie", mediaInfo: { videoCodec: "VC-1" } }, (a) => a.codec === ""],
["codec from scene name", { sceneName: "Movie.2020.1080p.BluRay.x265-GRP" }, (a) => a.codec === "x265" && a.resolution === 1080],
["dv from path", { relativePath: "Movie.2160p.DV.HDR10.mkv" }, (a) => a.dv && a.hdr && a.resolution === 2160],
["dv only", { mediaInfo: { videoDynamicRangeType: "DV" } }, (a) => a.dv && !a.hdr],
["hlg", { mediaInfo: { videoDynamicRangeType: "HLG" } }, (a) => a.hdr && !a.dv],
["sdr", { mediaInfo: { videoCodec: "x264" }, sceneName: "Movie.HDRip.x264" }, (a) => !a.hdr && !a.dv],
["hdr word with media info", { relativePath: "Hdr.Story.mkv", mediaInfo: { videoDynamicRange: "SDR" } }, (a) => !a.hdr],
];
for (const [name, f, check] of cases) ok(check(attrs(f)), `${name}: ${JSON.stringify(attrs(f))}`);
});
test("release attributes", () => {
const r = {
title: "Dune.2021.2160p.UHD.BluRay.DV.HDR10.x265-GRP", quality: { quality: { name: "Bluray-2160p", resolution: 2160 } },
languages: [{ name: "English" }], customFormats: [{ name: "HDR" }],
} as Release;
deepStrictEqual(releaseAttrs(r), { resolution: 2160, codec: "x265", dv: true, hdr: true, audio: [], languages: ["English"] });
const r2 = releaseAttrs({ title: "Show.S01E01.720p.WEB.H.264-GRP" } as Release);
ok(r2.resolution === 720 && r2.codec === "x264" && !r2.hdr && !r2.dv);
const r3 = releaseAttrs({ title: "Show.S01E01.HDTV.x264-GRP" } as Release);
ok(!r3.hdr && !r3.dv, "HDTV is not HDR");
});
test("audio tags", () => {
const cases: [string, string[]][] = [
["Movie.2160p.UHD.BluRay.TrueHD.7.1.Atmos.x265-G", ["Atmos", "TrueHD"]],
["Movie.1080p.DDP5.1.Atmos.x265", ["Atmos", "DD+"]],
["Movie.1080p.DTS-HD.MA.5.1.x264", ["DTS-HD", "DTS"]],
["Movie.2160p.DTS-X.7.1", ["DTS:X", "DTS"]],
["Movie.1080p.DTS.5.1", ["DTS"]],
["Movie.1080p.EAC3.5.1", ["DD+"]],
["Movie.1080p.E-AC-3.5.1", ["DD+"]],
["Movie.1080p.AC3.5.1", ["DD"]],
["Movie.720p.DD5.1.x264", ["DD"]],
["Show.S01E01.AAC.2.0", ["AAC"]],
["Album.FLAC", ["FLAC"]],
["Show.Opus", ["Opus"]],
["Movie.1080p.BluRay.x264-DTSAG", []],
["Movie.1080p.x264", []],
];
for (const [input, want] of cases) deepStrictEqual(audioTags(input), want, input);
deepStrictEqual(attrs({ sceneName: "Movie.x264.AC3", mediaInfo: { audioCodec: "TrueHD Atmos" } }).audio, ["Atmos", "TrueHD"]);
deepStrictEqual(attrs({ mediaInfo: { audioCodec: "EAC3", audioAdditionalFeatures: "Atmos" } }).audio, ["Atmos", "DD+"]);
});
test("filter matching", () => {
const a: FileAttrs = { resolution: 2160, codec: "x265", hdr: true, dv: false, audio: ["Atmos", "TrueHD"], languages: ["English"] };
ok(matchFile({ ...noFilter(), res: "2160p", lang: "english", hdr: "HDR", audio: "Atmos" }, a));
ok(!matchFile({ ...noFilter(), hdr: "SDR" }, a));
ok(!matchFile({ ...noFilter(), hdr: "DV" }, a));
ok(matchFile({ ...noFilter(), res: "SD" }, { ...a, resolution: 480 }));
ok(!matchFile({ ...noFilter(), res: "SD" }, { ...a, resolution: 0 }));
});
test("the dialog offers only options that leave something", () => {
const pool = { files: [[{ resolution: 1080, codec: "x264", hdr: false, dv: false, audio: ["DD"], languages: ["German"] }]], unknown: false };
const quality = facets[0];
strictEqual(quality.label, "Quality");
deepStrictEqual(listedValues(quality, noFilter(), pool), ["", "1080p"]);
strictEqual(facets[1].label, "Language");
deepStrictEqual(listedValues(facets[1], noFilter(), pool), ["", "German"]);
// Unknown files (series not read yet): everything is offered.
deepStrictEqual(listedValues(quality, noFilter(), { ...pool, unknown: true }), ["", "2160p", "1080p", "720p", "SD"]);
});
test("release order: approved first, then score, quality and age", () => {
const r = (guid: string, o: Partial<Release>) => ({ guid, title: guid, rejected: false, customFormatScore: 0, qualityWeight: 0, ageMinutes: 0, size: 0, ...o }) as Release;
const all = [
r("rejected", { rejected: true, customFormatScore: 900 }),
r("old", { customFormatScore: 100, ageMinutes: 500 }),
r("new", { customFormatScore: 100, ageMinutes: 5 }),
r("best", { customFormatScore: 200 }),
];
deepStrictEqual(visibleReleases(all, false, "best", noFilter()).map((x) => x.guid), ["best", "new", "old", "rejected"]);
deepStrictEqual(visibleReleases(all, true, "best", noFilter()).map((x) => x.guid), ["best", "new", "old"]);
deepStrictEqual(visibleReleases([r("s", { size: 1 }), r("l", { size: 9 })], false, "size", noFilter()).map((x) => x.guid), ["l", "s"]);
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["es2022", "dom", "dom.iterable"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test", "build.mjs"]
}