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",
"version": "2026.04.13.9",
"version": "2026.04.13.10",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",

View File

@@ -95,7 +95,11 @@ app.get("/schedule", (c) => {
app.patch("/schedule", async (c) => {
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());
});

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 {
if (updates.job_sleep_seconds != null) setConfig("job_sleep_seconds", String(updates.job_sleep_seconds));
if (updates.scan) writeWindow("scan", updates.scan);
if (updates.process) writeWindow("process", updates.process);
if (updates.job_sleep_seconds != null) {
const n = updates.job_sleep_seconds;
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 {

View File

@@ -1,9 +1,10 @@
import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
interface DoneColumnProps {
items: any[];
items: PipelineJobItem[];
onMutate: () => void;
}
@@ -19,7 +20,7 @@ export function DoneColumn({ items, onMutate }: DoneColumnProps) {
count={items.length}
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">
<p className="text-xs font-medium truncate">{item.name}</p>
<Badge variant={item.status === "done" ? "done" : "error"}>{item.status}</Badge>

View File

@@ -1,9 +1,20 @@
import { Link } from "@tanstack/react-router";
import { Badge } from "~/shared/components/ui/badge";
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 {
item: any;
item: PipelineCardItem;
jellyfinUrl: string;
onLanguageChange?: (lang: string) => 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
// 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 (
<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>
)}
{item.transcode_reasons?.length > 0
? item.transcode_reasons.map((r: string) => (
{item.transcode_reasons && item.transcode_reasons.length > 0
? item.transcode_reasons.map((r) => (
<Badge key={r} variant="manual">
{r}
</Badge>

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import { Badge } from "~/shared/components/ui/badge";
import { api } from "~/shared/lib/api";
import type { PipelineJobItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
interface QueueColumnProps {
items: any[];
items: PipelineJobItem[];
onMutate: () => void;
}
@@ -20,7 +21,7 @@ export function QueueColumn({ items, onMutate }: QueueColumnProps) {
count={items.length}
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">
<p className="text-xs font-medium truncate">{item.name}</p>
<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 type { PipelineReviewItem } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
import { PipelineCard } from "./PipelineCard";
import { SeriesCard } from "./SeriesCard";
interface ReviewColumnProps {
items: any[];
items: PipelineReviewItem[];
total: number;
jellyfinUrl: string;
onMutate: () => void;
}
interface SeriesGroup {
name: string;
key: string;
jellyfinId: string | null;
episodes: PipelineReviewItem[];
}
export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) {
const truncated = total > items.length;
@@ -29,24 +37,24 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
};
// Group by series (movies are standalone)
const movies = items.filter((i: any) => i.type === "Movie");
const seriesMap = new Map<string, { name: string; key: string; jellyfinId: string | null; episodes: any[] }>();
const movies = items.filter((i) => i.type === "Movie");
const seriesMap = new Map<string, SeriesGroup>();
for (const item of items.filter((i: any) => i.type === "Episode")) {
const key = item.series_jellyfin_id ?? item.series_name;
for (const item of items.filter((i) => i.type === "Episode")) {
const key = item.series_jellyfin_id ?? item.series_name ?? String(item.item_id);
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);
}
// Interleave movies and series, sorted by confidence (high first)
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) => ({
type: "series" as const,
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);

View File

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

View File

@@ -116,6 +116,7 @@ function SortableLanguageList({
type="button"
disabled={disabled || i === 0}
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"
>
@@ -124,6 +125,7 @@ function SortableLanguageList({
type="button"
disabled={disabled || i === langs.length - 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"
>
@@ -135,6 +137,7 @@ function SortableLanguageList({
type="button"
disabled={disabled}
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"
>
@@ -345,7 +348,14 @@ export function SettingsPage() {
settingsCache = d;
setData(d);
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;
}
})

View File

@@ -89,3 +89,55 @@ export interface Job {
started_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;
}