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
+74
View File
@@ -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);
}