remove path mappings, add subtitle summary endpoint, cache setup page, bump version
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m50s
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,4 @@ services:
|
|||||||
- PORT=3000
|
- PORT=3000
|
||||||
# Additional audio languages to keep alongside original (comma-separated ISO 639-2 codes, order = priority)
|
# Additional audio languages to keep alongside original (comma-separated ISO 639-2 codes, order = priority)
|
||||||
# - AUDIO_LANGUAGES=deu,spa
|
# - AUDIO_LANGUAGES=deu,spa
|
||||||
# Map Jellyfin library paths to container mount paths (comma-separated from=to pairs)
|
|
||||||
# - PATH_MAPPINGS=/tv/=/series/,/data/movies/=/movies/
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "netfelix-audio-fix",
|
"name": "netfelix-audio-fix",
|
||||||
"version": "2026.03.05.6",
|
"version": "2026.03.05.7",
|
||||||
"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 { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { stream } from 'hono/streaming';
|
import { stream } from 'hono/streaming';
|
||||||
import { getDb, getConfig, setConfig, getAllConfig, applyPathMappings } from '../db/index';
|
import { getDb, getConfig, setConfig, getAllConfig } from '../db/index';
|
||||||
import { getAllItems, getDevItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin';
|
import { getAllItems, getDevItems, extractOriginalLanguage, mapStream, normalizeLanguage } from '../services/jellyfin';
|
||||||
import { getOriginalLanguage as radarrLang } from '../services/radarr';
|
import { getOriginalLanguage as radarrLang } from '../services/radarr';
|
||||||
import { getOriginalLanguage as sonarrLang } from '../services/sonarr';
|
import { getOriginalLanguage as sonarrLang } from '../services/sonarr';
|
||||||
@@ -217,7 +217,7 @@ async function runScan(limit: number | null = null): Promise<void> {
|
|||||||
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
|
jellyfinItem.Id, jellyfinItem.Type === 'Episode' ? 'Episode' : 'Movie',
|
||||||
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null,
|
jellyfinItem.Name, jellyfinItem.SeriesName ?? null, jellyfinItem.SeriesId ?? null,
|
||||||
jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null,
|
jellyfinItem.ParentIndexNumber ?? null, jellyfinItem.IndexNumber ?? null,
|
||||||
jellyfinItem.ProductionYear ?? null, applyPathMappings(jellyfinItem.Path), jellyfinItem.Size ?? null,
|
jellyfinItem.ProductionYear ?? null, jellyfinItem.Path, jellyfinItem.Size ?? null,
|
||||||
jellyfinItem.Container ?? null, origLang, origLangSource, needsReview,
|
jellyfinItem.Container ?? null, origLang, origLangSource, needsReview,
|
||||||
imdbId, tmdbId, tvdbId
|
imdbId, tmdbId, tvdbId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,12 +89,6 @@ app.post('/audio-languages', async (c) => {
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/path-mappings', async (c) => {
|
|
||||||
const body = await c.req.json<{ mappings: [string, string][] }>();
|
|
||||||
setConfig('path_mappings', JSON.stringify(body.mappings ?? []));
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/clear-scan', (c) => {
|
app.post('/clear-scan', (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
// Delete children first to avoid slow cascade deletes
|
// Delete children first to avoid slow cascade deletes
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { getDb, getAllConfig } from '../db/index';
|
import { getDb, getConfig, getAllConfig } from '../db/index';
|
||||||
import { buildExtractOnlyCommand } from '../services/ffmpeg';
|
import { buildExtractOnlyCommand } from '../services/ffmpeg';
|
||||||
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
|
import { normalizeLanguage, getItem, refreshItem, mapStream } from '../services/jellyfin';
|
||||||
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
|
import type { MediaItem, MediaStream, SubtitleFile, ReviewPlan, StreamDecision } from '../types';
|
||||||
@@ -295,4 +295,194 @@ app.post('/:id/rescan', async (c) => {
|
|||||||
return c.json(detail);
|
return c.json(detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Summary ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CategoryRow { language: string | null; is_forced: number; is_hearing_impaired: number; cnt: number }
|
||||||
|
|
||||||
|
function variantOf(row: { is_forced: number; is_hearing_impaired: number }): 'forced' | 'cc' | 'standard' {
|
||||||
|
if (row.is_forced) return 'forced';
|
||||||
|
if (row.is_hearing_impaired) return 'cc';
|
||||||
|
return 'standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
function catKey(lang: string | null, variant: string) { return `${lang ?? '__null__'}|${variant}`; }
|
||||||
|
|
||||||
|
app.get('/summary', (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Embedded count — items with subtitle streams where subs_extracted = 0
|
||||||
|
const embeddedCount = (db.prepare(`
|
||||||
|
SELECT COUNT(DISTINCT mi.id) as n FROM media_items mi
|
||||||
|
JOIN media_streams ms ON ms.item_id = mi.id AND ms.type = 'Subtitle'
|
||||||
|
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||||
|
WHERE COALESCE(rp.subs_extracted, 0) = 0
|
||||||
|
`).get() as { n: number }).n;
|
||||||
|
|
||||||
|
// Stream counts by (language, variant)
|
||||||
|
const streamRows = db.prepare(`
|
||||||
|
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
||||||
|
FROM media_streams WHERE type = 'Subtitle'
|
||||||
|
GROUP BY language, is_forced, is_hearing_impaired
|
||||||
|
`).all() as CategoryRow[];
|
||||||
|
|
||||||
|
// File counts by (language, variant)
|
||||||
|
const fileRows = db.prepare(`
|
||||||
|
SELECT language, is_forced, is_hearing_impaired, COUNT(*) as cnt
|
||||||
|
FROM subtitle_files
|
||||||
|
GROUP BY language, is_forced, is_hearing_impaired
|
||||||
|
`).all() as CategoryRow[];
|
||||||
|
|
||||||
|
// Merge into categories
|
||||||
|
const catMap = new Map<string, { language: string | null; variant: string; streamCount: number; fileCount: number }>();
|
||||||
|
for (const r of streamRows) {
|
||||||
|
const v = variantOf(r);
|
||||||
|
const k = catKey(r.language, v);
|
||||||
|
catMap.set(k, { language: r.language, variant: v, streamCount: r.cnt, fileCount: 0 });
|
||||||
|
}
|
||||||
|
for (const r of fileRows) {
|
||||||
|
const v = variantOf(r);
|
||||||
|
const k = catKey(r.language, v);
|
||||||
|
const existing = catMap.get(k);
|
||||||
|
if (existing) { existing.fileCount = r.cnt; }
|
||||||
|
else { catMap.set(k, { language: r.language, variant: v, streamCount: 0, fileCount: r.cnt }); }
|
||||||
|
}
|
||||||
|
const categories = Array.from(catMap.values()).sort((a, b) => {
|
||||||
|
const la = a.language ?? 'zzz';
|
||||||
|
const lb = b.language ?? 'zzz';
|
||||||
|
if (la !== lb) return la.localeCompare(lb);
|
||||||
|
return a.variant.localeCompare(b.variant);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Title grouping
|
||||||
|
const titleRows = db.prepare(`
|
||||||
|
SELECT language, title, COUNT(*) as cnt
|
||||||
|
FROM media_streams WHERE type = 'Subtitle'
|
||||||
|
GROUP BY language, title
|
||||||
|
ORDER BY language, cnt DESC
|
||||||
|
`).all() as { language: string | null; title: string | null; cnt: number }[];
|
||||||
|
|
||||||
|
// Determine canonical title per language (most common)
|
||||||
|
const canonicalByLang = new Map<string | null, string | null>();
|
||||||
|
for (const r of titleRows) {
|
||||||
|
if (!canonicalByLang.has(r.language)) canonicalByLang.set(r.language, r.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titles = titleRows.map((r) => ({
|
||||||
|
language: r.language,
|
||||||
|
title: r.title,
|
||||||
|
count: r.cnt,
|
||||||
|
isCanonical: canonicalByLang.get(r.language) === r.title,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Keep languages from config
|
||||||
|
const raw = getConfig('subtitle_languages');
|
||||||
|
let keepLanguages: string[] = [];
|
||||||
|
try { keepLanguages = JSON.parse(raw ?? '[]'); } catch { /* empty */ }
|
||||||
|
|
||||||
|
return c.json({ embeddedCount, categories, titles, keepLanguages });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Batch delete subtitle files ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/batch-delete', async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = await c.req.json<{ categories: { language: string | null; variant: 'standard' | 'forced' | 'cc' }[] }>();
|
||||||
|
|
||||||
|
let deleted = 0;
|
||||||
|
for (const cat of body.categories) {
|
||||||
|
const isForced = cat.variant === 'forced' ? 1 : 0;
|
||||||
|
const isHI = cat.variant === 'cc' ? 1 : 0;
|
||||||
|
|
||||||
|
let files: SubtitleFile[];
|
||||||
|
if (cat.language === null) {
|
||||||
|
files = db.prepare(`
|
||||||
|
SELECT * FROM subtitle_files
|
||||||
|
WHERE language IS NULL AND is_forced = ? AND is_hearing_impaired = ?
|
||||||
|
`).all(isForced, isHI) as SubtitleFile[];
|
||||||
|
} else {
|
||||||
|
files = db.prepare(`
|
||||||
|
SELECT * FROM subtitle_files
|
||||||
|
WHERE language = ? AND is_forced = ? AND is_hearing_impaired = ?
|
||||||
|
`).all(cat.language, isForced, isHI) as SubtitleFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try { unlinkSync(file.file_path); } catch { /* file may not exist */ }
|
||||||
|
db.prepare('DELETE FROM subtitle_files WHERE id = ?').run(file.id);
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset subs_extracted for affected items that now have no subtitle files
|
||||||
|
const affectedItems = new Set(files.map((f) => f.item_id));
|
||||||
|
for (const itemId of affectedItems) {
|
||||||
|
const remaining = (db.prepare('SELECT COUNT(*) as n FROM subtitle_files WHERE item_id = ?').get(itemId) as { n: number }).n;
|
||||||
|
if (remaining === 0) {
|
||||||
|
db.prepare('UPDATE review_plans SET subs_extracted = 0 WHERE item_id = ?').run(itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, deleted });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Normalize titles ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/normalize-titles', (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Get title groups per language
|
||||||
|
const titleRows = db.prepare(`
|
||||||
|
SELECT language, title, COUNT(*) as cnt
|
||||||
|
FROM media_streams WHERE type = 'Subtitle'
|
||||||
|
GROUP BY language, title
|
||||||
|
ORDER BY language, cnt DESC
|
||||||
|
`).all() as { language: string | null; title: string | null; cnt: number }[];
|
||||||
|
|
||||||
|
// Find canonical (most common) title per language
|
||||||
|
const canonicalByLang = new Map<string | null, string | null>();
|
||||||
|
for (const r of titleRows) {
|
||||||
|
if (!canonicalByLang.has(r.language)) canonicalByLang.set(r.language, r.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = 0;
|
||||||
|
for (const r of titleRows) {
|
||||||
|
const canonical = canonicalByLang.get(r.language);
|
||||||
|
if (r.title === canonical) continue;
|
||||||
|
|
||||||
|
// Find all streams matching this language+title and set custom_title on their decisions
|
||||||
|
let streams: { id: number; item_id: number }[];
|
||||||
|
if (r.language === null) {
|
||||||
|
streams = db.prepare(`
|
||||||
|
SELECT id, item_id FROM media_streams
|
||||||
|
WHERE type = 'Subtitle' AND language IS NULL AND title IS ?
|
||||||
|
`).all(r.title) as { id: number; item_id: number }[];
|
||||||
|
} else {
|
||||||
|
streams = db.prepare(`
|
||||||
|
SELECT id, item_id FROM media_streams
|
||||||
|
WHERE type = 'Subtitle' AND language = ? AND title IS ?
|
||||||
|
`).all(r.language, r.title) as { id: number; item_id: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stream of streams) {
|
||||||
|
// Ensure review_plan exists
|
||||||
|
let plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(stream.item_id) as { id: number } | undefined;
|
||||||
|
if (!plan) {
|
||||||
|
db.prepare("INSERT INTO review_plans (item_id, status, is_noop) VALUES (?, 'pending', 0)").run(stream.item_id);
|
||||||
|
plan = db.prepare('SELECT id FROM review_plans WHERE item_id = ?').get(stream.item_id) as { id: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert stream_decision with custom_title
|
||||||
|
const existing = db.prepare('SELECT id FROM stream_decisions WHERE plan_id = ? AND stream_id = ?').get(plan.id, stream.id);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('UPDATE stream_decisions SET custom_title = ? WHERE plan_id = ? AND stream_id = ?').run(canonical, plan.id, stream.id);
|
||||||
|
} else {
|
||||||
|
db.prepare("INSERT INTO stream_decisions (plan_id, stream_id, action, custom_title) VALUES (?, ?, 'keep', ?)").run(plan.id, stream.id, canonical);
|
||||||
|
}
|
||||||
|
normalized++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, normalized });
|
||||||
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const ENV_MAP: Record<string, string> = {
|
|||||||
sonarr_enabled: 'SONARR_ENABLED',
|
sonarr_enabled: 'SONARR_ENABLED',
|
||||||
subtitle_languages: 'SUBTITLE_LANGUAGES',
|
subtitle_languages: 'SUBTITLE_LANGUAGES',
|
||||||
audio_languages: 'AUDIO_LANGUAGES',
|
audio_languages: 'AUDIO_LANGUAGES',
|
||||||
path_mappings: 'PATH_MAPPINGS',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Read a config key from environment variables (returns null if not set). */
|
/** Read a config key from environment variables (returns null if not set). */
|
||||||
@@ -34,7 +34,6 @@ function envValue(key: string): string | null {
|
|||||||
if (!val) return null;
|
if (!val) return null;
|
||||||
if (key.endsWith('_enabled')) return val === '1' || val.toLowerCase() === 'true' ? '1' : '0';
|
if (key.endsWith('_enabled')) return val === '1' || val.toLowerCase() === 'true' ? '1' : '0';
|
||||||
if (key === 'subtitle_languages' || key === 'audio_languages') return JSON.stringify(val.split(',').map((s) => s.trim()));
|
if (key === 'subtitle_languages' || key === 'audio_languages') return JSON.stringify(val.split(',').map((s) => s.trim()));
|
||||||
if (key === 'path_mappings') return JSON.stringify(val.split(',').map((pair) => { const [from, to] = pair.split('='); return [from?.trim(), to?.trim()]; }).filter(([f, t]) => f && t));
|
|
||||||
if (key.endsWith('_url')) return val.replace(/\/$/, '');
|
if (key.endsWith('_url')) return val.replace(/\/$/, '');
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
@@ -112,27 +111,3 @@ export function getAllConfig(): Record<string, string> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPathMappings(): [string, string][] {
|
|
||||||
const raw = getConfig('path_mappings');
|
|
||||||
if (!raw) return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed.filter(([f, t]: [string, string]) => f && t) : [];
|
|
||||||
} catch { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply path_mappings config to translate a Jellyfin path to a local container path. */
|
|
||||||
export function applyPathMappings(path: string): string {
|
|
||||||
for (const [from, to] of getPathMappings()) {
|
|
||||||
if (path.startsWith(from)) return to + path.slice(from.length);
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply all path_mappings as replaceAll on a command string (for baked-in paths). */
|
|
||||||
export function applyPathMappingsToCommand(cmd: string): string {
|
|
||||||
for (const [from, to] of getPathMappings()) {
|
|
||||||
cmd = cmd.replaceAll(from, to);
|
|
||||||
}
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -126,6 +126,6 @@ export const DEFAULT_CONFIG: Record<string, string> = {
|
|||||||
sonarr_enabled: '0',
|
sonarr_enabled: '0',
|
||||||
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
|
subtitle_languages: JSON.stringify(['eng', 'deu', 'spa']),
|
||||||
audio_languages: '[]',
|
audio_languages: '[]',
|
||||||
path_mappings: '[]',
|
|
||||||
scan_running: '0',
|
scan_running: '0',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function PathsPage() {
|
|||||||
{paths.some((p) => !p.accessible) && (
|
{paths.some((p) => !p.accessible) && (
|
||||||
<p className="mt-4 text-xs text-gray-500">
|
<p className="mt-4 text-xs text-gray-500">
|
||||||
Paths marked "Not mounted" are not reachable from the container.
|
Paths marked "Not mounted" are not reachable from the container.
|
||||||
Add a Docker volume mount for the missing path, or configure a path mapping in Settings to translate the Jellyfin path to a local path.
|
Mount each path into the Docker container exactly as Jellyfin reports it.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { LANG_NAMES } from '~/shared/lib/lang';
|
|||||||
|
|
||||||
interface SetupData { config: Record<string, string>; envLocked: string[]; }
|
interface SetupData { config: Record<string, string>; envLocked: string[]; }
|
||||||
|
|
||||||
|
let setupCache: SetupData | null = null;
|
||||||
|
|
||||||
const LANGUAGE_OPTIONS = Object.entries(LANG_NAMES).map(([code, label]) => ({ code, label }));
|
const LANGUAGE_OPTIONS = Object.entries(LANG_NAMES).map(([code, label]) => ({ code, label }));
|
||||||
|
|
||||||
// ─── Locked input ─────────────────────────────────────────────────────────────
|
// ─── Locked input ─────────────────────────────────────────────────────────────
|
||||||
@@ -169,7 +171,8 @@ function ConnSection({
|
|||||||
// ─── Setup page ───────────────────────────────────────────────────────────────
|
// ─── Setup page ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SetupPage() {
|
export function SetupPage() {
|
||||||
const [data, setData] = useState<SetupData | null>(null);
|
const [data, setData] = useState<SetupData | null>(setupCache);
|
||||||
|
const [loading, setLoading] = useState(setupCache === null);
|
||||||
const [clearStatus, setClearStatus] = useState('');
|
const [clearStatus, setClearStatus] = useState('');
|
||||||
const [subLangs, setSubLangs] = useState<string[]>([]);
|
const [subLangs, setSubLangs] = useState<string[]>([]);
|
||||||
const [subSaved, setSubSaved] = useState('');
|
const [subSaved, setSubSaved] = useState('');
|
||||||
@@ -177,17 +180,22 @@ export function SetupPage() {
|
|||||||
const [audSaved, setAudSaved] = useState('');
|
const [audSaved, setAudSaved] = useState('');
|
||||||
const [langsLoaded, setLangsLoaded] = useState(false);
|
const [langsLoaded, setLangsLoaded] = useState(false);
|
||||||
|
|
||||||
const load = () => api.get<SetupData>('/api/setup').then((d) => {
|
const load = () => {
|
||||||
|
if (!setupCache) setLoading(true);
|
||||||
|
api.get<SetupData>('/api/setup').then((d) => {
|
||||||
|
setupCache = d;
|
||||||
setData(d);
|
setData(d);
|
||||||
if (!langsLoaded) {
|
if (!langsLoaded) {
|
||||||
setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]'));
|
setSubLangs(JSON.parse(d.config.subtitle_languages ?? '["eng","deu","spa"]'));
|
||||||
setAudLangs(JSON.parse(d.config.audio_languages ?? '[]'));
|
setAudLangs(JSON.parse(d.config.audio_languages ?? '[]'));
|
||||||
setLangsLoaded(true);
|
setLangsLoaded(true);
|
||||||
}
|
}
|
||||||
});
|
}).finally(() => setLoading(false));
|
||||||
|
};
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
if (!data) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
if (loading && !data) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||||
|
if (!data) return <div className="text-red-600">Failed to load settings.</div>;
|
||||||
|
|
||||||
const { config: cfg, envLocked: envLockedArr } = data;
|
const { config: cfg, envLocked: envLockedArr } = data;
|
||||||
const locked = new Set(envLockedArr);
|
const locked = new Set(envLockedArr);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function RootLayout() {
|
|||||||
<NavLink to="/scan">Scan</NavLink>
|
<NavLink to="/scan">Scan</NavLink>
|
||||||
<NavLink to="/paths">Paths</NavLink>
|
<NavLink to="/paths">Paths</NavLink>
|
||||||
<NavLink to="/review/audio">Audio</NavLink>
|
<NavLink to="/review/audio">Audio</NavLink>
|
||||||
<NavLink to="/review/subtitles">Subs</NavLink>
|
<NavLink to="/review/subtitles">Subtitles</NavLink>
|
||||||
<NavLink to="/execute">Execute</NavLink>
|
<NavLink to="/execute">Execute</NavLink>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|||||||
Reference in New Issue
Block a user