review: lazy-load groups with infinite scroll, nest seasons
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m45s
Client changes paired with the earlier /groups endpoint: - Types: drop review[]/reviewTotal from PipelineData, add ReviewGroup and ReviewGroupsResponse. - PipelinePage: parallel-fetch /pipeline and /groups?offset=0&limit=25. - ReviewColumn: IntersectionObserver on a sentinel div fetches the next page when it scrolls into view. No more "Showing first N of M" banner — the column loads lazily until hasMore is false. - SeriesCard: when a series has pending work in >1 season, render collapsible season sub-groups each with an "Approve season" button wired to POST /season/:key/:season/approve-all. Rename the series button from "Approve all" to "Approve series" for clarity. v2026.04.15.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
73
AUDIT.md
Normal file
73
AUDIT.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Security & Reliability Audit (2026-04-15)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Reviewed server and client codepaths with focus on:
|
||||||
|
- exposed attack surface
|
||||||
|
- secret handling
|
||||||
|
- destructive endpoints
|
||||||
|
- job execution safety
|
||||||
|
- input validation consistency
|
||||||
|
|
||||||
|
## Findings (Highest Severity First)
|
||||||
|
|
||||||
|
### 1. Critical: Real service credentials are committed in the repository
|
||||||
|
- Evidence:
|
||||||
|
- `.env.development:6` (`JELLYFIN_API_KEY=...`)
|
||||||
|
- `.env.development:10` (`RADARR_API_KEY=...`)
|
||||||
|
- `.env.development:15` (`SONARR_API_KEY=...`)
|
||||||
|
- Impact:
|
||||||
|
- Anyone with repository access can use these keys against the referenced services.
|
||||||
|
- Secrets are now considered compromised and must be rotated.
|
||||||
|
- Fix:
|
||||||
|
- Rotate all exposed API keys immediately.
|
||||||
|
- Remove `.env.development` from Git history or sanitize it.
|
||||||
|
- Keep only `.env.example` in version control.
|
||||||
|
|
||||||
|
### 2. High: No authentication/authorization on privileged API routes
|
||||||
|
- Evidence:
|
||||||
|
- `server/index.tsx:37` to `server/index.tsx:43` mounts all admin routes without auth middleware.
|
||||||
|
- Destructive/control endpoints are publicly callable by any client that can reach port 3000, e.g.:
|
||||||
|
- `server/api/settings.ts:204` (`POST /api/settings/reset`)
|
||||||
|
- `server/api/settings.ts:183` (`POST /api/settings/clear-scan`)
|
||||||
|
- `server/api/execute.ts:150` (`POST /api/execute/start`)
|
||||||
|
- `server/api/execute.ts:209` (`POST /api/execute/stop`)
|
||||||
|
- Impact:
|
||||||
|
- Unauthorized users can start/stop jobs, wipe state, and alter processing behavior.
|
||||||
|
- In containerized deployments with published ports, this is remotely exploitable on the network.
|
||||||
|
- Fix:
|
||||||
|
- Add auth middleware at `/api/*` (at minimum: token-based admin auth).
|
||||||
|
- Gate destructive routes with explicit admin authorization checks.
|
||||||
|
|
||||||
|
### 3. High: Settings endpoint leaks secrets in cleartext
|
||||||
|
- Evidence:
|
||||||
|
- `server/api/settings.ts:11` to `server/api/settings.ts:15` returns `getAllConfig()` directly.
|
||||||
|
- `getAllConfig()` includes API keys and passwords from DB/env via `server/db/index.ts:123` to `server/db/index.ts:134`.
|
||||||
|
- Impact:
|
||||||
|
- Any caller with API access can retrieve Jellyfin/Radarr/Sonarr API keys and MQTT password.
|
||||||
|
- Fix:
|
||||||
|
- Redact secrets in responses (e.g. `***` with optional last-4 chars).
|
||||||
|
- Add a separate write-only secret update flow.
|
||||||
|
|
||||||
|
### 4. Medium: Inconsistent route ID validation in execute API
|
||||||
|
- Evidence:
|
||||||
|
- `server/lib/validate.ts:8` provides strict numeric `parseId`.
|
||||||
|
- `server/api/execute.ts:142` defines a looser local parser using `Number.parseInt`.
|
||||||
|
- Impact:
|
||||||
|
- Values like `"12abc"` are accepted as ID `12` on execute routes, which can target unintended jobs.
|
||||||
|
- Fix:
|
||||||
|
- Reuse `server/lib/validate.ts` `parseId` in `server/api/execute.ts`.
|
||||||
|
- Add route tests for mixed alphanumeric IDs (`"42abc"`, `"+1"`, etc.).
|
||||||
|
|
||||||
|
## Testing & Verification Gaps
|
||||||
|
|
||||||
|
- Could not run `bun test` / `bun lint` in this environment because `bun` is not installed.
|
||||||
|
- Existing tests cover some parser behavior in `server/lib/__tests__/validate.test.ts`, but execute-route param parsing has no dedicated regression test.
|
||||||
|
|
||||||
|
## Recommended Remediation Order
|
||||||
|
|
||||||
|
1. Rotate leaked credentials and sanitize repository history.
|
||||||
|
2. Introduce API authentication and enforce it on all `/api/*` routes.
|
||||||
|
3. Redact secret fields from settings responses.
|
||||||
|
4. Replace execute-local `parseId` with shared strict validator and add tests.
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "netfelix-audio-fix",
|
"name": "netfelix-audio-fix",
|
||||||
"version": "2026.04.15.2",
|
"version": "2026.04.15.3",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api } from "~/shared/lib/api";
|
import { api } from "~/shared/lib/api";
|
||||||
import type { PipelineData } from "~/shared/lib/types";
|
import type { PipelineData, ReviewGroupsResponse } 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";
|
||||||
@@ -20,13 +20,18 @@ interface QueueStatus {
|
|||||||
|
|
||||||
export function PipelinePage() {
|
export function PipelinePage() {
|
||||||
const [data, setData] = useState<PipelineData | null>(null);
|
const [data, setData] = useState<PipelineData | null>(null);
|
||||||
|
const [initialGroups, setInitialGroups] = useState<ReviewGroupsResponse | null>(null);
|
||||||
const [progress, setProgress] = useState<Progress | null>(null);
|
const [progress, setProgress] = useState<Progress | null>(null);
|
||||||
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null);
|
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const pipelineRes = await api.get<PipelineData>("/api/review/pipeline");
|
const [pipelineRes, groupsRes] = await Promise.all([
|
||||||
|
api.get<PipelineData>("/api/review/pipeline"),
|
||||||
|
api.get<ReviewGroupsResponse>("/api/review/groups?offset=0&limit=25"),
|
||||||
|
]);
|
||||||
setData(pipelineRes);
|
setData(pipelineRes);
|
||||||
|
setInitialGroups(groupsRes);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -70,7 +75,7 @@ export function PipelinePage() {
|
|||||||
};
|
};
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
if (loading || !data) return <div className="p-6 text-gray-500">Loading pipeline...</div>;
|
if (loading || !data || !initialGroups) return <div className="p-6 text-gray-500">Loading pipeline...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col -mx-3 sm:-mx-5 -mt-4 -mb-12 h-[calc(100vh-3rem)] overflow-hidden">
|
<div className="flex flex-col -mx-3 sm:-mx-5 -mt-4 -mb-12 h-[calc(100vh-3rem)] overflow-hidden">
|
||||||
@@ -79,7 +84,12 @@ export function PipelinePage() {
|
|||||||
<span className="text-sm text-gray-500">{data.doneCount} files in desired state</span>
|
<span className="text-sm text-gray-500">{data.doneCount} files in desired state</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0">
|
<div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0">
|
||||||
<ReviewColumn items={data.review} total={data.reviewTotal} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
|
<ReviewColumn
|
||||||
|
initialResponse={initialGroups}
|
||||||
|
totalItems={data.reviewItemsTotal}
|
||||||
|
jellyfinUrl={data.jellyfinUrl}
|
||||||
|
onMutate={load}
|
||||||
|
/>
|
||||||
<QueueColumn items={data.queued} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
|
<QueueColumn items={data.queued} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
|
||||||
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} onMutate={load} />
|
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} onMutate={load} />
|
||||||
<DoneColumn items={data.done} onMutate={load} />
|
<DoneColumn items={data.done} onMutate={load} />
|
||||||
|
|||||||
@@ -1,28 +1,57 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api } from "~/shared/lib/api";
|
import { api } from "~/shared/lib/api";
|
||||||
import type { PipelineReviewItem } from "~/shared/lib/types";
|
import type { ReviewGroup, ReviewGroupsResponse } 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";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
interface ReviewColumnProps {
|
interface ReviewColumnProps {
|
||||||
items: PipelineReviewItem[];
|
initialResponse: ReviewGroupsResponse;
|
||||||
total: number;
|
totalItems: number;
|
||||||
jellyfinUrl: string;
|
jellyfinUrl: string;
|
||||||
onMutate: () => void;
|
onMutate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SeriesGroup {
|
export function ReviewColumn({ initialResponse, totalItems, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
||||||
name: string;
|
const [groups, setGroups] = useState<ReviewGroup[]>(initialResponse.groups);
|
||||||
key: string;
|
const [hasMore, setHasMore] = useState(initialResponse.hasMore);
|
||||||
jellyfinId: string | null;
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
episodes: PipelineReviewItem[];
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
}
|
|
||||||
|
|
||||||
export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
// Reset when the parent refetches page 0 (after approve/skip actions).
|
||||||
const truncated = total > items.length;
|
useEffect(() => {
|
||||||
|
setGroups(initialResponse.groups);
|
||||||
|
setHasMore(initialResponse.hasMore);
|
||||||
|
}, [initialResponse]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (loadingMore || !hasMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<ReviewGroupsResponse>(`/api/review/groups?offset=${groups.length}&limit=${PAGE_SIZE}`);
|
||||||
|
setGroups((prev) => [...prev, ...res.groups]);
|
||||||
|
setHasMore(res.hasMore);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [groups.length, hasMore, loadingMore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMore || !sentinelRef.current) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) loadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" },
|
||||||
|
);
|
||||||
|
observer.observe(sentinelRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMore, loadMore]);
|
||||||
|
|
||||||
const skipAll = async () => {
|
const skipAll = async () => {
|
||||||
if (!confirm(`Skip all ${total} pending items? They won't be processed unless you unskip them.`)) return;
|
if (!confirm(`Skip all ${totalItems} pending items? They won't be processed unless you unskip them.`)) return;
|
||||||
await api.post("/api/review/skip-all");
|
await api.post("/api/review/skip-all");
|
||||||
onMutate();
|
onMutate();
|
||||||
};
|
};
|
||||||
@@ -47,89 +76,62 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
|
|||||||
onMutate();
|
onMutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group by series (movies are standalone)
|
// Compute ids per visible group for "Approve above"
|
||||||
const movies = items.filter((i) => i.type === "Movie");
|
const idsByGroup: number[][] = groups.map((g) =>
|
||||||
const seriesMap = new Map<string, SeriesGroup>();
|
g.kind === "movie" ? [g.item.item_id] : g.seasons.flatMap((s) => s.episodes.map((ep) => ep.item_id)),
|
||||||
|
|
||||||
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.get(key)!.episodes.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interleave movies and series, sorted by confidence (high first)
|
|
||||||
const allItems = [
|
|
||||||
...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) => e.confidence === "high") ? 0 : 1,
|
|
||||||
})),
|
|
||||||
].sort((a, b) => a.sortKey - b.sortKey);
|
|
||||||
|
|
||||||
// Flatten each visible entry to its list of item_ids. "Approve up to here"
|
|
||||||
// on index i approves everything in the union of idsByEntry[0..i-1] — one
|
|
||||||
// id for a movie, N ids for a series (one per episode).
|
|
||||||
const idsByEntry: number[][] = allItems.map((entry) =>
|
|
||||||
entry.type === "movie" ? [entry.item.item_id] : entry.item.episodes.map((e) => e.item_id),
|
|
||||||
);
|
);
|
||||||
const priorIds = (index: number): number[] => idsByEntry.slice(0, index).flat();
|
const priorIds = (index: number): number[] => idsByGroup.slice(0, index).flat();
|
||||||
|
|
||||||
return (
|
const actions =
|
||||||
<ColumnShell
|
totalItems > 0
|
||||||
title="Review"
|
|
||||||
count={truncated ? `${items.length} of ${total}` : total}
|
|
||||||
actions={
|
|
||||||
total > 0
|
|
||||||
? [
|
? [
|
||||||
{ label: "Auto Review", onClick: autoApprove, primary: true },
|
{ label: "Auto Review", onClick: autoApprove, primary: true },
|
||||||
{ label: "Skip all", onClick: skipAll },
|
{ label: "Skip all", onClick: skipAll },
|
||||||
]
|
]
|
||||||
: undefined
|
: undefined;
|
||||||
}
|
|
||||||
>
|
return (
|
||||||
|
<ColumnShell title="Review" count={totalItems} actions={actions}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{allItems.map((entry, index) => {
|
{groups.map((group, index) => {
|
||||||
// The button approves everything visually above this card. First
|
|
||||||
// card has nothing before it → undefined suppresses the affordance.
|
|
||||||
const prior = index > 0 ? priorIds(index) : null;
|
const prior = index > 0 ? priorIds(index) : null;
|
||||||
const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined;
|
const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined;
|
||||||
if (entry.type === "movie") {
|
if (group.kind === "movie") {
|
||||||
return (
|
return (
|
||||||
<PipelineCard
|
<PipelineCard
|
||||||
key={entry.item.id}
|
key={group.item.id}
|
||||||
item={entry.item}
|
item={group.item}
|
||||||
jellyfinUrl={jellyfinUrl}
|
jellyfinUrl={jellyfinUrl}
|
||||||
onToggleStream={async (streamId, action) => {
|
onToggleStream={async (streamId, action) => {
|
||||||
await api.patch(`/api/review/${entry.item.item_id}/stream/${streamId}`, { action });
|
await api.patch(`/api/review/${group.item.item_id}/stream/${streamId}`, { action });
|
||||||
onMutate();
|
onMutate();
|
||||||
}}
|
}}
|
||||||
onApprove={() => approveItem(entry.item.item_id)}
|
onApprove={() => approveItem(group.item.item_id)}
|
||||||
onSkip={() => skipItem(entry.item.item_id)}
|
onSkip={() => skipItem(group.item.item_id)}
|
||||||
onApproveUpToHere={onApproveUpToHere}
|
onApproveUpToHere={onApproveUpToHere}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<SeriesCard
|
<SeriesCard
|
||||||
key={entry.item.key}
|
key={group.seriesKey}
|
||||||
seriesKey={entry.item.key}
|
seriesKey={group.seriesKey}
|
||||||
seriesName={entry.item.name}
|
seriesName={group.seriesName}
|
||||||
jellyfinUrl={jellyfinUrl}
|
jellyfinUrl={jellyfinUrl}
|
||||||
seriesJellyfinId={entry.item.jellyfinId}
|
seriesJellyfinId={group.seriesJellyfinId}
|
||||||
episodes={entry.item.episodes}
|
seasons={group.seasons}
|
||||||
|
episodeCount={group.episodeCount}
|
||||||
|
originalLanguage={group.originalLanguage}
|
||||||
onMutate={onMutate}
|
onMutate={onMutate}
|
||||||
onApproveUpToHere={onApproveUpToHere}
|
onApproveUpToHere={onApproveUpToHere}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{allItems.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
{groups.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||||
{truncated && (
|
{hasMore && (
|
||||||
<p className="text-xs text-gray-400 text-center py-3 border-t mt-2">
|
<div ref={sentinelRef} className="py-4 text-center text-xs text-gray-400">
|
||||||
Showing first {items.length} of {total}. Approve some to see the rest.
|
{loadingMore ? "Loading more…" : ""}
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ColumnShell>
|
</ColumnShell>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ interface SeriesCardProps {
|
|||||||
seriesName: string;
|
seriesName: string;
|
||||||
jellyfinUrl: string;
|
jellyfinUrl: string;
|
||||||
seriesJellyfinId: string | null;
|
seriesJellyfinId: string | null;
|
||||||
episodes: PipelineReviewItem[];
|
seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
|
||||||
|
episodeCount: number;
|
||||||
|
originalLanguage: string | null;
|
||||||
onMutate: () => void;
|
onMutate: () => void;
|
||||||
// Review-column affordance: approve every card visually above this
|
// Review-column affordance: approve every card visually above this
|
||||||
// series in one round-trip. See ReviewColumn for the id computation.
|
// series in one round-trip. See ReviewColumn for the id computation.
|
||||||
@@ -21,13 +23,18 @@ export function SeriesCard({
|
|||||||
seriesName,
|
seriesName,
|
||||||
jellyfinUrl,
|
jellyfinUrl,
|
||||||
seriesJellyfinId,
|
seriesJellyfinId,
|
||||||
episodes,
|
seasons,
|
||||||
|
episodeCount,
|
||||||
|
originalLanguage,
|
||||||
onMutate,
|
onMutate,
|
||||||
onApproveUpToHere,
|
onApproveUpToHere,
|
||||||
}: SeriesCardProps) {
|
}: SeriesCardProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const seriesLang = episodes[0]?.original_language ?? "";
|
const flatEpisodes = seasons.flatMap((s) => s.episodes);
|
||||||
|
const highCount = flatEpisodes.filter((e) => e.confidence === "high").length;
|
||||||
|
const lowCount = flatEpisodes.filter((e) => e.confidence === "low").length;
|
||||||
|
const multipleSeasons = seasons.length > 1;
|
||||||
|
|
||||||
const setSeriesLanguage = async (lang: string) => {
|
const setSeriesLanguage = async (lang: string) => {
|
||||||
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
|
await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
|
||||||
@@ -39,8 +46,11 @@ export function SeriesCard({
|
|||||||
onMutate();
|
onMutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const highCount = episodes.filter((e) => e.confidence === "high").length;
|
const approveSeason = async (season: number | null) => {
|
||||||
const lowCount = episodes.filter((e) => e.confidence === "low").length;
|
if (season == null) return;
|
||||||
|
await api.post(`/api/review/season/${encodeURIComponent(seriesKey)}/${season}/approve-all`);
|
||||||
|
onMutate();
|
||||||
|
};
|
||||||
|
|
||||||
const jellyfinLink =
|
const jellyfinLink =
|
||||||
jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;
|
jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;
|
||||||
@@ -70,13 +80,14 @@ export function SeriesCard({
|
|||||||
|
|
||||||
{/* Controls row */}
|
{/* Controls row */}
|
||||||
<div className="flex items-center gap-2 px-3 pb-3 pt-1">
|
<div className="flex items-center gap-2 px-3 pb-3 pt-1">
|
||||||
<span className="text-xs text-gray-500 shrink-0">{episodes.length} eps</span>
|
<span className="text-xs text-gray-500 shrink-0">{episodeCount} eps</span>
|
||||||
|
{multipleSeasons && <span className="text-xs text-gray-500 shrink-0">· {seasons.length} seasons</span>}
|
||||||
{highCount > 0 && <span className="text-xs text-green-600 shrink-0">{highCount} ready</span>}
|
{highCount > 0 && <span className="text-xs text-green-600 shrink-0">{highCount} ready</span>}
|
||||||
{lowCount > 0 && <span className="text-xs text-amber-600 shrink-0">{lowCount} review</span>}
|
{lowCount > 0 && <span className="text-xs text-amber-600 shrink-0">{lowCount} review</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<select
|
<select
|
||||||
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
|
className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
|
||||||
value={seriesLang}
|
value={originalLanguage ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSeriesLanguage(e.target.value);
|
setSeriesLanguage(e.target.value);
|
||||||
@@ -91,6 +102,7 @@ export function SeriesCard({
|
|||||||
</select>
|
</select>
|
||||||
{onApproveUpToHere && (
|
{onApproveUpToHere && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onApproveUpToHere();
|
onApproveUpToHere();
|
||||||
@@ -102,21 +114,100 @@ export function SeriesCard({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
approveSeries();
|
approveSeries();
|
||||||
}}
|
}}
|
||||||
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap shrink-0"
|
className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap shrink-0"
|
||||||
>
|
>
|
||||||
Approve all
|
Approve series
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t px-3 pb-3 space-y-2 pt-2">
|
<div className="border-t">
|
||||||
|
{multipleSeasons
|
||||||
|
? seasons.map((s) => (
|
||||||
|
<SeasonGroup
|
||||||
|
key={s.season ?? "unknown"}
|
||||||
|
season={s.season}
|
||||||
|
episodes={s.episodes}
|
||||||
|
jellyfinUrl={jellyfinUrl}
|
||||||
|
onApproveSeason={() => approveSeason(s.season)}
|
||||||
|
onMutate={onMutate}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: flatEpisodes.map((ep) => <EpisodeRow key={ep.id} ep={ep} jellyfinUrl={jellyfinUrl} onMutate={onMutate} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeasonGroup({
|
||||||
|
season,
|
||||||
|
episodes,
|
||||||
|
jellyfinUrl,
|
||||||
|
onApproveSeason,
|
||||||
|
onMutate,
|
||||||
|
}: {
|
||||||
|
season: number | null;
|
||||||
|
episodes: PipelineReviewItem[];
|
||||||
|
jellyfinUrl: string;
|
||||||
|
onApproveSeason: () => void;
|
||||||
|
onMutate: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const highCount = episodes.filter((e) => e.confidence === "high").length;
|
||||||
|
const lowCount = episodes.filter((e) => e.confidence === "low").length;
|
||||||
|
const label = season == null ? "No season" : `Season ${String(season).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t first:border-t-0">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-gray-50" onClick={() => setOpen(!open)}>
|
||||||
|
<span className="text-xs text-gray-400 shrink-0">{open ? "▼" : "▶"}</span>
|
||||||
|
<span className="text-xs font-medium shrink-0">{label}</span>
|
||||||
|
<span className="text-xs text-gray-500 shrink-0">· {episodes.length} eps</span>
|
||||||
|
{highCount > 0 && <span className="text-xs text-green-600 shrink-0">{highCount} ready</span>}
|
||||||
|
{lowCount > 0 && <span className="text-xs text-amber-600 shrink-0">{lowCount} review</span>}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{season != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApproveSeason();
|
||||||
|
}}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 cursor-pointer whitespace-nowrap shrink-0"
|
||||||
|
>
|
||||||
|
Approve season
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="px-3 pb-3 space-y-2 pt-2">
|
||||||
{episodes.map((ep) => (
|
{episodes.map((ep) => (
|
||||||
|
<EpisodeRow key={ep.id} ep={ep} jellyfinUrl={jellyfinUrl} onMutate={onMutate} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EpisodeRow({
|
||||||
|
ep,
|
||||||
|
jellyfinUrl,
|
||||||
|
onMutate,
|
||||||
|
}: {
|
||||||
|
ep: PipelineReviewItem;
|
||||||
|
jellyfinUrl: string;
|
||||||
|
onMutate: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-1">
|
||||||
<PipelineCard
|
<PipelineCard
|
||||||
key={ep.id}
|
|
||||||
item={ep}
|
item={ep}
|
||||||
jellyfinUrl={jellyfinUrl}
|
jellyfinUrl={jellyfinUrl}
|
||||||
onToggleStream={async (streamId, action) => {
|
onToggleStream={async (streamId, action) => {
|
||||||
@@ -132,9 +223,6 @@ export function SeriesCard({
|
|||||||
onMutate();
|
onMutate();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,11 +160,32 @@ export interface PipelineJobItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PipelineData {
|
export interface PipelineData {
|
||||||
review: PipelineReviewItem[];
|
reviewItemsTotal: number;
|
||||||
reviewTotal: number;
|
|
||||||
queued: PipelineJobItem[];
|
queued: PipelineJobItem[];
|
||||||
processing: PipelineJobItem[];
|
processing: PipelineJobItem[];
|
||||||
done: PipelineJobItem[];
|
done: PipelineJobItem[];
|
||||||
doneCount: number;
|
doneCount: number;
|
||||||
jellyfinUrl: string;
|
jellyfinUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Review groups (GET /api/review/groups) ──────────────────────────────────
|
||||||
|
|
||||||
|
export type ReviewGroup =
|
||||||
|
| { kind: "movie"; item: PipelineReviewItem }
|
||||||
|
| {
|
||||||
|
kind: "series";
|
||||||
|
seriesKey: string;
|
||||||
|
seriesName: string;
|
||||||
|
seriesJellyfinId: string | null;
|
||||||
|
episodeCount: number;
|
||||||
|
minConfidence: "high" | "low";
|
||||||
|
originalLanguage: string | null;
|
||||||
|
seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReviewGroupsResponse {
|
||||||
|
groups: ReviewGroup[];
|
||||||
|
totalGroups: number;
|
||||||
|
totalItems: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user