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>
201 lines
7.1 KiB
TypeScript
201 lines
7.1 KiB
TypeScript
// 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);
|
|
});
|
|
}
|