Compare commits

...

2 Commits

Author SHA1 Message Date
d0b5f4c10f feat(ui): redesign panel with Mantine light theme, routing, modals and notifications 2026-02-28 23:12:47 +03:00
0dd8f60626 feat(api): fps tabanlı altyazı eşleştirmesi ekle
Video ve altyazı FPS değerlerini karşılaştırarak daha doğru eşleştirme
yapar. Tam eşleşme ve token eşleşmesi bulunamadığında FPS uyumlu
altyazıları önceliklendirir. FFprobe çıktısından FPS değerini normalize
eder ve karşılaştırma için kullanır.
2026-02-16 17:05:51 +03:00
18 changed files with 2361 additions and 254 deletions

View File

@@ -16,7 +16,7 @@ export interface RealTaCandidate {
releaseHints: string[];
isHI: boolean;
isForced: boolean;
strategy?: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback';
strategy?: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
isPackage?: boolean;
}
@@ -113,6 +113,34 @@ function normalizeReleaseHints(raw: string): string[] {
.slice(0, 10);
}
function normalizeFpsText(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) return null;
const s = String(raw).trim().replace(/,/g, '.').replace(/\s+/g, '');
if (!s) return null;
if (s.includes('/')) {
const [a, b] = s.split('/');
const n = Number(a);
const d = Number(b);
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null;
const v = Number((n / d).toFixed(3));
return Number.isInteger(v) ? String(v) : String(v);
}
const v = Number(s);
if (!Number.isFinite(v)) return null;
const rounded = Number(v.toFixed(3));
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
}
function fpsEquals(a?: string | null, b?: string | null): boolean {
if (!a || !b) return false;
const an = Number(a);
const bn = Number(b);
if (!Number.isFinite(an) || !Number.isFinite(bn)) return false;
return Math.abs(an - bn) <= 0.01;
}
function abs(base: string, maybeRelative: string): string {
return new URL(maybeRelative, base).toString();
}
@@ -268,7 +296,8 @@ function pickSubPageFromMovieDetail(
title: string;
releaseHints: string[];
isHI: boolean;
strategy: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback';
strategy: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
fps?: string | null;
isPackage?: boolean;
};
noMatchReason?: 'episode_not_matched' | 'release_not_matched' | 'no_sub_rows';
@@ -276,6 +305,11 @@ function pickSubPageFromMovieDetail(
const $ = cheerio.load(html);
const wantedRelease = normalizeText(params.release || '');
const wantedReleaseTokens = wantedRelease.split(/\s+/).filter(Boolean);
const wantedFps = normalizeFpsText(
params.mediaInfo?.video?.fps ??
params.mediaInfo?.video?.avg_frame_rate ??
params.mediaInfo?.video?.r_frame_rate
);
const wantedSeason = params.type === 'tv' ? params.season : undefined;
const wantedEpisode = params.type === 'tv' ? params.episode : undefined;
const rows = $('[class*="altsonsez"]');
@@ -292,6 +326,7 @@ function pickSubPageFromMovieDetail(
season?: number;
episode?: number;
isPackage: boolean;
fps?: string | null;
}> = [];
rows.each((_, row) => {
@@ -307,6 +342,7 @@ function pickSubPageFromMovieDetail(
const isTr = $(row).find('.flagtr').length > 0;
const indirmeRaw = ($(row).find('.alindirme').text() || '').replace(/\./g, '').replace(/,/g, '').trim();
const downloadCount = Number(indirmeRaw.replace(/[^\d]/g, '')) || 0;
const fps = normalizeFpsText(($(row).find('.alfps').text() || '').trim());
const { season, episode, isPackage } = parseSeasonEpisodeFromRow($, row);
if (params.type === 'tv') {
@@ -341,7 +377,8 @@ function pickSubPageFromMovieDetail(
downloadCount,
season,
episode,
isPackage
isPackage,
fps
});
});
@@ -366,28 +403,39 @@ function pickSubPageFromMovieDetail(
}
}
if (!wantedRelease || forcedStrategy === 'package_fallback') {
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
if (forcedStrategy === 'package_fallback') {
return { picked: { ...picked, strategy: 'package_fallback', isPackage: true } };
if (wantedRelease && forcedStrategy !== 'package_fallback') {
const exact = selectedPool
.filter((c) => c.releaseExact)
.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
if (exact) {
return { picked: { ...exact, strategy: 'exact' } };
}
const token = selectedPool
.filter((c) => c.releaseTokenHits > 0)
.sort((a, b) => b.releaseTokenHits - a.releaseTokenHits || b.score - a.score || b.downloadCount - a.downloadCount)[0];
if (token) {
return { picked: { ...token, strategy: 'token' } };
}
}
const fpsMatched = wantedFps
? selectedPool.find((c) => fpsEquals(c.fps || null, wantedFps))
: undefined;
if (fpsMatched) {
return { picked: { ...fpsMatched, strategy: 'fps' } };
}
if (forcedStrategy === 'package_fallback') {
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
return { picked: { ...picked, strategy: 'package_fallback', isPackage: true } };
}
if (!wantedRelease) {
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
return { picked: { ...picked, strategy: 'default' } };
}
const exact = selectedPool
.filter((c) => c.releaseExact)
.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
if (exact) {
return { picked: { ...exact, strategy: 'exact' } };
}
const token = selectedPool
.filter((c) => c.releaseTokenHits > 0)
.sort((a, b) => b.releaseTokenHits - a.releaseTokenHits || b.score - a.score || b.downloadCount - a.downloadCount)[0];
if (token) {
return { picked: { ...token, strategy: 'token' } };
}
if (params.type === 'tv') {
// TV'de once bolum dogrulugu, sonra release gelir. Release bulunamasa da en iyi bolum satirini kullan.
const tvFallback = selectedPool
@@ -502,6 +550,12 @@ export async function searchTurkceAltyaziReal(params: SearchParams): Promise<Rea
subUrl: pickedSub.subUrl,
releaseHints: pickedSub.releaseHints,
strategy: pickedSub.strategy,
fps: pickedSub.fps,
wantedFps: normalizeFpsText(
params.mediaInfo?.video?.fps ??
params.mediaInfo?.video?.avg_frame_rate ??
params.mediaInfo?.video?.r_frame_rate
),
isPackage: pickedSub.isPackage === true
});

View File

@@ -3,6 +3,25 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
function parseFraction(raw?: string): number | null {
if (!raw) return null;
const parts = raw.split('/');
if (parts.length === 2) {
const n = Number(parts[0]);
const d = Number(parts[1]);
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null;
return n / d;
}
const v = Number(raw);
return Number.isFinite(v) ? v : null;
}
function normalizeFpsValue(raw?: string): number | null {
const v = parseFraction(raw);
if (v === null) return null;
return Number(v.toFixed(3));
}
export async function analyzeWithFfprobe(path: string): Promise<any> {
const { stdout } = await execFileAsync('ffprobe', [
'-v',
@@ -25,7 +44,8 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
codec_name: video.codec_name,
width: video.width,
height: video.height,
r_frame_rate: video.r_frame_rate
r_frame_rate: video.r_frame_rate,
fps: normalizeFpsValue(video.avg_frame_rate || video.r_frame_rate)
}
: null,
audio,
@@ -39,7 +59,7 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
export function fallbackMediaInfo(): any {
return {
video: { codec_name: 'unknown', width: 1920, height: 1080, r_frame_rate: '24/1' },
video: { codec_name: 'unknown', width: 1920, height: 1080, r_frame_rate: '24/1', fps: 24 },
audio: [{ codec_name: 'unknown', channels: 2, language: 'und' }],
format: { duration: '0', bit_rate: '0', format_name: 'matroska' }
};

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",

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;
}