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
+7
View File
@@ -131,6 +131,13 @@ Keep the file private (`chmod 600`); it holds your API keys.
| `Esc` | back / close dialog |
| `Ctrl+C` | quit |
## On the TV (LG webOS)
[webos/](webos/README.md) has the same views as a TV app for LG webOS (2020 models and newer),
driven by the remote: search and Trakt lists, Library with filters, seasons and episodes, release
search and grab, Downloads, Calendar. It is built with your config baked in and installed in the
TV's Developer Mode; see [webos/README.md](webos/README.md).
## Development
`dev/` has a compose file with pinned Radarr, Sonarr and SABnzbd containers plus a fake indexer;
+1 -1
View File
@@ -18,7 +18,7 @@ DejaVu fonts; `oxipng` (optional) compresses the PNGs losslessly. It takes about
|---|---|
| `catalog.py` | The made-up library: movies (with file details) and series (with seasons). |
| `posters.sh` | Draws a poster per title into `posters/` (gradient, title, year); run by `take.sh` when `posters/` is missing. |
| `server.py` | The fake services on 127.0.0.1: Radarr 19891, Sonarr 19892, SABnzbd 19893, Trakt 19894 (`take.sh` points arr-tui there with `ARR_TUI_TRAKT_URL`). Library, search, lists, releases, queues, history, calendar, posters. |
| `server.py` | The fake services on 127.0.0.1: Radarr 19891, Sonarr 19892, SABnzbd 19893, Trakt 19894 (`take.sh` points arr-tui there with `ARR_TUI_TRAKT_URL`). Library, search, lists, releases, queues, history, calendar, posters. Like the real services it answers cross-origin requests, and it accepts writes without changing anything, for the webOS app (`webos/`, `npm run demo`). |
| `xshot.sh` | Runs a command in Ghostty under Xvfb and follows a scene script. |
| `scenes/*.txt` | What to press and when to take a picture: `key`, `type`, `sleep`, `shot <name>`. |
+21
View File
@@ -220,6 +220,27 @@ def sab(q):
def handler(port):
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
# Like the real services, answer cross-origin requests (the webOS app
# runs in a browser).
def end_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
super().end_headers()
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
self.send_header("Access-Control-Allow-Headers", self.headers.get("Access-Control-Request-Headers", "*"))
self.end_headers()
# Writes (add, grab, commands, monitoring, removal) are accepted and
# change nothing; an added title comes back with an id.
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}") if n else {}
out = dict(body, id=900) if isinstance(body, dict) and self.path.split("?")[0].endswith(("/movie", "/series")) else {}
b = json.dumps(out).encode()
self.send_response(201 if out else 200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
do_PUT = do_POST
def do_DELETE(self):
self.send_response(200); self.send_header("Content-Length", "0"); self.end_headers()
def do_GET(self):
u = urllib.parse.urlparse(self.path); q = urllib.parse.parse_qs(u.query); path = u.path
if path.startswith("/MediaCover/") or path.startswith("/remote/"):
+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"]
}