address audit findings: schedule validation, settings json guard, pipeline types, a11y labels
All checks were successful
Build and Push Docker Image / build (push) Successful in 58s

This commit is contained in:
2026-04-13 15:48:55 +02:00
parent c0bcbaec1b
commit c5ea37aab9
13 changed files with 161 additions and 37 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "netfelix-audio-fix", "name": "netfelix-audio-fix",
"version": "2026.04.13.9", "version": "2026.04.13.10",
"scripts": { "scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite", "dev:client": "vite",

View File

@@ -95,7 +95,11 @@ app.get("/schedule", (c) => {
app.patch("/schedule", async (c) => { app.patch("/schedule", async (c) => {
const body = await c.req.json<Partial<ScheduleConfig>>(); const body = await c.req.json<Partial<ScheduleConfig>>();
updateScheduleConfig(body); try {
updateScheduleConfig(body);
} catch (e) {
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
}
return c.json(getScheduleConfig()); return c.json(getScheduleConfig());
}); });

View File

@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { updateScheduleConfig } from "../scheduler";
// These tests only exercise the pure validation path in updateScheduleConfig —
// invalid payloads must throw before any setConfig() call, so they never touch
// the DB. Valid payloads would hit the DB, so we don't exercise them here.
describe("updateScheduleConfig validation", () => {
test("rejects malformed HH:MM start/end", () => {
expect(() => updateScheduleConfig({ scan: { enabled: true, start: "xx:yy", end: "07:00" } })).toThrow();
expect(() => updateScheduleConfig({ scan: { enabled: true, start: "1:00", end: "07:00" } })).toThrow();
expect(() => updateScheduleConfig({ scan: { enabled: true, start: "24:00", end: "07:00" } })).toThrow();
expect(() => updateScheduleConfig({ process: { enabled: true, start: "01:00", end: "99:99" } })).toThrow();
});
test("rejects non-integer, negative, or out-of-bounds job_sleep_seconds", () => {
expect(() => updateScheduleConfig({ job_sleep_seconds: Number.NaN })).toThrow();
expect(() => updateScheduleConfig({ job_sleep_seconds: -1 })).toThrow();
expect(() => updateScheduleConfig({ job_sleep_seconds: 1.5 })).toThrow();
expect(() => updateScheduleConfig({ job_sleep_seconds: 86_401 })).toThrow();
});
});

View File

@@ -41,10 +41,33 @@ export function getScheduleConfig(): ScheduleConfig {
}; };
} }
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
function validateWindow(kind: WindowKind, w: Partial<ScheduleWindow>): void {
if (w.start != null && !HHMM.test(w.start)) {
throw new Error(`${kind}.start must be HH:MM 24h, got ${JSON.stringify(w.start)}`);
}
if (w.end != null && !HHMM.test(w.end)) {
throw new Error(`${kind}.end must be HH:MM 24h, got ${JSON.stringify(w.end)}`);
}
}
export function updateScheduleConfig(updates: Partial<ScheduleConfig>): void { export function updateScheduleConfig(updates: Partial<ScheduleConfig>): void {
if (updates.job_sleep_seconds != null) setConfig("job_sleep_seconds", String(updates.job_sleep_seconds)); if (updates.job_sleep_seconds != null) {
if (updates.scan) writeWindow("scan", updates.scan); const n = updates.job_sleep_seconds;
if (updates.process) writeWindow("process", updates.process); if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 86_400) {
throw new Error(`job_sleep_seconds must be an integer in [0, 86400], got ${JSON.stringify(n)}`);
}
setConfig("job_sleep_seconds", String(n));
}
if (updates.scan) {
validateWindow("scan", updates.scan);
writeWindow("scan", updates.scan);
}
if (updates.process) {
validateWindow("process", updates.process);
writeWindow("process", updates.process);
}
} }
function parseTime(hhmm: string): number { function parseTime(hhmm: string): number {

View File

@@ -1,9 +1,10 @@
import { Badge } from "~/shared/components/ui/badge"; import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api"; import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell"; import { ColumnShell } from "./ColumnShell";
interface DoneColumnProps { interface DoneColumnProps {
items: any[]; items: PipelineJobItem[];
onMutate: () => void; onMutate: () => void;
} }
@@ -19,7 +20,7 @@ export function DoneColumn({ items, onMutate }: DoneColumnProps) {
count={items.length} count={items.length}
action={items.length > 0 ? { label: "Clear", onClick: clear } : undefined} action={items.length > 0 ? { label: "Clear", onClick: clear } : undefined}
> >
{items.map((item: any) => ( {items.map((item) => (
<div key={item.id} className="rounded border bg-white p-2"> <div key={item.id} className="rounded border bg-white p-2">
<p className="text-xs font-medium truncate">{item.name}</p> <p className="text-xs font-medium truncate">{item.name}</p>
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge> <Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>

View File

@@ -1,9 +1,20 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Badge } from "~/shared/components/ui/badge"; import { Badge } from "~/shared/components/ui/badge";
import { LANG_NAMES, langName } from "~/shared/lib/lang"; import { LANG_NAMES, langName } from "~/shared/lib/lang";
import type { PipelineReviewItem } from "~/shared/lib/types";
// Accepts pipeline rows (plan+item) and also raw media_item rows (card is
// reused in a couple of list contexts where no plan is attached yet).
type PipelineCardItem =
| PipelineReviewItem
| (Omit<PipelineReviewItem, "item_id" | "transcode_reasons"> & {
id: number;
item_id?: number;
transcode_reasons?: string[];
});
interface PipelineCardProps { interface PipelineCardProps {
item: any; item: PipelineCardItem;
jellyfinUrl: string; jellyfinUrl: string;
onLanguageChange?: (lang: string) => void; onLanguageChange?: (lang: string) => void;
onApprove?: () => void; onApprove?: () => void;
@@ -23,7 +34,7 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApprove, o
// item.item_id is present in pipeline payloads; card can also be fed raw // item.item_id is present in pipeline payloads; card can also be fed raw
// media_item rows (no plan) in which case we fall back to item.id. // media_item rows (no plan) in which case we fall back to item.id.
const mediaItemId: number = item.item_id ?? item.id; const mediaItemId: number = item.item_id ?? (item as { id: number }).id;
return ( return (
<div className={`rounded-lg border p-3 ${confidenceColor}`}> <div className={`rounded-lg border p-3 ${confidenceColor}`}>
@@ -60,8 +71,8 @@ export function PipelineCard({ item, jellyfinUrl, onLanguageChange, onApprove, o
<Badge variant="default">{langName(item.original_language)}</Badge> <Badge variant="default">{langName(item.original_language)}</Badge>
)} )}
{item.transcode_reasons?.length > 0 {item.transcode_reasons && item.transcode_reasons.length > 0
? item.transcode_reasons.map((r: string) => ( ? item.transcode_reasons.map((r) => (
<Badge key={r} variant="manual"> <Badge key={r} variant="manual">
{r} {r}
</Badge> </Badge>

View File

@@ -1,21 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "~/shared/components/ui/button"; import { Button } from "~/shared/components/ui/button";
import { api } from "~/shared/lib/api"; import { api } from "~/shared/lib/api";
import type { PipelineData } from "~/shared/lib/types";
import { DoneColumn } from "./DoneColumn"; import { DoneColumn } from "./DoneColumn";
import { ProcessingColumn } from "./ProcessingColumn"; import { ProcessingColumn } from "./ProcessingColumn";
import { QueueColumn } from "./QueueColumn"; import { QueueColumn } from "./QueueColumn";
import { ReviewColumn } from "./ReviewColumn"; import { ReviewColumn } from "./ReviewColumn";
interface PipelineData {
review: any[];
reviewTotal: number;
queued: any[];
processing: any[];
done: any[];
doneCount: number;
jellyfinUrl: string;
}
interface Progress { interface Progress {
id: number; id: number;
seconds: number; seconds: number;

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Badge } from "~/shared/components/ui/badge"; import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api"; import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell"; import { ColumnShell } from "./ColumnShell";
interface ProcessingColumnProps { interface ProcessingColumnProps {
items: any[]; items: PipelineJobItem[];
progress?: { id: number; seconds: number; total: number } | null; progress?: { id: number; seconds: number; total: number } | null;
queueStatus?: { status: string; until?: string; seconds?: number } | null; queueStatus?: { status: string; until?: string; seconds?: number } | null;
onMutate: () => void; onMutate: () => void;

View File

@@ -1,9 +1,10 @@
import { Badge } from "~/shared/components/ui/badge"; import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api"; import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell"; import { ColumnShell } from "./ColumnShell";
interface QueueColumnProps { interface QueueColumnProps {
items: any[]; items: PipelineJobItem[];
onMutate: () => void; onMutate: () => void;
} }
@@ -20,7 +21,7 @@ export function QueueColumn({ items, onMutate }: QueueColumnProps) {
count={items.length} count={items.length}
action={items.length > 0 ? { label: "Clear", onClick: clear } : undefined} action={items.length > 0 ? { label: "Clear", onClick: clear } : undefined}
> >
{items.map((item: any) => ( {items.map((item) => (
<div key={item.id} className="rounded border bg-white p-2"> <div key={item.id} className="rounded border bg-white p-2">
<p className="text-xs font-medium truncate">{item.name}</p> <p className="text-xs font-medium truncate">{item.name}</p>
<Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge> <Badge variant={item.job_type === "transcode" ? "manual" : "noop"}>{item.job_type}</Badge>

View File

@@ -1,15 +1,23 @@
import { api } from "~/shared/lib/api"; import { api } from "~/shared/lib/api";
import type { PipelineReviewItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell"; import { ColumnShell } from "./ColumnShell";
import { PipelineCard } from "./PipelineCard"; import { PipelineCard } from "./PipelineCard";
import { SeriesCard } from "./SeriesCard"; import { SeriesCard } from "./SeriesCard";
interface ReviewColumnProps { interface ReviewColumnProps {
items: any[]; items: PipelineReviewItem[];
total: number; total: number;
jellyfinUrl: string; jellyfinUrl: string;
onMutate: () => void; onMutate: () => void;
} }
interface SeriesGroup {
name: string;
key: string;
jellyfinId: string | null;
episodes: PipelineReviewItem[];
}
export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) { export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) {
const truncated = total > items.length; const truncated = total > items.length;
@@ -29,24 +37,24 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
}; };
// Group by series (movies are standalone) // Group by series (movies are standalone)
const movies = items.filter((i: any) => i.type === "Movie"); const movies = items.filter((i) => i.type === "Movie");
const seriesMap = new Map<string, { name: string; key: string; jellyfinId: string | null; episodes: any[] }>(); const seriesMap = new Map<string, SeriesGroup>();
for (const item of items.filter((i: any) => i.type === "Episode")) { for (const item of items.filter((i) => i.type === "Episode")) {
const key = item.series_jellyfin_id ?? item.series_name; const key = item.series_jellyfin_id ?? item.series_name ?? String(item.item_id);
if (!seriesMap.has(key)) { if (!seriesMap.has(key)) {
seriesMap.set(key, { name: item.series_name, key, jellyfinId: item.series_jellyfin_id, episodes: [] }); seriesMap.set(key, { name: item.series_name ?? "", key, jellyfinId: item.series_jellyfin_id, episodes: [] });
} }
seriesMap.get(key)!.episodes.push(item); seriesMap.get(key)!.episodes.push(item);
} }
// Interleave movies and series, sorted by confidence (high first) // Interleave movies and series, sorted by confidence (high first)
const allItems = [ const allItems = [
...movies.map((m: any) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })), ...movies.map((m) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })),
...[...seriesMap.values()].map((s) => ({ ...[...seriesMap.values()].map((s) => ({
type: "series" as const, type: "series" as const,
item: s, item: s,
sortKey: s.episodes.every((e: any) => e.confidence === "high") ? 0 : 1, sortKey: s.episodes.every((e) => e.confidence === "high") ? 0 : 1,
})), })),
].sort((a, b) => a.sortKey - b.sortKey); ].sort((a, b) => a.sortKey - b.sortKey);

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { api } from "~/shared/lib/api"; import { api } from "~/shared/lib/api";
import { LANG_NAMES } from "~/shared/lib/lang"; import { LANG_NAMES } from "~/shared/lib/lang";
import type { PipelineReviewItem } from "~/shared/lib/types";
import { PipelineCard } from "./PipelineCard"; import { PipelineCard } from "./PipelineCard";
interface SeriesCardProps { interface SeriesCardProps {
@@ -8,7 +9,7 @@ interface SeriesCardProps {
seriesName: string; seriesName: string;
jellyfinUrl: string; jellyfinUrl: string;
seriesJellyfinId: string | null; seriesJellyfinId: string | null;
episodes: any[]; episodes: PipelineReviewItem[];
onMutate: () => void; onMutate: () => void;
} }
@@ -34,8 +35,8 @@ export function SeriesCard({
onMutate(); onMutate();
}; };
const highCount = episodes.filter((e: any) => e.confidence === "high").length; const highCount = episodes.filter((e) => e.confidence === "high").length;
const lowCount = episodes.filter((e: any) => e.confidence === "low").length; const lowCount = episodes.filter((e) => e.confidence === "low").length;
const jellyfinLink = const jellyfinLink =
jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null; jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;
@@ -97,7 +98,7 @@ export function SeriesCard({
{expanded && ( {expanded && (
<div className="border-t px-3 pb-3 space-y-2 pt-2"> <div className="border-t px-3 pb-3 space-y-2 pt-2">
{episodes.map((ep: any) => ( {episodes.map((ep) => (
<PipelineCard <PipelineCard
key={ep.id} key={ep.id}
item={ep} item={ep}

View File

@@ -116,6 +116,7 @@ function SortableLanguageList({
type="button" type="button"
disabled={disabled || i === 0} disabled={disabled || i === 0}
onClick={() => move(i, -1)} onClick={() => move(i, -1)}
aria-label={`Move ${label} up`}
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs" className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
> >
@@ -124,6 +125,7 @@ function SortableLanguageList({
type="button" type="button"
disabled={disabled || i === langs.length - 1} disabled={disabled || i === langs.length - 1}
onClick={() => move(i, 1)} onClick={() => move(i, 1)}
aria-label={`Move ${label} down`}
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs" className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
> >
@@ -135,6 +137,7 @@ function SortableLanguageList({
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={() => remove(i)} onClick={() => remove(i)}
aria-label={`Remove ${label}`}
className="text-red-400 hover:text-red-600 text-sm border-0 bg-transparent cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed" className="text-red-400 hover:text-red-600 text-sm border-0 bg-transparent cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
> >
@@ -345,7 +348,14 @@ export function SettingsPage() {
settingsCache = d; settingsCache = d;
setData(d); setData(d);
if (!langsLoadedRef.current) { if (!langsLoadedRef.current) {
setAudLangs(JSON.parse(d.config.audio_languages ?? "[]")); let parsed: string[] = [];
try {
const raw = JSON.parse(d.config.audio_languages ?? "[]");
if (Array.isArray(raw)) parsed = raw.filter((x): x is string => typeof x === "string");
} catch (e) {
console.warn("audio_languages config is not valid JSON, defaulting to []", e);
}
setAudLangs(parsed);
langsLoadedRef.current = true; langsLoadedRef.current = true;
} }
}) })

View File

@@ -89,3 +89,55 @@ export interface Job {
started_at: string | null; started_at: string | null;
completed_at: string | null; completed_at: string | null;
} }
// ─── Pipeline payloads (GET /api/review/pipeline) ────────────────────────────
/** Row in the Review column: review_plan joined with media_item. */
export interface PipelineReviewItem {
// review_plan fields (subset used by UI)
id: number;
item_id: number;
status: string;
is_noop: number;
confidence: "high" | "low";
apple_compat: ReviewPlan["apple_compat"];
job_type: "copy" | "transcode";
// media_item fields
name: string;
series_name: string | null;
series_jellyfin_id: string | null;
jellyfin_id: string;
season_number: number | null;
episode_number: number | null;
type: "Movie" | "Episode";
container: string | null;
original_language: string | null;
orig_lang_source: string | null;
file_path: string;
// computed
transcode_reasons: string[];
}
/** Row in the Queued / Processing / Done columns: job joined with media_item + review_plan. */
export interface PipelineJobItem {
id: number;
item_id: number;
status: Job["status"];
job_type: "copy" | "transcode";
started_at: string | null;
completed_at: string | null;
name: string;
series_name: string | null;
type: "Movie" | "Episode";
apple_compat: ReviewPlan["apple_compat"];
}
export interface PipelineData {
review: PipelineReviewItem[];
reviewTotal: number;
queued: PipelineJobItem[];
processing: PipelineJobItem[];
done: PipelineJobItem[];
doneCount: number;
jellyfinUrl: string;
}