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:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
@@ -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
@@ -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" : ""})`);
|
||||
@@ -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();
|
||||
@@ -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
|
||||
Generated
+7099
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(/\/+$/, "");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)) },
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user