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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 16:51:48 +01:00
co-authored by Claude Opus 5.5
parent 1015135a4b
commit 7f6d4d07b9
47 changed files with 12294 additions and 1 deletions
+56
View File
@@ -0,0 +1,56 @@
import { deepStrictEqual, strictEqual } from "node:assert";
import { test } from "node:test";
import { errorMessage, queueName, queueProgress, scaledVariant, tmdbSized, type QueueItem } from "../src/api/arr.ts";
import { compact, decodeList, movieLists, refKey } from "../src/api/trakt.ts";
import { formatReleaseAge, formatSize } from "../src/format.ts";
import { episodeStatus, summarizeSeasons } from "../src/seasons.ts";
import type { Episode } from "../src/api/sonarr.ts";
test("poster URLs", () => {
strictEqual(scaledVariant("/MediaCover/1/poster.jpg?lastWrite=5", 500), "/MediaCover/1/poster-500.jpg?lastWrite=5");
strictEqual(scaledVariant("/MediaCover/1/fanart.jpg", 500), "/MediaCover/1/fanart.jpg");
strictEqual(tmdbSized("https://image.tmdb.org/t/p/original/abc.jpg", true), "https://image.tmdb.org/t/p/w780/abc.jpg");
strictEqual(tmdbSized("https://image.tmdb.org/t/p/original/abc.jpg", false), "https://image.tmdb.org/t/p/w342/abc.jpg");
strictEqual(tmdbSized("https://artworks.thetvdb.com/banners/1.jpg", true), "https://artworks.thetvdb.com/banners/1.jpg");
});
test("error messages", () => {
strictEqual(errorMessage('{"message":"Movie exists"}'), "Movie exists");
strictEqual(errorMessage('[{"propertyName":"Path","errorMessage":"Bad path"},{"errorMessage":"Other"}]'), "Bad path; Other");
strictEqual(errorMessage(" plain "), "plain");
});
test("queue items", () => {
const q = { id: 1, title: "rel", status: "", size: 100, sizeleft: 25, series: { title: "Show" }, episode: { seasonNumber: 1, episodeNumber: 2, title: "x" } } as QueueItem;
strictEqual(queueName(q), "Show S01E02");
strictEqual(queueProgress(q), 75);
});
test("trakt", () => {
strictEqual(compact(950), "950");
strictEqual(compact(1234), "1.2k");
strictEqual(compact(2000), "2k");
strictEqual(compact(34567), "34k");
strictEqual(compact(1_500_000), "1.5M");
strictEqual(compact(48_000_000), "48M");
const entries = decodeList([{ watchers: 1234, movie: { title: "Dune", year: 2021, ids: { trakt: 1, tmdb: 438631 } } }], movieLists[0]);
deepStrictEqual(entries, [{ title: "Dune", year: 2021, ids: { trakt: 1, tmdb: 438631 }, count: 1234 }]);
const bare = decodeList([{ title: "Heat", year: 1995, ids: { trakt: 2, tmdb: 949 } }], movieLists[3]);
strictEqual(bare[0].title, "Heat");
strictEqual(refKey({ kind: "show", tvdb: 81189 }), "show:tvdb:81189");
});
test("formatting", () => {
strictEqual(formatSize(1.5 * 1024 ** 3), "1.5 GB");
strictEqual(formatSize(0), "–");
strictEqual(formatReleaseAge(30), "30m");
strictEqual(formatReleaseAge(300), "5h");
strictEqual(formatReleaseAge(5000), "3d");
});
test("seasons", () => {
const ep = (s: number, n: number, hasFile: boolean) => ({ id: s * 100 + n, seriesId: 1, seasonNumber: s, episodeNumber: n, title: "", hasFile, monitored: true }) as Episode;
deepStrictEqual(summarizeSeasons([ep(0, 1, false), ep(1, 1, true), ep(2, 1, false), ep(2, 2, true)]).map((s) => [s.number, s.have, s.total]), [[2, 1, 2], [1, 1, 1], [0, 0, 1]]);
deepStrictEqual(episodeStatus({ ...ep(1, 1, false), airDate: "2026-01-01" }, "2026-02-01"), ["missing", "warn"]);
deepStrictEqual(episodeStatus({ ...ep(1, 1, false), airDate: "2026-03-01" }, "2026-02-01"), ["unaired", "muted"]);
});
+100
View File
@@ -0,0 +1,100 @@
// Ported from internal/arr/mediafile_test.go and the filter tests.
import { deepStrictEqual, strictEqual, ok } from "node:assert";
import { test } from "node:test";
import type { MediaFile, Release } from "../src/api/arr.ts";
import { audioTags, facets, fileAttrs, listedValues, matchFile, noFilter, releaseAttrs, visibleReleases, type FileAttrs } from "../src/media.ts";
const attrs = (f: Partial<MediaFile>) => fileAttrs({ size: 0, ...f });
test("attributes from media info", () => {
deepStrictEqual(attrs({
quality: { quality: { name: "Bluray-2160p", resolution: 2160 } },
mediaInfo: { videoCodec: "x265", videoDynamicRange: "HDR", videoDynamicRangeType: "DV HDR10" },
languages: [{ name: "English" }, { name: "German" }],
}), { resolution: 2160, codec: "x265", dv: true, hdr: true, audio: [], languages: ["English", "German"] });
});
test("attribute variants", () => {
const cases: [string, Partial<MediaFile>, (a: FileAttrs) => boolean][] = [
["hevc", { mediaInfo: { videoCodec: "HEVC" } }, (a) => a.codec === "x265"],
["h264", { mediaInfo: { videoCodec: "h264" } }, (a) => a.codec === "x264"],
["avc", { mediaInfo: { videoCodec: "AVC" } }, (a) => a.codec === "x264"],
["av1", { mediaInfo: { videoCodec: "AV1" } }, (a) => a.codec === "AV1"],
["unoffered codec not guessed", { sceneName: "x264.Movie", mediaInfo: { videoCodec: "VC-1" } }, (a) => a.codec === ""],
["codec from scene name", { sceneName: "Movie.2020.1080p.BluRay.x265-GRP" }, (a) => a.codec === "x265" && a.resolution === 1080],
["dv from path", { relativePath: "Movie.2160p.DV.HDR10.mkv" }, (a) => a.dv && a.hdr && a.resolution === 2160],
["dv only", { mediaInfo: { videoDynamicRangeType: "DV" } }, (a) => a.dv && !a.hdr],
["hlg", { mediaInfo: { videoDynamicRangeType: "HLG" } }, (a) => a.hdr && !a.dv],
["sdr", { mediaInfo: { videoCodec: "x264" }, sceneName: "Movie.HDRip.x264" }, (a) => !a.hdr && !a.dv],
["hdr word with media info", { relativePath: "Hdr.Story.mkv", mediaInfo: { videoDynamicRange: "SDR" } }, (a) => !a.hdr],
];
for (const [name, f, check] of cases) ok(check(attrs(f)), `${name}: ${JSON.stringify(attrs(f))}`);
});
test("release attributes", () => {
const r = {
title: "Dune.2021.2160p.UHD.BluRay.DV.HDR10.x265-GRP", quality: { quality: { name: "Bluray-2160p", resolution: 2160 } },
languages: [{ name: "English" }], customFormats: [{ name: "HDR" }],
} as Release;
deepStrictEqual(releaseAttrs(r), { resolution: 2160, codec: "x265", dv: true, hdr: true, audio: [], languages: ["English"] });
const r2 = releaseAttrs({ title: "Show.S01E01.720p.WEB.H.264-GRP" } as Release);
ok(r2.resolution === 720 && r2.codec === "x264" && !r2.hdr && !r2.dv);
const r3 = releaseAttrs({ title: "Show.S01E01.HDTV.x264-GRP" } as Release);
ok(!r3.hdr && !r3.dv, "HDTV is not HDR");
});
test("audio tags", () => {
const cases: [string, string[]][] = [
["Movie.2160p.UHD.BluRay.TrueHD.7.1.Atmos.x265-G", ["Atmos", "TrueHD"]],
["Movie.1080p.DDP5.1.Atmos.x265", ["Atmos", "DD+"]],
["Movie.1080p.DTS-HD.MA.5.1.x264", ["DTS-HD", "DTS"]],
["Movie.2160p.DTS-X.7.1", ["DTS:X", "DTS"]],
["Movie.1080p.DTS.5.1", ["DTS"]],
["Movie.1080p.EAC3.5.1", ["DD+"]],
["Movie.1080p.E-AC-3.5.1", ["DD+"]],
["Movie.1080p.AC3.5.1", ["DD"]],
["Movie.720p.DD5.1.x264", ["DD"]],
["Show.S01E01.AAC.2.0", ["AAC"]],
["Album.FLAC", ["FLAC"]],
["Show.Opus", ["Opus"]],
["Movie.1080p.BluRay.x264-DTSAG", []],
["Movie.1080p.x264", []],
];
for (const [input, want] of cases) deepStrictEqual(audioTags(input), want, input);
deepStrictEqual(attrs({ sceneName: "Movie.x264.AC3", mediaInfo: { audioCodec: "TrueHD Atmos" } }).audio, ["Atmos", "TrueHD"]);
deepStrictEqual(attrs({ mediaInfo: { audioCodec: "EAC3", audioAdditionalFeatures: "Atmos" } }).audio, ["Atmos", "DD+"]);
});
test("filter matching", () => {
const a: FileAttrs = { resolution: 2160, codec: "x265", hdr: true, dv: false, audio: ["Atmos", "TrueHD"], languages: ["English"] };
ok(matchFile({ ...noFilter(), res: "2160p", lang: "english", hdr: "HDR", audio: "Atmos" }, a));
ok(!matchFile({ ...noFilter(), hdr: "SDR" }, a));
ok(!matchFile({ ...noFilter(), hdr: "DV" }, a));
ok(matchFile({ ...noFilter(), res: "SD" }, { ...a, resolution: 480 }));
ok(!matchFile({ ...noFilter(), res: "SD" }, { ...a, resolution: 0 }));
});
test("the dialog offers only options that leave something", () => {
const pool = { files: [[{ resolution: 1080, codec: "x264", hdr: false, dv: false, audio: ["DD"], languages: ["German"] }]], unknown: false };
const quality = facets[0];
strictEqual(quality.label, "Quality");
deepStrictEqual(listedValues(quality, noFilter(), pool), ["", "1080p"]);
strictEqual(facets[1].label, "Language");
deepStrictEqual(listedValues(facets[1], noFilter(), pool), ["", "German"]);
// Unknown files (series not read yet): everything is offered.
deepStrictEqual(listedValues(quality, noFilter(), { ...pool, unknown: true }), ["", "2160p", "1080p", "720p", "SD"]);
});
test("release order: approved first, then score, quality and age", () => {
const r = (guid: string, o: Partial<Release>) => ({ guid, title: guid, rejected: false, customFormatScore: 0, qualityWeight: 0, ageMinutes: 0, size: 0, ...o }) as Release;
const all = [
r("rejected", { rejected: true, customFormatScore: 900 }),
r("old", { customFormatScore: 100, ageMinutes: 500 }),
r("new", { customFormatScore: 100, ageMinutes: 5 }),
r("best", { customFormatScore: 200 }),
];
deepStrictEqual(visibleReleases(all, false, "best", noFilter()).map((x) => x.guid), ["best", "new", "old", "rejected"]);
deepStrictEqual(visibleReleases(all, true, "best", noFilter()).map((x) => x.guid), ["best", "new", "old"]);
deepStrictEqual(visibleReleases([r("s", { size: 1 }), r("l", { size: 9 })], false, "size", noFilter()).map((x) => x.guid), ["l", "s"]);
});