feat(ui): redesign panel with Mantine light theme, routing, modals and notifications

This commit is contained in:
2026-02-28 23:12:47 +03:00
parent 0dd8f60626
commit d0b5f4c10f
16 changed files with 2264 additions and 231 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,14 @@
"start": "serve -s dist -l 3000"
},
"dependencies": {
"@mantine/core": "^8.3.15",
"@mantine/hooks": "^8.3.15",
"@mantine/modals": "^8.3.15",
"@mantine/notifications": "^8.3.15",
"@tabler/icons-react": "^3.37.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
@@ -19,4 +25,4 @@
"typescript": "^5.7.3",
"vite": "^6.0.11"
}
}
}

View File

@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Layout, Tab } from './components/Layout';
import { Route, Routes, useNavigate, useParams } from 'react-router-dom';
import { Alert, Button, Stack } from '@mantine/core';
import { IconArrowLeft, IconAlertTriangle } from '@tabler/icons-react';
import { Layout } from './components/Layout';
import { DashboardPage } from './pages/DashboardPage';
import { JobsPage } from './pages/JobsPage';
import { JobDetailPage } from './pages/JobDetailPage';
@@ -8,27 +10,44 @@ import { SettingsPage } from './pages/SettingsPage';
import { WatchedPathsPage } from './pages/WatchedPathsPage';
export default function App() {
const [tab, setTab] = useState<Tab>('dashboard');
const [selectedJob, setSelectedJob] = useState<string | null>(null);
const navigate = useNavigate();
const handleSelectJob = (jobId: string) => {
navigate(`/jobs/${jobId}`);
};
return (
<Layout tab={tab} setTab={setTab}>
{selectedJob && (
<div style={{ marginBottom: 12 }}>
<button onClick={() => setSelectedJob(null)}>Job listesine don</button>
</div>
)}
{selectedJob ? (
<JobDetailPage jobId={selectedJob} />
) : (
<>
{tab === 'dashboard' && <DashboardPage onSelectJob={setSelectedJob} />}
{tab === 'jobs' && <JobsPage onSelectJob={setSelectedJob} />}
{tab === 'review' && <ReviewPage onSelectJob={setSelectedJob} />}
{tab === 'settings' && <SettingsPage />}
{tab === 'paths' && <WatchedPathsPage />}
</>
)}
<Layout>
<Routes>
<Route path="/" element={<DashboardPage onSelectJob={handleSelectJob} />} />
<Route path="/jobs" element={<JobsPage onSelectJob={handleSelectJob} />} />
<Route path="/jobs/:id" element={<JobDetailRoute />} />
<Route path="/review" element={<ReviewPage onSelectJob={handleSelectJob} />} />
<Route path="/paths" element={<WatchedPathsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Layout>
);
}
function JobDetailRoute() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
if (!id) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />}>
Geçersiz id.
</Alert>
);
}
return (
<Stack gap="md">
<Button variant="default" leftSection={<IconArrowLeft size={16} />} w="fit-content" onClick={() => navigate('/jobs')}>
Jobs listesine dön
</Button>
<JobDetailPage jobId={id} />
</Stack>
);
}

View File

@@ -1,9 +1,29 @@
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
function getCoreBaseUrl(): string {
const base = import.meta.env.VITE_PUBLIC_CORE_URL || 'http://localhost:3001';
const res = await fetch(`${base}${path}`, {
return base.replace(/\/+$/, '');
}
function normalizePath(path: string): string {
return path.startsWith('/') ? path : `/${path}`;
}
export function buildApiUrl(path: string): string {
return `${getCoreBaseUrl()}${normalizePath(path)}`;
}
export function buildSseUrl(path: string): string {
return buildApiUrl(path);
}
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(buildApiUrl(path), {
headers: { 'content-type': 'application/json', ...(options?.headers || {}) },
...options
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
}
return res.json();
}

View File

@@ -1,26 +1,50 @@
import { Badge, Paper, ScrollArea, Table, Text } from '@mantine/core';
import { Job } from '../types';
import { statusColor } from '../lib/status';
export function JobTable({ jobs, onSelect }: { jobs: Job[]; onSelect: (id: string) => void }) {
return (
<table width="100%" cellPadding={8} style={{ borderCollapse: 'collapse', background: '#fff' }}>
<thead>
<tr>
<th align="left">ID</th>
<th align="left">Durum</th>
<th align="left">Baslik</th>
<th align="left">Guncelleme</th>
</tr>
</thead>
<tbody>
{jobs.map((j) => (
<tr key={j._id} onClick={() => onSelect(j._id)} style={{ cursor: 'pointer', borderTop: '1px solid #e2e8f0' }}>
<td>{j._id.slice(-8)}</td>
<td>{j.status}</td>
<td>{j.requestSnapshot?.title || '-'}</td>
<td>{new Date(j.updatedAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
<Paper radius="lg" withBorder p={0} style={{ background: 'rgba(255,255,255,0.88)', borderColor: 'rgba(16,32,50,0.12)' }}>
<ScrollArea>
<Table highlightOnHover horizontalSpacing="md" verticalSpacing="sm" miw={780}>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Durum</Table.Th>
<Table.Th>Başlık</Table.Th>
<Table.Th>Güncelleme</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{jobs.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<Text c="dimmed" ta="center" py="lg">
Kayıt bulunamadı.
</Text>
</Table.Td>
</Table.Tr>
) : (
jobs.map((job) => (
<Table.Tr key={job._id} style={{ cursor: 'pointer' }} onClick={() => onSelect(job._id)}>
<Table.Td>
<Text ff="monospace" size="sm" c="dimmed">
{job._id.slice(-8)}
</Text>
</Table.Td>
<Table.Td>
<Badge color={statusColor(job.status)} variant="light">
{job.status}
</Badge>
</Table.Td>
<Table.Td>{job.requestSnapshot?.title || job.requestSnapshot?.path || '-'}</Table.Td>
<Table.Td>{new Date(job.updatedAt).toLocaleString()}</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
);
}

View File

@@ -1,20 +1,118 @@
import React from 'react';
import { useState } from 'react';
import { AppShell, Box, Burger, Group, NavLink, Stack, Text } from '@mantine/core';
import {
IconChartDonut3,
IconListDetails,
IconMessage2Exclamation,
IconFolderSearch,
IconAdjustmentsCog,
IconRadar2
} from '@tabler/icons-react';
import { useLocation, useNavigate } from 'react-router-dom';
const tabs = ['dashboard', 'jobs', 'review', 'settings', 'paths'] as const;
export type Tab = (typeof tabs)[number];
interface MenuItem {
label: string;
path: string;
icon: typeof IconChartDonut3;
}
const items: MenuItem[] = [
{ label: 'Dashboard', path: '/', icon: IconChartDonut3 },
{ label: 'Jobs', path: '/jobs', icon: IconListDetails },
{ label: 'Review', path: '/review', icon: IconMessage2Exclamation },
{ label: 'Watched Paths', path: '/paths', icon: IconFolderSearch },
{ label: 'Settings', path: '/settings', icon: IconAdjustmentsCog }
];
export function Layout({ children }: { children: React.ReactNode }) {
const [opened, setOpened] = useState(false);
const navigate = useNavigate();
const location = useLocation();
export function Layout({ tab, setTab, children }: { tab: Tab; setTab: (t: Tab) => void; children: React.ReactNode }) {
return (
<div style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', background: 'linear-gradient(120deg,#f6f8fb,#eef3ff)', minHeight: '100vh', color: '#0f172a' }}>
<header style={{ padding: 16, borderBottom: '1px solid #dbe2f0', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<strong>subwatcher</strong>
{tabs.map((t) => (
<button key={t} onClick={() => setTab(t)} style={{ background: tab === t ? '#1e293b' : '#fff', color: tab === t ? '#fff' : '#111', border: '1px solid #94a3b8', borderRadius: 8, padding: '6px 10px' }}>
{t}
</button>
))}
</header>
<main style={{ padding: 16 }}>{children}</main>
</div>
<AppShell
header={{ height: 70 }}
navbar={{ width: 270, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="lg"
styles={{
main: {
background: 'transparent'
},
navbar: {
backdropFilter: 'blur(10px)',
background: 'linear-gradient(180deg, rgba(255,255,255,0.9), rgba(247,251,255,0.94))',
borderRight: '1px solid rgba(16,32,50,0.08)'
},
header: {
backdropFilter: 'blur(10px)',
background: 'linear-gradient(90deg, rgba(255,255,255,0.9), rgba(244,249,255,0.88))',
borderBottom: '1px solid rgba(16,32,50,0.08)'
}
}}
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group gap="sm">
<Burger opened={opened} onClick={() => setOpened((s) => !s)} hiddenFrom="sm" size="sm" />
<Group gap={10}>
<IconRadar2 size={24} color="#0ea5a3" />
<div>
<Text className="sw-page-title" fw={700} size="lg">
SubWatcher Control
</Text>
<Text c="dimmed" size="xs">
subtitle automation cockpit
</Text>
</div>
</Group>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<Stack gap="xs">
{items.map((item) => {
const active = item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path);
return (
<NavLink
key={item.path}
active={active}
label={item.label}
leftSection={<item.icon size={18} />}
onClick={() => {
navigate(item.path);
setOpened(false);
}}
styles={{
root: {
borderRadius: 10,
color: '#233548'
},
body: {
fontFamily: 'Sora, sans-serif',
letterSpacing: '0.02em'
}
}}
/>
);
})}
</Stack>
<Box
mt="auto"
p="sm"
style={{ borderRadius: 12, background: 'rgba(14,165,163,0.08)', border: '1px solid rgba(14,165,163,0.2)' }}
>
<Text size="sm" fw={600} className="sw-page-title">
Live Pipeline
</Text>
<Text size="xs" c="dimmed" mt={4}>
Durumlar panelden canlı izlenir, hata durumları review ekranına düşer.
</Text>
</Box>
</AppShell.Navbar>
<AppShell.Main>{children}</AppShell.Main>
</AppShell>
);
}

47
services/ui/src/index.css Normal file
View File

@@ -0,0 +1,47 @@
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600&display=swap');
:root {
--sw-bg-0: #f7fbff;
--sw-bg-1: #f1f6fd;
--sw-bg-2: #e9f1fb;
--sw-accent: #0ea5a3;
--sw-accent-2: #1d4ed8;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at 10% 12%, rgba(14, 165, 163, 0.14), transparent 32%),
radial-gradient(circle at 88% 10%, rgba(29, 78, 216, 0.1), transparent 34%),
linear-gradient(160deg, var(--sw-bg-0), var(--sw-bg-1) 48%, var(--sw-bg-2));
color: #1c2733;
font-family: 'Source Serif 4', serif;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image: linear-gradient(rgba(14, 23, 38, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(14, 23, 38, 0.03) 1px, transparent 1px);
background-size: 36px 36px;
opacity: 0.5;
}
.sw-page-title {
font-family: 'Sora', sans-serif;
letter-spacing: 0.02em;
margin: 0;
color: #102032;
}

View File

@@ -0,0 +1,23 @@
export const JOB_STATUSES = [
'PENDING',
'WAITING_FILE_STABLE',
'PARSED',
'ANALYZED',
'REQUESTING_API',
'FOUND_TEMP',
'NORMALIZING_ENCODING',
'WRITING_SUBTITLE',
'DONE',
'NEEDS_REVIEW',
'NOT_FOUND',
'AMBIGUOUS',
'ERROR'
] as const;
export function statusColor(status: string): string {
if (status === 'DONE') return 'teal';
if (status === 'NEEDS_REVIEW') return 'yellow';
if (status === 'ERROR') return 'red';
if (status === 'NOT_FOUND' || status === 'AMBIGUOUS') return 'orange';
return 'blue';
}

View File

@@ -1,9 +1,46 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { createTheme, MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import App from './App';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import './index.css';
const theme = createTheme({
primaryColor: 'cyan',
defaultRadius: 'md',
fontFamily: '"Source Serif 4", serif',
headings: {
fontFamily: '"Sora", sans-serif'
},
colors: {
shell: [
'#ffffff',
'#f7fbff',
'#eef5ff',
'#dde9f7',
'#ccdaec',
'#b4c4d8',
'#8fa0b3',
'#6f7f90',
'#556473',
'#3a4651'
]
}
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<MantineProvider theme={theme} defaultColorScheme="light">
<ModalsProvider>
<Notifications position="top-right" />
<BrowserRouter>
<App />
</BrowserRouter>
</ModalsProvider>
</MantineProvider>
</React.StrictMode>
);

View File

@@ -1,4 +1,6 @@
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Badge, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
import { IconAlertTriangle, IconChecklist, IconClockHour4, IconProgressCheck } from '@tabler/icons-react';
import { api } from '../api/client';
import { Job } from '../types';
import { usePoll } from '../hooks/usePoll';
@@ -8,37 +10,88 @@ export function DashboardPage({ onSelectJob }: { onSelectJob: (id: string) => vo
const [jobs, setJobs] = useState<Job[]>([]);
const load = useCallback(async () => {
const data = await api<{ items: Job[] }>('/api/jobs?limit=20');
const data = await api<{ items: Job[] }>('/api/jobs?limit=30');
setJobs(data.items);
}, []);
usePoll(load, 5000);
const since = Date.now() - 24 * 3600 * 1000;
const recent = jobs.filter((x) => new Date(x.createdAt).getTime() >= since);
const done = recent.filter((x) => x.status === 'DONE').length;
const review = recent.filter((x) => x.status === 'NEEDS_REVIEW').length;
const errors = recent.filter((x) => x.status === 'ERROR').length;
const stats = useMemo(() => {
const since = Date.now() - 24 * 3600 * 1000;
const recent = jobs.filter((x) => new Date(x.createdAt).getTime() >= since);
return {
recent,
total: recent.length,
done: recent.filter((x) => x.status === 'DONE').length,
review: recent.filter((x) => x.status === 'NEEDS_REVIEW').length,
error: recent.filter((x) => x.status === 'ERROR').length
};
}, [jobs]);
return (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(120px, 1fr))', gap: 12, marginBottom: 16 }}>
<Stat label="24h Job" value={String(recent.length)} />
<Stat label="DONE" value={String(done)} />
<Stat label="REVIEW" value={String(review)} />
<Stat label="ERROR" value={String(errors)} />
</div>
<h3>Son Isler</h3>
<JobTable jobs={jobs} onSelect={onSelectJob} />
</div>
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<h1 className="sw-page-title">Operations Dashboard</h1>
<Text c="dimmed">Son 24 saatin özetini ve son işleri buradan takip et.</Text>
</div>
<Badge size="lg" variant="outline" color="cyan">
Live polling: 5s
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, xl: 4 }} spacing="md">
<StatCard title="24h Total" value={stats.total} color="#3fa6ff" icon={<IconClockHour4 size={18} />} />
<StatCard title="Completed" value={stats.done} color="#0ea5a3" icon={<IconProgressCheck size={18} />} />
<StatCard title="Needs Review" value={stats.review} color="#e3a008" icon={<IconChecklist size={18} />} />
<StatCard title="Errors" value={stats.error} color="#dc4c64" icon={<IconAlertTriangle size={18} />} />
</SimpleGrid>
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600} size="lg" className="sw-page-title">
Son İşler
</Text>
<Text c="dimmed" size="sm">
Liste otomatik yenilenir.
</Text>
</Group>
<JobTable jobs={jobs} onSelect={onSelectJob} />
</Stack>
</Stack>
);
}
function Stat({ label, value }: { label: string; value: string }) {
function StatCard({
title,
value,
color,
icon
}: {
title: string;
value: number;
color: string;
icon: React.ReactNode;
}) {
return (
<div style={{ border: '1px solid #cbd5e1', borderRadius: 12, padding: 12, background: '#ffffffcc' }}>
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>{value}</div>
</div>
<Card
radius="lg"
withBorder
p="lg"
style={{
background: 'linear-gradient(160deg, rgba(255,255,255,0.92), rgba(246,251,255,0.9))',
borderColor: 'rgba(16,32,50,0.12)'
}}
>
<Group justify="space-between" mb={8}>
<Text c="dimmed" size="sm" ff="Sora, sans-serif">
{title}
</Text>
<div style={{ color }}>{icon}</div>
</Group>
<Text fw={700} size="2rem" ff="Sora, sans-serif" c="#18314a">
{value}
</Text>
</Card>
);
}

View File

@@ -1,25 +1,54 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { Job, JobLog } from '../types';
import {
Alert,
Badge,
Button,
Card,
Divider,
Group,
NumberInput,
ScrollArea,
SimpleGrid,
Stack,
Text,
TextInput
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconSparkles, IconTerminal2 } from '@tabler/icons-react';
import { api, buildSseUrl } from '../api/client';
import { Job, JobCandidate, JobLog } from '../types';
import { statusColor } from '../lib/status';
interface ManualOverride {
title?: string;
year?: number;
release?: string;
season?: number;
episode?: number;
}
export function JobDetailPage({ jobId }: { jobId: string }) {
const [job, setJob] = useState<Job | null>(null);
const [logs, setLogs] = useState<JobLog[]>([]);
const [override, setOverride] = useState<any>({});
const [candidates, setCandidates] = useState<any[]>([]);
const [override, setOverride] = useState<ManualOverride>({});
const [candidates, setCandidates] = useState<JobCandidate[]>([]);
useEffect(() => {
let es: EventSource | null = null;
(async () => {
const j = await api<Job>(`/api/jobs/${jobId}`);
setJob(j);
const l = await api<{ items: JobLog[] }>(`/api/jobs/${jobId}/logs?limit=200`);
setLogs(l.items);
if (j.apiSnapshot?.candidates) setCandidates(j.apiSnapshot.candidates);
const loadedJob = await api<Job>(`/api/jobs/${jobId}`);
setJob(loadedJob);
es = new EventSource(`/api/jobs/${jobId}/stream`);
es.onmessage = (ev) => {
const item = JSON.parse(ev.data);
const loadedLogs = await api<{ items: JobLog[] }>(`/api/jobs/${jobId}/logs?limit=200`);
setLogs(loadedLogs.items);
if (loadedJob.apiSnapshot?.candidates) {
setCandidates(loadedJob.apiSnapshot.candidates);
}
es = new EventSource(buildSseUrl(`/api/jobs/${jobId}/stream`));
es.onmessage = (event) => {
const item = JSON.parse(event.data) as JobLog;
setLogs((prev) => [...prev, item]);
};
})();
@@ -30,66 +59,173 @@ export function JobDetailPage({ jobId }: { jobId: string }) {
}, [jobId]);
async function manualSearch() {
const res = await api<any>(`/api/review/${jobId}/search`, { method: 'POST', body: JSON.stringify(override) });
setCandidates(res.candidates || []);
try {
const res = await api<{ candidates?: JobCandidate[] }>(`/api/review/${jobId}/search`, {
method: 'POST',
body: JSON.stringify(override)
});
const list = res.candidates || [];
setCandidates(list);
notifications.show({
color: list.length > 0 ? 'teal' : 'yellow',
title: 'Arama tamamlandı',
message: list.length > 0 ? `${list.length} aday bulundu.` : 'Aday bulunamadı.'
});
} catch (error) {
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Arama başarısız.' });
}
}
async function choose(c: any) {
await api(`/api/review/${jobId}/choose`, { method: 'POST', body: JSON.stringify({ chosenCandidateId: c.id, lang: c.lang || 'tr' }) });
const j = await api<Job>(`/api/jobs/${jobId}`);
setJob(j);
async function choose(candidate: JobCandidate) {
try {
await api(`/api/review/${jobId}/choose`, {
method: 'POST',
body: JSON.stringify({ chosenCandidateId: candidate.id, lang: candidate.lang || 'tr' })
});
const refreshed = await api<Job>(`/api/jobs/${jobId}`);
setJob(refreshed);
notifications.show({ color: 'teal', title: 'Seçim kaydedildi', message: 'Aday başarıyla uygulandı.' });
} catch (error) {
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Aday seçilemedi.' });
}
}
if (!job) return <div>Yukleniyor...</div>;
if (!job) return <Text c="dimmed">Yükleniyor...</Text>;
const media = job.mediaFileId;
const media = job.mediaFileId as any;
return (
<div style={{ display: 'grid', gap: 12 }}>
<h3>Job #{job._id.slice(-8)} - {job.status}</h3>
<div style={{ border: '1px solid #cbd5e1', padding: 12, borderRadius: 8, background: '#fff' }}>
<div>Baslik: {job.requestSnapshot?.title || '-'}</div>
<div>Tip: {job.requestSnapshot?.type || '-'}</div>
<div>Yil: {job.requestSnapshot?.year || '-'}</div>
<div>Release: {job.requestSnapshot?.release || '-'}</div>
<div>Season/Episode: {job.requestSnapshot?.season ?? '-'} / {job.requestSnapshot?.episode ?? '-'}</div>
<div>Media: {media?.path || '-'}</div>
<div>Video: {media?.mediaInfo?.video?.codec_name || '-'} {media?.mediaInfo?.video?.width || '-'}x{media?.mediaInfo?.video?.height || '-'}</div>
<div>Sonuc: {job.result?.subtitles?.map((s: any) => s.writtenPath).join(', ') || '-'}</div>
</div>
{job.status === 'NEEDS_REVIEW' && (
<div style={{ border: '1px solid #f59e0b', padding: 12, borderRadius: 8, background: '#fffbeb' }}>
<h4>Manual Override</h4>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input placeholder="title" onChange={(e) => setOverride((x: any) => ({ ...x, title: e.target.value }))} />
<input placeholder="year" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, year: Number(e.target.value) }))} />
<input placeholder="release" onChange={(e) => setOverride((x: any) => ({ ...x, release: e.target.value }))} />
<input placeholder="season" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, season: Number(e.target.value) }))} />
<input placeholder="episode" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, episode: Number(e.target.value) }))} />
<button onClick={manualSearch}>Search</button>
</div>
<ul>
{candidates.map((c) => (
<li key={c.id}>
{c.provider} | {c.id} | score={c.score}
<button onClick={() => choose(c)}>Sec</button>
</li>
))}
</ul>
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<div>
<h1 className="sw-page-title">Job #{job._id.slice(-8)}</h1>
<Text c="dimmed">{job.requestSnapshot?.path || 'manual trigger'}</Text>
</div>
)}
<Badge color={statusColor(job.status)} size="lg" variant="light">
{job.status}
</Badge>
</Group>
<div style={{ border: '1px solid #cbd5e1', borderRadius: 8, background: '#fff', padding: 12 }}>
<h4>Canli Loglar</h4>
<div style={{ maxHeight: 320, overflow: 'auto', fontSize: 12 }}>
{logs.map((l) => (
<div key={l._id + l.ts} style={{ borderBottom: '1px dashed #e2e8f0', padding: '4px 0' }}>
[{new Date(l.ts).toLocaleTimeString()}] {l.step} - {l.message}
{l.meta ? ` | meta=${JSON.stringify(l.meta)}` : ''}
</div>
))}
</div>
</div>
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
<Detail label="Başlık" value={job.requestSnapshot?.title || '-'} />
<Detail label="Tür" value={job.requestSnapshot?.type || '-'} />
<Detail label="Yıl" value={String(job.requestSnapshot?.year || '-')} />
<Detail label="Release" value={job.requestSnapshot?.release || '-'} />
<Detail label="Season / Episode" value={`${job.requestSnapshot?.season ?? '-'} / ${job.requestSnapshot?.episode ?? '-'}`} />
<Detail label="Media" value={media?.path || '-'} />
<Detail
label="Video"
value={`${media?.mediaInfo?.video?.codec_name || '-'} ${media?.mediaInfo?.video?.width || '-'}x${media?.mediaInfo?.video?.height || '-'}`}
/>
<Detail label=ıktı" value={job.result?.subtitles?.map((item) => item.writtenPath).join(', ') || '-'} />
</SimpleGrid>
</Card>
{job.status === 'NEEDS_REVIEW' ? (
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,252,243,0.92)', borderColor: 'rgba(227,160,8,0.3)' }}>
<Stack>
<Group gap="xs">
<IconSparkles size={18} color="#e3a008" />
<Text fw={600} className="sw-page-title">
Manual Override
</Text>
</Group>
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="sm">
<TextInput placeholder="title" value={override.title || ''} onChange={(e) => setOverride((prev) => ({ ...prev, title: e.currentTarget.value }))} />
<NumberInput placeholder="year" value={override.year} onChange={(value) => setOverride((prev) => ({ ...prev, year: Number(value) }))} hideControls />
<TextInput placeholder="release" value={override.release || ''} onChange={(e) => setOverride((prev) => ({ ...prev, release: e.currentTarget.value }))} />
<NumberInput placeholder="season" value={override.season} onChange={(value) => setOverride((prev) => ({ ...prev, season: Number(value) }))} hideControls />
<NumberInput placeholder="episode" value={override.episode} onChange={(value) => setOverride((prev) => ({ ...prev, episode: Number(value) }))} hideControls />
<Button onClick={manualSearch}>Search</Button>
</SimpleGrid>
{candidates.length === 0 ? (
<Text c="dimmed" size="sm">
Aday bulunamadı.
</Text>
) : (
<Stack gap="xs">
{candidates.map((candidate) => (
<Group
key={candidate.id}
justify="space-between"
p="sm"
style={{ border: '1px solid rgba(16,32,50,0.1)', borderRadius: 10, background: 'rgba(255,255,255,0.75)' }}
>
<div>
<Text fw={600}>{candidate.provider}</Text>
<Text c="dimmed" size="sm">
{candidate.id} score {candidate.score}
</Text>
</div>
<Button size="xs" variant="light" leftSection={<IconCheck size={14} />} onClick={() => choose(candidate)}>
Seç
</Button>
</Group>
))}
</Stack>
)}
</Stack>
</Card>
) : null}
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
<Stack gap="sm">
<Group gap="xs">
<IconTerminal2 size={18} />
<Text fw={600} className="sw-page-title">
Canlı Log Akışı
</Text>
</Group>
<Divider />
<ScrollArea h={340}>
<Stack gap={6}>
{logs.length === 0 ? (
<Text c="dimmed" size="sm">
Henüz log yok.
</Text>
) : (
logs.map((log) => (
<Alert key={log._id + log.ts} color={log.level === 'error' ? 'red' : log.level === 'warn' ? 'yellow' : 'teal'} variant="light" p="xs">
<Group gap={8} wrap="nowrap" align="flex-start">
<Text ff="monospace" size="xs" c="dimmed" mt={2}>
[{new Date(log.ts).toLocaleTimeString()}]
</Text>
<div>
<Text fw={600} size="sm" ff="Sora, sans-serif">
{log.step}
</Text>
<Text size="sm">{log.message}</Text>
{log.meta ? (
<Text size="xs" c="dimmed" ff="monospace">
{JSON.stringify(log.meta)}
</Text>
) : null}
</div>
</Group>
</Alert>
))
)}
</Stack>
</ScrollArea>
</Stack>
</Card>
</Stack>
);
}
function Detail({ label, value }: { label: string; value: string }) {
return (
<div>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text fw={600}>{value}</Text>
</div>
);
}

View File

@@ -1,18 +1,21 @@
import { useCallback, useState } from 'react';
import { Button, Group, Select, Stack, Text, TextInput } from '@mantine/core';
import { IconFilter, IconSearch } from '@tabler/icons-react';
import { api } from '../api/client';
import { Job } from '../types';
import { usePoll } from '../hooks/usePoll';
import { JobTable } from '../components/JobTable';
import { JOB_STATUSES } from '../lib/status';
export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void }) {
const [jobs, setJobs] = useState<Job[]>([]);
const [status, setStatus] = useState('');
const [status, setStatus] = useState<string | null>(null);
const [search, setSearch] = useState('');
const load = useCallback(async () => {
const q = new URLSearchParams({ limit: '100' });
if (status) q.set('status', status);
if (search) q.set('search', search);
if (search.trim()) q.set('search', search.trim());
const data = await api<{ items: Job[] }>(`/api/jobs?${q.toString()}`);
setJobs(data.items);
}, [status, search]);
@@ -20,13 +23,39 @@ export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void })
usePoll(load, 4000);
return (
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input placeholder="status" value={status} onChange={(e) => setStatus(e.target.value)} />
<input placeholder="title/path" value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={() => load()}>Filtrele</button>
<Stack gap="lg">
<div>
<h1 className="sw-page-title">Jobs Explorer</h1>
<Text c="dimmed">Durum ve metin filtresiyle işleri detaylı inceleyin.</Text>
</div>
<Group align="end" gap="md" wrap="wrap">
<Select
label="Status"
placeholder="Tüm durumlar"
data={JOB_STATUSES.map((value) => ({ value, label: value }))}
value={status}
onChange={setStatus}
leftSection={<IconFilter size={16} />}
w={260}
clearable
/>
<TextInput
label="Ara"
placeholder="title/path"
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
w={320}
/>
<Button variant="light" onClick={() => load()}>
Filtreyi Uygula
</Button>
</Group>
<JobTable jobs={jobs} onSelect={onSelectJob} />
</div>
</Stack>
);
}

View File

@@ -1,4 +1,6 @@
import { useCallback, useState } from 'react';
import { ActionIcon, Badge, Card, Group, Stack, Text } from '@mantine/core';
import { IconArrowRight } from '@tabler/icons-react';
import { api } from '../api/client';
import { Job } from '../types';
import { usePoll } from '../hooks/usePoll';
@@ -8,22 +10,46 @@ export function ReviewPage({ onSelectJob }: { onSelectJob: (id: string) => void
const load = useCallback(async () => {
const data = await api<Job[]>('/api/review');
setJobs(data);
setJobs(data || []);
}, []);
usePoll(load, 5000);
return (
<div>
<h3>Needs Review</h3>
<ul>
{jobs.map((j) => (
<li key={j._id}>
{j.requestSnapshot?.title || '-'} ({j.status})
<button onClick={() => onSelectJob(j._id)}>Ac</button>
</li>
))}
</ul>
</div>
<Stack gap="lg">
<Group justify="space-between">
<div>
<h1 className="sw-page-title">Manual Review Queue</h1>
<Text c="dimmed">Belirsiz veya bulunamayan işler burada manuel karar bekler.</Text>
</div>
<Badge size="lg" color="yellow" variant="light">
{jobs.length} pending
</Badge>
</Group>
<Stack gap="sm">
{jobs.length === 0 ? (
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
<Text c="dimmed">Review bekleyen yok.</Text>
</Card>
) : (
jobs.map((job) => (
<Card key={job._id} withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
<Group justify="space-between" align="center">
<div>
<Text fw={600}>{job.requestSnapshot?.title || job.requestSnapshot?.path || 'Unknown job'}</Text>
<Text c="dimmed" size="sm">
{job.status} {new Date(job.updatedAt).toLocaleString()}
</Text>
</div>
<ActionIcon variant="light" size="lg" onClick={() => onSelectJob(job._id)}>
<IconArrowRight size={16} />
</ActionIcon>
</Group>
</Card>
))
)}
</Stack>
</Stack>
);
}

View File

@@ -1,32 +1,125 @@
import { useEffect, useState } from 'react';
import { Button, Card, Checkbox, Grid, Group, NumberInput, Stack, Text, TextInput } from '@mantine/core';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { api } from '../api/client';
import { Settings } from '../types';
export function SettingsPage() {
const [settings, setSettings] = useState<any>(null);
const [settings, setSettings] = useState<Settings | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
api<any>('/api/settings').then(setSettings);
api<Settings>('/api/settings').then(setSettings);
}, []);
async function save() {
await api('/api/settings', { method: 'POST', body: JSON.stringify(settings) });
if (!settings) return;
setSaving(true);
try {
await api('/api/settings', { method: 'POST', body: JSON.stringify(settings) });
notifications.show({ color: 'teal', title: 'Kaydedildi', message: 'Ayarlar başarıyla güncellendi.' });
} catch (error) {
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Ayarlar kaydedilemedi.' });
} finally {
setSaving(false);
}
}
if (!settings) return <div>Yukleniyor...</div>;
if (!settings) return <Text c="dimmed">Yükleniyor...</Text>;
return (
<div style={{ display: 'grid', gap: 8, maxWidth: 760 }}>
<label>Languages (comma)
<input value={settings.languages.join(',')} onChange={(e) => setSettings({ ...settings, languages: e.target.value.split(',').map((x) => x.trim()).filter(Boolean) })} />
</label>
<label><input type="checkbox" checked={settings.multiSubtitleEnabled} onChange={(e) => setSettings({ ...settings, multiSubtitleEnabled: e.target.checked })} /> Multi subtitle</label>
<label><input type="checkbox" checked={settings.overwriteExisting} onChange={(e) => setSettings({ ...settings, overwriteExisting: e.target.checked })} /> Overwrite existing</label>
<label><input type="checkbox" checked={settings.preferHI} onChange={(e) => setSettings({ ...settings, preferHI: e.target.checked })} /> Prefer HI</label>
<label><input type="checkbox" checked={settings.preferForced} onChange={(e) => setSettings({ ...settings, preferForced: e.target.checked })} /> Prefer Forced</label>
<label>stableChecks <input type="number" value={settings.stableChecks} onChange={(e) => setSettings({ ...settings, stableChecks: Number(e.target.value) })} /></label>
<label>stableIntervalSeconds <input type="number" value={settings.stableIntervalSeconds} onChange={(e) => setSettings({ ...settings, stableIntervalSeconds: Number(e.target.value) })} /></label>
<label>autoWriteThreshold <input type="number" step="0.01" value={settings.autoWriteThreshold} onChange={(e) => setSettings({ ...settings, autoWriteThreshold: Number(e.target.value) })} /></label>
<button onClick={save}>Kaydet</button>
</div>
<Stack gap="lg">
<div>
<h1 className="sw-page-title">Settings</h1>
<Text c="dimmed">Arama, stabilite ve yazma davranışlarını buradan yönetin.</Text>
</div>
<Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
<Stack>
<TextInput
label="Öncelikli diller"
description="Virgülle ayırın (örn: tr, en)"
value={settings.languages.join(', ')}
onChange={(event) =>
setSettings({
...settings,
languages: event.currentTarget.value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
})
}
/>
<Grid>
<Grid.Col span={{ base: 12, md: 6 }}>
<Checkbox
label="Multi subtitle enabled"
checked={settings.multiSubtitleEnabled}
onChange={(event) => setSettings({ ...settings, multiSubtitleEnabled: event.currentTarget.checked })}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Checkbox
label="Overwrite existing"
checked={settings.overwriteExisting}
onChange={(event) => setSettings({ ...settings, overwriteExisting: event.currentTarget.checked })}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Checkbox
label="Prefer HI"
checked={settings.preferHI}
onChange={(event) => setSettings({ ...settings, preferHI: event.currentTarget.checked })}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Checkbox
label="Prefer forced"
checked={settings.preferForced}
onChange={(event) => setSettings({ ...settings, preferForced: event.currentTarget.checked })}
/>
</Grid.Col>
</Grid>
<Grid>
<Grid.Col span={{ base: 12, md: 4 }}>
<NumberInput
label="Stable checks"
min={1}
value={settings.stableChecks}
onChange={(value) => setSettings({ ...settings, stableChecks: Number(value) })}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<NumberInput
label="Stable interval seconds"
min={1}
value={settings.stableIntervalSeconds}
onChange={(value) => setSettings({ ...settings, stableIntervalSeconds: Number(value) })}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<NumberInput
label="Auto write threshold"
min={0}
max={1}
step={0.01}
decimalScale={2}
value={settings.autoWriteThreshold}
onChange={(value) => setSettings({ ...settings, autoWriteThreshold: Number(value) })}
/>
</Grid.Col>
</Grid>
<Group justify="flex-end">
<Button loading={saving} leftSection={<IconDeviceFloppy size={16} />} onClick={save}>
Kaydet
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -1,60 +1,160 @@
import { useCallback, useState } from 'react';
import {
ActionIcon,
Badge,
Button,
Group,
Paper,
ScrollArea,
Select,
Stack,
Table,
Text,
TextInput
} from '@mantine/core';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconTrash, IconToggleLeft, IconToggleRight } from '@tabler/icons-react';
import { api } from '../api/client';
import { usePoll } from '../hooks/usePoll';
import { WatchedPath } from '../types';
export function WatchedPathsPage() {
const [items, setItems] = useState<any[]>([]);
const [items, setItems] = useState<WatchedPath[]>([]);
const [path, setPath] = useState('');
const [kind, setKind] = useState('mixed');
const [kind, setKind] = useState<string>('mixed');
const load = useCallback(async () => {
const data = await api<any[]>('/api/watched-paths');
setItems(data);
const data = await api<WatchedPath[]>('/api/watched-paths');
setItems(data || []);
}, []);
usePoll(load, 5000);
async function add() {
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'add', path, kind }) });
setPath('');
await load();
if (!path.trim()) return;
try {
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'add', path: path.trim(), kind }) });
setPath('');
notifications.show({ color: 'teal', title: 'Eklendi', message: 'Watched path başarıyla eklendi.' });
await load();
} catch (error) {
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Path eklenemedi.' });
}
}
async function toggle(item: any) {
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'toggle', path: item.path, enabled: !item.enabled }) });
await load();
async function toggle(item: WatchedPath) {
try {
await api('/api/watched-paths', {
method: 'POST',
body: JSON.stringify({ action: 'toggle', path: item.path, enabled: !item.enabled })
});
notifications.show({ color: 'cyan', title: 'Güncellendi', message: `${item.path} durumu değiştirildi.` });
await load();
} catch (error) {
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Durum güncellenemedi.' });
}
}
async function remove(item: any) {
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'remove', path: item.path }) });
await load();
function remove(item: WatchedPath) {
modals.openConfirmModal({
title: 'Path silinsin mi?',
centered: true,
children: <Text size="sm">{item.path} izleme listesinden çıkarılacak.</Text>,
labels: { confirm: 'Sil', cancel: 'Vazgeç' },
confirmProps: { color: 'red' },
onConfirm: async () => {
try {
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'remove', path: item.path }) });
notifications.show({ color: 'teal', title: 'Silindi', message: 'Watched path kaldırıldı.' });
await load();
} catch (error) {
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Path silinemedi.' });
}
}
});
}
return (
<div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input placeholder="/media/custom" value={path} onChange={(e) => setPath(e.target.value)} />
<select value={kind} onChange={(e) => setKind(e.target.value)}>
<option value="tv">tv</option>
<option value="movie">movie</option>
<option value="mixed">mixed</option>
</select>
<button onClick={add}>Ekle</button>
<Stack gap="lg">
<div>
<h1 className="sw-page-title">Watched Paths</h1>
<Text c="dimmed">Core watcherın izleyeceği dizinleri buradan yönetin.</Text>
</div>
<table width="100%" cellPadding={8} style={{ borderCollapse: 'collapse', background: '#fff' }}>
<thead><tr><th align="left">Path</th><th align="left">Kind</th><th align="left">Enabled</th><th /></tr></thead>
<tbody>
{items.map((i) => (
<tr key={i.path} style={{ borderTop: '1px solid #e2e8f0' }}>
<td>{i.path}</td><td>{i.kind}</td><td>{String(i.enabled)}</td>
<td>
<button onClick={() => toggle(i)}>Toggle</button>
<button onClick={() => remove(i)}>Sil</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Group align="end" wrap="wrap">
<TextInput
label="Yeni dizin"
placeholder="/media/custom"
value={path}
onChange={(event) => setPath(event.currentTarget.value)}
w={420}
/>
<Select
label="İçerik tipi"
value={kind}
onChange={(value) => setKind(value || 'mixed')}
data={[
{ value: 'tv', label: 'TV' },
{ value: 'movie', label: 'Movie' },
{ value: 'mixed', label: 'Mixed' }
]}
w={140}
/>
<Button leftSection={<IconPlus size={16} />} onClick={add}>
Ekle
</Button>
</Group>
<Paper radius="lg" withBorder p={0} style={{ background: 'rgba(255,255,255,0.88)', borderColor: 'rgba(16,32,50,0.12)' }}>
<ScrollArea>
<Table horizontalSpacing="md" verticalSpacing="sm" miw={780}>
<Table.Thead>
<Table.Tr>
<Table.Th>Path</Table.Th>
<Table.Th>Kind</Table.Th>
<Table.Th>Enabled</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<Text c="dimmed" ta="center" py="md">
Henüz watched path eklenmedi.
</Text>
</Table.Td>
</Table.Tr>
) : (
items.map((item) => (
<Table.Tr key={item.path}>
<Table.Td>{item.path}</Table.Td>
<Table.Td>
<Badge variant="light">{item.kind}</Badge>
</Table.Td>
<Table.Td>
<Badge color={item.enabled ? 'teal' : 'gray'} variant="dot">
{item.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap={6} wrap="nowrap">
<ActionIcon variant="light" color={item.enabled ? 'yellow' : 'teal'} onClick={() => toggle(item)}>
{item.enabled ? <IconToggleLeft size={16} /> : <IconToggleRight size={16} />}
</ActionIcon>
<ActionIcon variant="light" color="red" onClick={() => remove(item)}>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</ScrollArea>
</Paper>
</Stack>
);
}

View File

@@ -1,10 +1,40 @@
export interface JobRequestSnapshot {
title?: string;
path?: string;
type?: string;
year?: number;
release?: string;
season?: number;
episode?: number;
}
export interface JobCandidate {
id: string;
provider: string;
score: number;
lang?: string;
}
export interface Job {
_id: string;
status: string;
requestSnapshot?: any;
apiSnapshot?: any;
result?: any;
mediaFileId?: any;
requestSnapshot?: JobRequestSnapshot;
apiSnapshot?: {
candidates?: JobCandidate[];
};
result?: {
subtitles?: Array<{ writtenPath: string }>;
};
mediaFileId?: {
path?: string;
mediaInfo?: {
video?: {
codec_name?: string;
width?: number;
height?: number;
};
};
};
createdAt: string;
updatedAt: string;
}
@@ -16,5 +46,22 @@ export interface JobLog {
message: string;
level: 'info' | 'warn' | 'error';
ts: string;
meta?: any;
meta?: unknown;
}
export interface Settings {
languages: string[];
multiSubtitleEnabled: boolean;
overwriteExisting: boolean;
preferHI: boolean;
preferForced: boolean;
stableChecks: number;
stableIntervalSeconds: number;
autoWriteThreshold: number;
}
export interface WatchedPath {
path: string;
kind: 'tv' | 'movie' | 'mixed';
enabled: boolean;
}