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
+200
View File
@@ -0,0 +1,200 @@
// File and release properties and the property filter, ported from
// internal/arr/mediafile.go, internal/arr/release.go and internal/ui/libfilter.go.
import type { MediaFile, Release } from "./api/arr.ts";
export interface FileAttrs {
resolution: number; // 2160, 1080, 720, 480 ... or 0
codec: string; // x265, x264, AV1 or ""
hdr: boolean;
dv: boolean;
audio: string[];
languages: string[];
}
export const resOptions = ["2160p", "1080p", "720p", "SD"];
export const codecOptions = ["x265", "x264", "AV1"];
export const hdrOptions = ["HDR", "DV", "SDR"];
export const audioOptions = ["Atmos", "TrueHD", "DTS:X", "DTS-HD", "DTS", "DD+", "DD", "AAC", "FLAC", "Opus"];
const reRes = /\b(2160|1080|720|576|480)p\b/i;
const re265 = /(x\.?265|h\.?265|hevc)/i;
const re264 = /(x\.?264|h\.?264|\bavc\b)/i;
const reAV1 = /\bav1\b/i;
const reDV = /(\bdv\b|dovi|dolby[ .]?vision)/i;
const reAtmos = /(atmos|\bddpa\b)/i;
const reTrueHD = /true[ .-]?hd/i;
const reDTSX = /\bdts[ .:-]?x\b/i;
const reDTSHD = /dts[ .-]?(hd|ma|hra|es)/i;
const reDTS = /\bdts([^a-z]|$)/i;
const reDDPlus = /(e[ .-]?ac[ .-]?3|\bddp|dd\+|dolby digital plus)/i;
const reDD = /(\bac[ .-]?3\b|\bdd[ .]?[0-9]|dolby digital\b)/i;
const reAAC = /\baac/i;
const reFLAC = /\bflac\b/i;
const reOpus = /\bopus\b/i;
const reHDR = /\b(hdr(10(\+|plus)?)?|hlg|pq)\b/i;
export function codecOf(s: string): string {
if (re265.test(s)) return "x265";
if (re264.test(s)) return "x264";
if (reAV1.test(s)) return "AV1";
return "";
}
export function audioTags(s: string): string[] {
const tags: string[] = [];
if (reAtmos.test(s)) tags.push("Atmos");
if (reTrueHD.test(s)) tags.push("TrueHD");
const dtsx = reDTSX.test(s);
const dtshd = reDTSHD.test(s);
if (dtsx) tags.push("DTS:X");
if (dtshd) tags.push("DTS-HD");
if (dtsx || dtshd || reDTS.test(s)) tags.push("DTS");
if (reDDPlus.test(s)) tags.push("DD+");
else if (reDD.test(s)) tags.push("DD");
if (reAAC.test(s)) tags.push("AAC");
if (reFLAC.test(s)) tags.push("FLAC");
if (reOpus.test(s)) tags.push("Opus");
return tags;
}
function resolutionIn(text: string): number {
const m = reRes.exec(text);
return m ? parseInt(m[1], 10) : 0;
}
// fileAttrs uses Radarr/Sonarr's media info where present, else the names.
export function fileAttrs(f: MediaFile): FileAttrs {
const q = f.quality?.quality;
const names = [f.sceneName ?? "", f.relativePath ?? "", q?.name ?? ""].join(" ");
const mi = f.mediaInfo ?? {};
let dr = `${mi.videoDynamicRangeType ?? ""} ${mi.videoDynamicRange ?? ""}`;
if (!dr.trim()) dr = names;
let audio = `${mi.audioCodec ?? ""} ${mi.audioAdditionalFeatures ?? ""}`;
if (!audio.trim()) audio = names;
return {
resolution: q?.resolution || resolutionIn(names),
codec: mi.videoCodec ? codecOf(mi.videoCodec) : codecOf(names),
dv: reDV.test(dr),
hdr: reHDR.test(dr),
audio: audioTags(audio),
languages: (f.languages ?? []).map((l) => l.name).filter(Boolean),
};
}
// releaseAttrs reads what a release announces: title, quality, custom formats.
export function releaseAttrs(r: Release): FileAttrs {
const q = r.quality?.quality;
const text = [r.title, q?.name ?? "", ...(r.customFormats ?? []).map((c) => c.name)].join(" ");
return {
resolution: q?.resolution || resolutionIn(text),
codec: codecOf(text),
dv: reDV.test(text),
hdr: reHDR.test(text),
audio: audioTags(text),
languages: (r.languages ?? []).map((l) => l.name).filter(Boolean),
};
}
// AttrFilter: "" means any. All fields must match the same file.
export interface AttrFilter {
res: string;
lang: string;
codec: string;
hdr: string;
audio: string;
}
export const noFilter = (): AttrFilter => ({ res: "", lang: "", codec: "", hdr: "", audio: "" });
export function filterActive(f: AttrFilter): boolean {
return !!(f.res || f.lang || f.codec || f.hdr || f.audio);
}
export function filterLabel(f: AttrFilter): string {
return [f.res, f.lang, f.codec, f.hdr, f.audio].filter(Boolean).join(" ");
}
export function matchFile(f: AttrFilter, a: FileAttrs): boolean {
if (f.res === "SD") {
if (a.resolution <= 0 || a.resolution > 576) return false;
} else if (f.res && f.res !== a.resolution + "p") return false;
if (f.codec && f.codec !== a.codec) return false;
if (f.hdr === "HDR" && !a.hdr) return false;
if (f.hdr === "DV" && !a.dv) return false;
if (f.hdr === "SDR" && (a.hdr || a.dv)) return false;
if (f.audio && !a.audio.includes(f.audio)) return false;
if (f.lang && !a.languages.some((l) => l.toLowerCase() === f.lang.toLowerCase())) return false;
return true;
}
// matchAny: any of an item's files (a series has several). None: no match.
export function matchAny(f: AttrFilter, files: FileAttrs[]): boolean {
return files.some((a) => matchFile(f, a));
}
// Pool is what the filter dialog works on: the files of every item that
// passes the other filters. unknown: some are not read yet, nothing can be
// ruled out.
export interface Pool {
files: FileAttrs[][];
unknown: boolean;
}
export function poolHas(p: Pool, f: AttrFilter): boolean {
return p.unknown || p.files.some((files) => matchAny(f, files));
}
export function poolLanguages(p: Pool): string[] {
const seen = new Set<string>();
for (const files of p.files) for (const a of files) for (const l of a.languages) seen.add(l);
return [...seen].sort();
}
export interface Facet {
label: string;
key: keyof AttrFilter;
candidates: (p: Pool) => string[];
}
// Most used first.
export const facets: Facet[] = [
{ label: "Quality", key: "res", candidates: () => resOptions },
{ label: "Language", key: "lang", candidates: poolLanguages },
{ label: "Codec", key: "codec", candidates: () => codecOptions },
{ label: "HDR", key: "hdr", candidates: () => hdrOptions },
{ label: "Audio", key: "audio", candidates: () => audioOptions },
];
// listedValues: the options a facet offers, those that together with the
// other choices still leave a result, plus the current choice; "" first (any).
export function listedValues(fc: Facet, state: AttrFilter, pool: Pool): string[] {
const cur = state[fc.key];
let cands = fc.candidates(pool);
if (cur && !cands.includes(cur)) cands = [cur, ...cands];
const vals = [""];
for (const o of cands) {
if (o === cur || poolHas(pool, { ...state, [fc.key]: o })) vals.push(o);
}
return vals;
}
// --- Releases ---
export type SortMode = "best" | "size" | "age";
export function isRejected(r: Release): boolean {
return r.rejected || (r.rejections?.length ?? 0) > 0;
}
export function visibleReleases(all: Release[], hideRejected: boolean, mode: SortMode, af: AttrFilter): Release[] {
const out = all.filter((r) => !(hideRejected && isRejected(r)) && (!filterActive(af) || matchFile(af, releaseAttrs(r))));
const cmp = (a: number, b: number) => (a < b ? -1 : a > b ? 1 : 0);
// Array.prototype.sort is stable in Chrome 70+; older TVs may reorder ties.
return out.sort((a, b) => {
if (mode === "size") return cmp(b.size, a.size);
if (mode === "age") return cmp(a.ageMinutes, b.ageMinutes);
return cmp(+isRejected(a), +isRejected(b)) || cmp(b.customFormatScore, a.customFormatScore) ||
cmp(b.qualityWeight, a.qualityWeight) || cmp(a.ageMinutes, b.ageMinutes);
});
}