feat(web): missing-subtitles queue alongside no-pt-source

A third lane in the attention queues view: one row per movie or
series with a gap chip per language and why (#186's reasons, plus a
sync alass flagged). Reuses the existing deck-group markup and
missingChipLabel wording so it reads like its two siblings; each
row opens the title's own page, where the #203 fetch/translate
panel already carries the resolving actions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-25 03:11:34 +01:00
parent fb40b35156
commit a780b49ab3
4 changed files with 220 additions and 14 deletions
+13
View File
@@ -199,6 +199,19 @@
</p>
<ul class="deck-rows" id="rows-decision"></ul>
</section>
<section class="deck-group" id="queue-subtitles" hidden aria-labelledby="label-subtitles">
<header class="deck-head">
<h3 class="deck-label" id="label-subtitles">missing subtitles</h3>
<span class="deck-count readout" id="count-subtitles"></span>
</header>
<p class="queue-note readout dim">
a wanted language with no subtitle, and why — no candidate, a translation or provider
failure, the daily budget, or a sync `alass` flagged. open the title to search,
translate or retry by hand.
</p>
<ul class="deck-rows" id="rows-subtitles"></ul>
</section>
</main>
<!--
+126 -14
View File
@@ -29,7 +29,12 @@ import {
attemptsLabel,
attentionTotal,
fetchAttention,
fetchSubtitleQueue,
type MovieSubtitleGaps,
type SeriesAttention,
type SeriesSubtitleGaps,
type SubtitleQueue,
subtitleQueueTotal,
} from "./queues";
import {
type ActionOutcome,
@@ -284,7 +289,7 @@ function main() {
const movieDetail = movieMain(views);
const library = libraryMain(movieDetail, seriesDetail, views);
const goHome = () => library.open();
const queues = queuesMain(movieDetail, views, goHome);
const queues = queuesMain(movieDetail, seriesDetail, views, goHome);
const settings = settingsMain(views, goHome);
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
const search = searchMain(movieDetail, seriesDetail, views, goHome);
@@ -4601,14 +4606,19 @@ interface QueueGroup {
rows: HTMLUListElement;
}
function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () => void): QueuesView {
function queuesMain(
movieDetail: MovieView,
seriesDetail: SeriesView,
views: HideableView[],
goHome: () => void,
): QueuesView {
const view = must<HTMLElement>("#queues");
const deck = must<HTMLElement>("#deck");
const nav = must<HTMLButtonElement>("#nav-queues");
const badge = must<HTMLElement>("#queues-count");
const summary = must<HTMLElement>("#queues-summary");
const status = must<HTMLElement>("#queues-status");
const groups: { noPt: QueueGroup; decision: QueueGroup } = {
const groups: { noPt: QueueGroup; decision: QueueGroup; subtitles: QueueGroup } = {
noPt: {
section: must<HTMLElement>("#queue-no-pt"),
count: must<HTMLElement>("#count-no-pt"),
@@ -4619,7 +4629,13 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
count: must<HTMLElement>("#count-decision"),
rows: must<HTMLUListElement>("#rows-decision"),
},
subtitles: {
section: must<HTMLElement>("#queue-subtitles"),
count: must<HTMLElement>("#count-subtitles"),
rows: must<HTMLUListElement>("#rows-subtitles"),
},
};
const allGroups = [groups.noPt, groups.decision, groups.subtitles];
let roots: Root[] = [];
// guards a stale fetch from painting over a newer view
@@ -4636,7 +4652,7 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
}
function clearGroups() {
for (const group of [groups.noPt, groups.decision]) {
for (const group of allGroups) {
group.section.hidden = true;
group.rows.replaceChildren();
}
@@ -4664,7 +4680,7 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
function paintCounts() {
let total = 0;
for (const group of [groups.noPt, groups.decision]) {
for (const group of allGroups) {
const entries = entryCount(group);
group.count.textContent = String(entries);
total += entries;
@@ -4678,6 +4694,16 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
void movieDetail.open(movie.id, origin, view, { kind: "queues" });
};
const openMovie = (movieId: number, origin: HTMLElement) => {
navigate({ kind: "movie", movieId });
void movieDetail.open(movieId, origin, view, { kind: "queues" });
};
const openSeries = (seriesId: number, origin: HTMLElement) => {
navigate({ kind: "series", seriesId });
void seriesDetail.open(seriesId, origin, view, { kind: "queues" });
};
/** Drops an emptied item without a refetch: the override IS written. */
function settle(item: HTMLLIElement, group: QueueGroup, text: string) {
item.remove();
@@ -4686,11 +4712,11 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
setStatus(text);
}
function render(queues: AttentionQueues) {
function render(queues: AttentionQueues, subtitles: SubtitleQueue | null) {
clearGroups();
// both sections always render, even empty — this is the one surface
// every section always renders, even empty — this is the one surface
// whose good news is an absence, and the operator visits to see it
for (const group of [groups.noPt, groups.decision]) {
for (const group of allGroups) {
group.section.hidden = false;
}
for (const movie of queues.no_pt_source) {
@@ -4712,19 +4738,30 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
for (const series of queues.tv_needs_decision) {
groups.decision.rows.append(seriesAttentionRow(series));
}
for (const group of [groups.noPt, groups.decision]) {
if (subtitles !== null) {
for (const movie of subtitles.movies) {
groups.subtitles.rows.append(movieSubtitleGapsRow(movie, openMovie));
}
for (const series of subtitles.series) {
groups.subtitles.rows.append(seriesSubtitleGapsRow(series, openSeries));
}
}
for (const group of allGroups) {
emptyLine(group);
}
paintCounts();
setStatus(attentionTotal(queues) === 0 ? "nothing needs attention" : null);
const nothingWaiting =
attentionTotal(queues) === 0 && (subtitles === null || subtitleQueueTotal(subtitles) === 0);
setStatus(nothingWaiting ? "nothing needs attention" : null);
}
async function load() {
sequence += 1;
const ticket = sequence;
setStatus("reading queues…");
const [outcome, fetchedRoots] = await Promise.all([
const [outcome, subtitlesOutcome, fetchedRoots] = await Promise.all([
fetchAttention(),
fetchSubtitleQueue(),
allRoots().catch(() => roots),
]);
if (ticket !== sequence) {
@@ -4736,7 +4773,10 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
setStatus(`queues unavailable — ${outcome.detail}`, "fault");
return;
}
render(outcome.queues);
render(outcome.queues, subtitlesOutcome.kind === "results" ? subtitlesOutcome.queue : null);
if (subtitlesOutcome.kind === "error") {
setStatus(`subtitle queue unavailable — ${subtitlesOutcome.detail}`, "fault");
}
}
function open() {
@@ -4785,11 +4825,13 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
);
async function refreshBadge() {
const outcome = await fetchAttention();
const [outcome, subtitlesOutcome] = await Promise.all([fetchAttention(), fetchSubtitleQueue()]);
if (outcome.kind === "error") {
return;
}
paintBadge(attentionTotal(outcome.queues));
const subtitlesTotal =
subtitlesOutcome.kind === "results" ? subtitleQueueTotal(subtitlesOutcome.queue) : 0;
paintBadge(attentionTotal(outcome.queues) + subtitlesTotal);
}
return { hide, open, refreshBadge };
@@ -4890,6 +4932,76 @@ function noPtRow(
return item;
}
/**
* One missing-subtitles entry for a movie: its title, plus one chip per gap
* — language and why (#202). The row opens the movie page, where the
* subtitle section already offers manual search, translate and retry
* (#203, §9.3).
*/
function movieSubtitleGapsRow(
movie: MovieSubtitleGaps,
open: (movieId: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const line = document.createElement("button");
line.type = "button";
line.className = "row row-tmdb queue-line";
const chips = document.createElement("span");
chips.className = "row-chips";
for (const gap of movie.gaps) {
chips.append(chip(missingChipLabel(gap)));
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "open";
chips.append(affordance);
line.append(rowTitle(movie.title, movie.year), chips);
line.addEventListener("click", () => {
open(movie.movie_id, line);
});
item.append(line);
return item;
}
/**
* One missing-subtitles entry for a series: one chip per episode gap,
* tagged `SxxEyy`, plus one per season row a uniform gap collapsed into
* (§9.5's restraint). Opens the series page, same reasoning as the movie row.
*/
function seriesSubtitleGapsRow(
series: SeriesSubtitleGaps,
open: (seriesId: number, origin: HTMLElement) => void,
): HTMLLIElement {
const item = document.createElement("li");
const line = document.createElement("button");
line.type = "button";
line.className = "row row-tmdb queue-line";
const chips = document.createElement("span");
chips.className = "row-chips";
for (const episode of series.episodes) {
const tag = episodeTag(episode.season_number, episode.episode_number);
for (const gap of episode.gaps) {
chips.append(chip(`${tag} ${missingChipLabel(gap)}`));
}
}
for (const season of series.seasons) {
const tag = seasonTag(season.season_number);
for (const gap of season.gaps) {
chips.append(chip(`${tag} ${missingChipLabel(gap)}`));
}
}
const affordance = document.createElement("span");
affordance.className = "row-add readout";
affordance.textContent = "open";
chips.append(affordance);
line.append(rowTitle(series.title, series.year), chips);
line.addEventListener("click", () => {
open(series.series_id, line);
});
item.append(line);
return item;
}
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
const parsed = parseManualInput(kind, raw);
intake.replaceChildren();
+78
View File
@@ -4,6 +4,7 @@
import { type ActionOutcome, queueSearch } from "./releases";
import type { LibraryMovie } from "./search";
import type { MissingSubtitle } from "./subtitles";
/** A queue entry: the movie plus its §6.2 search history. */
export interface AttentionMovie extends LibraryMovie {
@@ -114,3 +115,80 @@ async function errorDetail(response: Response): Promise<string> {
return `http ${response.status}`;
}
}
/* ---- missing-subtitles queue (§15, issue #202) ------------------------- */
/**
* A wanted language a media file lacks, or a subtitle it has but `alass`
* flagged. Same shape as `MissingSubtitle` (#201) — `reason` carries one more
* word here, `sync_rejected`, for the flagged case.
*/
export type SubtitleGap = MissingSubtitle;
/** A movie with at least one subtitle gap. */
export interface MovieSubtitleGaps {
movie_id: number;
tmdb_id: number;
title: string;
year: number | null;
poster_path: string | null;
media_file_id: number;
gaps: SubtitleGap[];
}
/** One episode's gaps, named `SxxEyy` for the operator like `QueuedEpisode`. */
export interface EpisodeSubtitleGaps {
episode_id: number;
media_file_id: number;
season_number: number;
episode_number: number;
gaps: SubtitleGap[];
}
/**
* A season collapsed into one row because every one of its episodes carries
* the identical gap — the restraint §9.5 already applies to the TV
* attention queues.
*/
export interface SeasonSubtitleGaps {
season_number: number;
media_file_ids: number[];
gaps: SubtitleGap[];
}
/** A series with at least one subtitle gap. */
export interface SeriesSubtitleGaps {
series_id: number;
tmdb_id: number;
title: string;
year: number | null;
poster_path: string | null;
episodes: EpisodeSubtitleGaps[];
seasons: SeasonSubtitleGaps[];
}
export interface SubtitleQueue {
movies: MovieSubtitleGaps[];
series: SeriesSubtitleGaps[];
}
export type SubtitleQueueOutcome =
| { kind: "results"; queue: SubtitleQueue }
| { kind: "error"; detail: string };
export async function fetchSubtitleQueue(): Promise<SubtitleQueueOutcome> {
try {
const response = await fetch("/api/queues/subtitles");
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "results", queue: (await response.json()) as SubtitleQueue };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** Entries across both lanes — the queue section's own count. */
export function subtitleQueueTotal(queue: SubtitleQueue): number {
return queue.movies.length + queue.series.length;
}
+3
View File
@@ -120,6 +120,9 @@ const REASON_LABELS: Record<string, string> = {
no_candidates: "no candidates",
capped: "capped",
failed: "failed",
// #202: not a missing language — a subtitle exists but `alass` rejected
// its sync (§15) and the file is flagged.
sync_rejected: "sync flagged",
};
/** How long a `failed` detail rides in the chip text before it is title-only. */