add collapsible series/season/episode view to subtitle list page
All checks were successful
Build and Push Docker Image / build (push) Successful in 21s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 10:22:56 +01:00
parent f562cb42d9
commit 923f9afafc
2 changed files with 275 additions and 62 deletions

View File

@@ -7,6 +7,22 @@ import { unlinkSync } from 'node:fs';
const app = new Hono();
// ─── Types ───────────────────────────────────────────────────────────────────
interface SubListItem {
id: number; jellyfin_id: string; type: string; name: string;
series_name: string | null; season_number: number | null;
episode_number: number | null; year: number | null;
original_language: string | null; file_path: string;
subs_extracted: number | null; sub_count: number; file_count: number;
}
interface SubSeriesGroup {
series_key: string; series_name: string; original_language: string | null;
season_count: number; episode_count: number;
not_extracted_count: number; extracted_count: number; no_subs_count: number;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
@@ -35,18 +51,22 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
// ─── List ────────────────────────────────────────────────────────────────────
function buildSubWhere(filter: string): string {
switch (filter) {
case 'not_extracted': return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0";
case 'extracted': return "rp.subs_extracted = 1";
case 'no_subs': return "sub_count = 0";
default: return '1=1';
}
}
app.get('/', (c) => {
const db = getDb();
const filter = c.req.query('filter') ?? 'all';
const where = buildSubWhere(filter);
let where = '1=1';
switch (filter) {
case 'not_extracted': where = 'rp.subs_extracted = 0 AND sub_count > 0'; break;
case 'extracted': where = 'rp.subs_extracted = 1'; break;
case 'no_subs': where = 'sub_count = 0'; break;
}
const rows = db.prepare(`
// Movies
const movieRows = db.prepare(`
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
mi.episode_number, mi.year, mi.original_language, mi.file_path,
rp.subs_extracted,
@@ -54,14 +74,30 @@ app.get('/', (c) => {
(SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count
FROM media_items mi
LEFT JOIN review_plans rp ON rp.item_id = mi.id
WHERE mi.type = 'Movie' AND ${where}
ORDER BY mi.name LIMIT 500
`).all() as SubListItem[];
// Series groups
const series = db.prepare(`
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key,
mi.series_name,
MAX(mi.original_language) as original_language,
COUNT(DISTINCT mi.season_number) as season_count,
COUNT(mi.id) as episode_count,
SUM(CASE WHEN sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0 THEN 1 ELSE 0 END) as not_extracted_count,
SUM(CASE WHEN rp.subs_extracted = 1 THEN 1 ELSE 0 END) as extracted_count,
SUM(CASE WHEN sub_count = 0 THEN 1 ELSE 0 END) as no_subs_count
FROM (
SELECT mi.*,
(SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count
FROM media_items mi
WHERE mi.type = 'Episode'
) mi
LEFT JOIN review_plans rp ON rp.item_id = mi.id
WHERE ${where}
ORDER BY mi.name
LIMIT 500
`).all() as (Pick<MediaItem, 'id' | 'jellyfin_id' | 'type' | 'name' | 'series_name' | 'season_number' | 'episode_number' | 'year' | 'original_language' | 'file_path'> & {
subs_extracted: number | null;
sub_count: number;
file_count: number;
})[];
GROUP BY series_key ORDER BY mi.series_name
`).all() as SubSeriesGroup[];
const totalAll = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
const totalExtracted = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE subs_extracted = 1').get() as { n: number }).n;
@@ -72,12 +108,52 @@ app.get('/', (c) => {
const totalNotExtracted = totalAll - totalExtracted - totalNoSubs;
return c.json({
items: rows,
movies: movieRows,
series,
filter,
totalCounts: { all: totalAll, not_extracted: totalNotExtracted, extracted: totalExtracted, no_subs: totalNoSubs },
});
});
// ─── Series episodes (subtitles) ─────────────────────────────────────────────
app.get('/series/:seriesKey/episodes', (c) => {
const db = getDb();
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
const rows = db.prepare(`
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
mi.episode_number, mi.year, mi.original_language, mi.file_path,
rp.subs_extracted,
(SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count,
(SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count
FROM media_items mi
LEFT JOIN review_plans rp ON rp.item_id = mi.id
WHERE mi.type = 'Episode'
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
ORDER BY mi.season_number, mi.episode_number
`).all(seriesKey, seriesKey) as SubListItem[];
const seasonMap = new Map<number | null, SubListItem[]>();
for (const r of rows) {
const season = r.season_number ?? null;
if (!seasonMap.has(season)) seasonMap.set(season, []);
seasonMap.get(season)!.push(r);
}
const seasons = Array.from(seasonMap.entries())
.sort(([a], [b]) => (a ?? -1) - (b ?? -1))
.map(([season, episodes]) => ({
season,
episodes,
extractedCount: episodes.filter((e) => e.subs_extracted === 1).length,
notExtractedCount: episodes.filter((e) => e.sub_count > 0 && !e.subs_extracted).length,
noSubsCount: episodes.filter((e) => e.sub_count === 0).length,
}));
return c.json({ seasons });
});
// ─── Detail ──────────────────────────────────────────────────────────────────
app.get('/:id', (c) => {

View File

@@ -4,22 +4,35 @@ import { api } from '~/shared/lib/api';
import { Badge } from '~/shared/components/ui/badge';
import { Button } from '~/shared/components/ui/button';
import { langName } from '~/shared/lib/lang';
import type { MediaItem } from '~/shared/lib/types';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SubtitleListItem extends Pick<MediaItem, 'id' | 'jellyfin_id' | 'type' | 'name' | 'series_name' | 'season_number' | 'episode_number' | 'year' | 'original_language' | 'file_path'> {
subs_extracted: number | null;
sub_count: number;
file_count: number;
interface SubListItem {
id: number; name: string; type: string; series_name: string | null;
season_number: number | null; episode_number: number | null;
year: number | null; original_language: string | null;
subs_extracted: number | null; sub_count: number; file_count: number;
}
interface SubtitleListData {
items: SubtitleListItem[];
interface SubSeriesGroup {
series_key: string; series_name: string; original_language: string | null;
season_count: number; episode_count: number;
not_extracted_count: number; extracted_count: number; no_subs_count: number;
}
interface SubListData {
movies: SubListItem[];
series: SubSeriesGroup[];
filter: string;
totalCounts: Record<string, number>;
}
interface SeasonGroup {
season: number | null;
episodes: SubListItem[];
extractedCount: number; notExtractedCount: number; noSubsCount: number;
}
const FILTER_TABS = [
{ key: 'all', label: 'All' },
{ key: 'not_extracted', label: 'Not Extracted' },
@@ -27,17 +40,130 @@ const FILTER_TABS = [
{ key: 'no_subs', label: 'No Subtitles' },
];
// ─── Helpers ──────────────────────────────────────────────────────────────────
const Th = ({ children }: { children: React.ReactNode }) => (
<th className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">
{children}
</th>
);
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ''}`}>{children}</td>
);
function subStatus(item: SubListItem): 'extracted' | 'not_extracted' | 'no_subs' {
if (item.sub_count === 0) return 'no_subs';
return item.subs_extracted ? 'extracted' : 'not_extracted';
}
function StatusBadge({ item }: { item: SubListItem }) {
const s = subStatus(item);
if (s === 'extracted') return <Badge variant="keep">extracted</Badge>;
if (s === 'not_extracted') return <Badge variant="pending">pending</Badge>;
return <Badge variant="noop">no subs</Badge>;
}
// ─── Status pills ─────────────────────────────────────────────────────────────
function StatusPills({ g }: { g: SubSeriesGroup }) {
return (
<span className="inline-flex flex-wrap gap-1 items-center">
{g.extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">{g.extracted_count} extracted</span>}
{g.not_extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.not_extracted_count} pending</span>}
{g.no_subs_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.no_subs_count} no subs</span>}
</span>
);
}
// ─── Series row (collapsible) ─────────────────────────────────────────────────
function SeriesRow({ g }: { g: SubSeriesGroup }) {
const [open, setOpen] = useState(false);
const urlKey = encodeURIComponent(g.series_key);
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
const toggle = async () => {
if (!open && seasons === null) {
const data = await api.get<{ seasons: SeasonGroup[] }>(`/api/subtitles/series/${urlKey}/episodes`);
setSeasons(data.seasons);
}
setOpen((v) => !v);
};
return (
<tbody>
<tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
<td className="py-1.5 px-2 border-b border-gray-100 font-medium">
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? 'rotate-90' : ''}`}></span>
{' '}<strong>{g.series_name}</strong>
</td>
<Td>{langName(g.original_language)}</Td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
<Td><StatusPills g={g} /></Td>
</tr>
{open && seasons && (
<tr>
<td colSpan={5} className="p-0 border-b border-gray-100">
<table className="w-full border-collapse">
<tbody>
{seasons.map((s) => (
<>
<tr key={`season-${s.season}`} className="bg-gray-50">
<td colSpan={5} className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100">
Season {s.season ?? '?'}
<span className="ml-3 inline-flex gap-1">
{s.extractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">{s.extractedCount} extracted</span>}
{s.notExtractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">{s.notExtractedCount} pending</span>}
{s.noSubsCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">{s.noSubsCount} no subs</span>}
</span>
</td>
</tr>
{s.episodes.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
<span className="text-gray-400 font-mono text-xs">E{String(item.episode_number ?? 0).padStart(2, '0')}</span>
{' '}
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
</Link>
</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.sub_count}</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.file_count}</td>
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
<StatusBadge item={item} />
</td>
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap">
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
Detail
</Link>
</td>
</tr>
))}
</>
))}
</tbody>
</table>
</td>
</tr>
)}
</tbody>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function SubtitleListPage() {
const { filter } = useSearch({ from: '/review/subtitles/' });
const navigate = useNavigate();
const [data, setData] = useState<SubtitleListData | null>(null);
const [data, setData] = useState<SubListData | null>(null);
const [loading, setLoading] = useState(true);
const [extracting, setExtracting] = useState(false);
const [extractResult, setExtractResult] = useState('');
const load = () => api.get<SubtitleListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
const load = () => api.get<SubListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
useEffect(() => { load(); }, [filter]);
@@ -55,13 +181,13 @@ export function SubtitleListPage() {
if (loading) return <div className="text-gray-400 py-8 text-center">Loading</div>;
if (!data) return <div className="text-red-600">Failed to load.</div>;
const { items, totalCounts } = data;
const { movies, series, totalCounts } = data;
return (
<div>
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold m-0">Subtitle Manager</h1>
<Button size="sm" onClick={extractAll} disabled={extracting || (data?.totalCounts.not_extracted ?? 0) === 0}>
<Button size="sm" onClick={extractAll} disabled={extracting || (totalCounts.not_extracted ?? 0) === 0}>
{extracting ? 'Queuing…' : 'Extract All Pending'}
</Button>
{extractResult && <span className="text-sm text-gray-500">{extractResult}</span>}
@@ -82,45 +208,56 @@ export function SubtitleListPage() {
))}
</div>
{items.length === 0 && (
{movies.length === 0 && series.length === 0 && (
<p className="text-gray-500">No items match this filter.</p>
)}
{items.length > 0 && (
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.82rem]">
<thead>
<tr>
{['Name', 'Lang', 'Container Subs', 'Extracted Files', 'Status'].map((h) => (
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{items.map((item) => {
const status = item.sub_count === 0 ? 'no_subs' : item.subs_extracted ? 'extracted' : 'not_extracted';
return (
<tr key={item.id} className="hover:bg-gray-50">
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
<span className="truncate inline-block max-w-[360px]" title={item.name}>{item.name}</span>
</Link>
{item.series_name && <span className="text-gray-400 text-[0.72rem]"> {item.series_name}</span>}
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</td>
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">{langName(item.original_language)}</td>
<td className="py-1.5 px-2 border-b border-gray-100 align-middle font-mono text-xs">{item.sub_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 align-middle font-mono text-xs">{item.file_count}</td>
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">
{status === 'extracted' && <Badge variant="keep">extracted</Badge>}
{status === 'not_extracted' && <Badge variant="pending">pending</Badge>}
{status === 'no_subs' && <Badge variant="noop">no subs</Badge>}
</td>
</tr>
);
})}
</tbody>
</table></div>
{/* Movies */}
{movies.length > 0 && (
<>
<div className="flex items-center gap-2 mt-5 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500">
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{movies.length}</span>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Name</Th><Th>Lang</Th><Th>Subs</Th><Th>Files</Th><Th>Status</Th></tr></thead>
<tbody>
{movies.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<Td>
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</span>
</Link>
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
</Td>
<Td>{langName(item.original_language)}</Td>
<Td className="font-mono text-xs">{item.sub_count}</Td>
<Td className="font-mono text-xs">{item.file_count}</Td>
<Td><StatusBadge item={item} /></Td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* TV Series */}
{series.length > 0 && (
<>
<div className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${movies.length > 0 ? 'mt-5' : 'mt-0'}`}>
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{series.length}</span>
</div>
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
<table className="w-full border-collapse text-[0.82rem]">
<thead><tr><Th>Series</Th><Th>Lang</Th><Th>S</Th><Th>Ep</Th><Th>Status</Th></tr></thead>
{series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
</table>
</div>
</>
)}
</div>
);
}
import type React from 'react';