// 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(); 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); }); }