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:
@@ -199,6 +199,19 @@
|
|||||||
</p>
|
</p>
|
||||||
<ul class="deck-rows" id="rows-decision"></ul>
|
<ul class="deck-rows" id="rows-decision"></ul>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
+126
-14
@@ -29,7 +29,12 @@ import {
|
|||||||
attemptsLabel,
|
attemptsLabel,
|
||||||
attentionTotal,
|
attentionTotal,
|
||||||
fetchAttention,
|
fetchAttention,
|
||||||
|
fetchSubtitleQueue,
|
||||||
|
type MovieSubtitleGaps,
|
||||||
type SeriesAttention,
|
type SeriesAttention,
|
||||||
|
type SeriesSubtitleGaps,
|
||||||
|
type SubtitleQueue,
|
||||||
|
subtitleQueueTotal,
|
||||||
} from "./queues";
|
} from "./queues";
|
||||||
import {
|
import {
|
||||||
type ActionOutcome,
|
type ActionOutcome,
|
||||||
@@ -284,7 +289,7 @@ function main() {
|
|||||||
const movieDetail = movieMain(views);
|
const movieDetail = movieMain(views);
|
||||||
const library = libraryMain(movieDetail, seriesDetail, views);
|
const library = libraryMain(movieDetail, seriesDetail, views);
|
||||||
const goHome = () => library.open();
|
const goHome = () => library.open();
|
||||||
const queues = queuesMain(movieDetail, views, goHome);
|
const queues = queuesMain(movieDetail, seriesDetail, views, goHome);
|
||||||
const settings = settingsMain(views, goHome);
|
const settings = settingsMain(views, goHome);
|
||||||
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
|
views.push(tvDeck, seriesDetail, movieDetail, library, queues, settings);
|
||||||
const search = searchMain(movieDetail, seriesDetail, views, goHome);
|
const search = searchMain(movieDetail, seriesDetail, views, goHome);
|
||||||
@@ -4601,14 +4606,19 @@ interface QueueGroup {
|
|||||||
rows: HTMLUListElement;
|
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 view = must<HTMLElement>("#queues");
|
||||||
const deck = must<HTMLElement>("#deck");
|
const deck = must<HTMLElement>("#deck");
|
||||||
const nav = must<HTMLButtonElement>("#nav-queues");
|
const nav = must<HTMLButtonElement>("#nav-queues");
|
||||||
const badge = must<HTMLElement>("#queues-count");
|
const badge = must<HTMLElement>("#queues-count");
|
||||||
const summary = must<HTMLElement>("#queues-summary");
|
const summary = must<HTMLElement>("#queues-summary");
|
||||||
const status = must<HTMLElement>("#queues-status");
|
const status = must<HTMLElement>("#queues-status");
|
||||||
const groups: { noPt: QueueGroup; decision: QueueGroup } = {
|
const groups: { noPt: QueueGroup; decision: QueueGroup; subtitles: QueueGroup } = {
|
||||||
noPt: {
|
noPt: {
|
||||||
section: must<HTMLElement>("#queue-no-pt"),
|
section: must<HTMLElement>("#queue-no-pt"),
|
||||||
count: must<HTMLElement>("#count-no-pt"),
|
count: must<HTMLElement>("#count-no-pt"),
|
||||||
@@ -4619,7 +4629,13 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
|||||||
count: must<HTMLElement>("#count-decision"),
|
count: must<HTMLElement>("#count-decision"),
|
||||||
rows: must<HTMLUListElement>("#rows-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[] = [];
|
let roots: Root[] = [];
|
||||||
// guards a stale fetch from painting over a newer view
|
// guards a stale fetch from painting over a newer view
|
||||||
@@ -4636,7 +4652,7 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearGroups() {
|
function clearGroups() {
|
||||||
for (const group of [groups.noPt, groups.decision]) {
|
for (const group of allGroups) {
|
||||||
group.section.hidden = true;
|
group.section.hidden = true;
|
||||||
group.rows.replaceChildren();
|
group.rows.replaceChildren();
|
||||||
}
|
}
|
||||||
@@ -4664,7 +4680,7 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
|||||||
|
|
||||||
function paintCounts() {
|
function paintCounts() {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const group of [groups.noPt, groups.decision]) {
|
for (const group of allGroups) {
|
||||||
const entries = entryCount(group);
|
const entries = entryCount(group);
|
||||||
group.count.textContent = String(entries);
|
group.count.textContent = String(entries);
|
||||||
total += entries;
|
total += entries;
|
||||||
@@ -4678,6 +4694,16 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
|||||||
void movieDetail.open(movie.id, origin, view, { kind: "queues" });
|
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. */
|
/** Drops an emptied item without a refetch: the override IS written. */
|
||||||
function settle(item: HTMLLIElement, group: QueueGroup, text: string) {
|
function settle(item: HTMLLIElement, group: QueueGroup, text: string) {
|
||||||
item.remove();
|
item.remove();
|
||||||
@@ -4686,11 +4712,11 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
|||||||
setStatus(text);
|
setStatus(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(queues: AttentionQueues) {
|
function render(queues: AttentionQueues, subtitles: SubtitleQueue | null) {
|
||||||
clearGroups();
|
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
|
// 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;
|
group.section.hidden = false;
|
||||||
}
|
}
|
||||||
for (const movie of queues.no_pt_source) {
|
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) {
|
for (const series of queues.tv_needs_decision) {
|
||||||
groups.decision.rows.append(seriesAttentionRow(series));
|
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);
|
emptyLine(group);
|
||||||
}
|
}
|
||||||
paintCounts();
|
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() {
|
async function load() {
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
const ticket = sequence;
|
const ticket = sequence;
|
||||||
setStatus("reading queues…");
|
setStatus("reading queues…");
|
||||||
const [outcome, fetchedRoots] = await Promise.all([
|
const [outcome, subtitlesOutcome, fetchedRoots] = await Promise.all([
|
||||||
fetchAttention(),
|
fetchAttention(),
|
||||||
|
fetchSubtitleQueue(),
|
||||||
allRoots().catch(() => roots),
|
allRoots().catch(() => roots),
|
||||||
]);
|
]);
|
||||||
if (ticket !== sequence) {
|
if (ticket !== sequence) {
|
||||||
@@ -4736,7 +4773,10 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
|||||||
setStatus(`queues unavailable — ${outcome.detail}`, "fault");
|
setStatus(`queues unavailable — ${outcome.detail}`, "fault");
|
||||||
return;
|
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() {
|
function open() {
|
||||||
@@ -4785,11 +4825,13 @@ function queuesMain(movieDetail: MovieView, views: HideableView[], goHome: () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function refreshBadge() {
|
async function refreshBadge() {
|
||||||
const outcome = await fetchAttention();
|
const [outcome, subtitlesOutcome] = await Promise.all([fetchAttention(), fetchSubtitleQueue()]);
|
||||||
if (outcome.kind === "error") {
|
if (outcome.kind === "error") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
paintBadge(attentionTotal(outcome.queues));
|
const subtitlesTotal =
|
||||||
|
subtitlesOutcome.kind === "results" ? subtitleQueueTotal(subtitlesOutcome.queue) : 0;
|
||||||
|
paintBadge(attentionTotal(outcome.queues) + subtitlesTotal);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hide, open, refreshBadge };
|
return { hide, open, refreshBadge };
|
||||||
@@ -4890,6 +4932,76 @@ function noPtRow(
|
|||||||
return item;
|
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) {
|
function renderManual(intake: HTMLElement, kind: "magnet" | "torrent_url", raw: string) {
|
||||||
const parsed = parseManualInput(kind, raw);
|
const parsed = parseManualInput(kind, raw);
|
||||||
intake.replaceChildren();
|
intake.replaceChildren();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { type ActionOutcome, queueSearch } from "./releases";
|
import { type ActionOutcome, queueSearch } from "./releases";
|
||||||
import type { LibraryMovie } from "./search";
|
import type { LibraryMovie } from "./search";
|
||||||
|
import type { MissingSubtitle } from "./subtitles";
|
||||||
|
|
||||||
/** A queue entry: the movie plus its §6.2 search history. */
|
/** A queue entry: the movie plus its §6.2 search history. */
|
||||||
export interface AttentionMovie extends LibraryMovie {
|
export interface AttentionMovie extends LibraryMovie {
|
||||||
@@ -114,3 +115,80 @@ async function errorDetail(response: Response): Promise<string> {
|
|||||||
return `http ${response.status}`;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ const REASON_LABELS: Record<string, string> = {
|
|||||||
no_candidates: "no candidates",
|
no_candidates: "no candidates",
|
||||||
capped: "capped",
|
capped: "capped",
|
||||||
failed: "failed",
|
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. */
|
/** How long a `failed` detail rides in the chip text before it is title-only. */
|
||||||
|
|||||||
Reference in New Issue
Block a user