Compare commits

..

4 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
2c60c669c0 feat(api): turkcealtyazi aramasına sayfalama desteği ekle
Arama sonuçlarının birden fazla sayfa taranabilmesi için sayfalama
mekanizması eklendi. İlk sayfadan maksimum sayfa sayısı keşfedilir ve
her sayfa taranarak eşleşen film aranır. Sayfalar arası bekleme süresi
korunur ve boş sayfalarda işlem durdurulur. Maksimum 10 sayfa sınırı
eklendi.
2026-02-16 14:47:45 +03:00
14c64d8032 feat(watcher): polling desteği ekle
CORE_WATCHER_USE_POLLING ve CORE_WATCHER_POLL_INTERVAL_MS ortam
değişkenleri eklendi. Bu değişkenler sayesinde dosya izleyici için
polling modu ve aralığı yapılandırılabilir hale getirildi.
2026-02-16 14:47:26 +03:00
21 changed files with 2447 additions and 265 deletions

View File

@@ -13,6 +13,8 @@ MEDIA_MOVIE_PATH=/media/movie
ENABLE_API_KEY=false ENABLE_API_KEY=false
API_KEY= API_KEY=
CORE_WATCHER_DEDUP_WINDOW_MS=15000 CORE_WATCHER_DEDUP_WINDOW_MS=15000
CORE_WATCHER_USE_POLLING=false
CORE_WATCHER_POLL_INTERVAL_MS=2000
ENABLE_TA_STEP_LOGS=false ENABLE_TA_STEP_LOGS=false
CLAMAV_AUTO_UPDATE=true CLAMAV_AUTO_UPDATE=true
CLAMAV_FAIL_ON_UPDATE_ERROR=false CLAMAV_FAIL_ON_UPDATE_ERROR=false

View File

@@ -16,7 +16,7 @@ export interface RealTaCandidate {
releaseHints: string[]; releaseHints: string[];
isHI: boolean; isHI: boolean;
isForced: boolean; isForced: boolean;
strategy?: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback'; strategy?: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
isPackage?: boolean; isPackage?: boolean;
} }
@@ -113,6 +113,34 @@ function normalizeReleaseHints(raw: string): string[] {
.slice(0, 10); .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 { function abs(base: string, maybeRelative: string): string {
return new URL(maybeRelative, base).toString(); return new URL(maybeRelative, base).toString();
} }
@@ -149,7 +177,31 @@ function buildFindQuery(params: SearchParams): string {
return queryTokens.join(' '); return queryTokens.join(' ');
} }
function pickMovieLinkFromSearch(html: string, params: SearchParams, baseUrl: string): { movieUrl: string; movieTitle: string } | null { function buildSearchUrl(query: string, page: number): string {
if (page <= 1) return `${env.turkcealtyaziBaseUrl}/find.php?cat=sub&find=${encodeURIComponent(query)}`;
return `${env.turkcealtyaziBaseUrl}/find.php?cat=sub&find=${encodeURIComponent(query)}&p=${page}`;
}
function parseSearchMaxPage(html: string, baseUrl: string): number {
const $ = cheerio.load(html);
let maxPage = 1;
$('a[href]').each((_, el) => {
const href = ($(el).attr('href') || '').trim();
if (!href) return;
let parsedUrl: URL | null = null;
try {
parsedUrl = new URL(href, baseUrl);
} catch {
return;
}
if (parsedUrl.pathname !== '/find.php') return;
const p = Number(parsedUrl.searchParams.get('p') || '1');
if (Number.isFinite(p) && p > maxPage) maxPage = p;
});
return maxPage;
}
function extractMovieLinksFromSearch(html: string, params: SearchParams, baseUrl: string): Array<{ url: string; title: string; year?: number; score: number }> {
const $ = cheerio.load(html); const $ = cheerio.load(html);
const wantedYear = params.year; const wantedYear = params.year;
const wantedTitleTokens = tokenize(params.title); const wantedTitleTokens = tokenize(params.title);
@@ -207,7 +259,12 @@ function pickMovieLinkFromSearch(html: string, params: SearchParams, baseUrl: st
if (!prev || item.score > prev.score) dedup.set(item.url, item); if (!prev || item.score > prev.score) dedup.set(item.url, item);
} }
const ordered = [...dedup.values()].sort((a, b) => b.score - a.score); return [...dedup.values()].sort((a, b) => b.score - a.score);
}
function pickMovieLinkFromSearch(html: string, params: SearchParams, baseUrl: string): { movieUrl: string; movieTitle: string } | null {
const wantedYear = params.year;
const ordered = extractMovieLinksFromSearch(html, params, baseUrl);
if (ordered.length === 0) return null; if (ordered.length === 0) return null;
const best = ordered[0]; const best = ordered[0];
@@ -239,7 +296,8 @@ function pickSubPageFromMovieDetail(
title: string; title: string;
releaseHints: string[]; releaseHints: string[];
isHI: boolean; isHI: boolean;
strategy: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback'; strategy: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
fps?: string | null;
isPackage?: boolean; isPackage?: boolean;
}; };
noMatchReason?: 'episode_not_matched' | 'release_not_matched' | 'no_sub_rows'; noMatchReason?: 'episode_not_matched' | 'release_not_matched' | 'no_sub_rows';
@@ -247,6 +305,11 @@ function pickSubPageFromMovieDetail(
const $ = cheerio.load(html); const $ = cheerio.load(html);
const wantedRelease = normalizeText(params.release || ''); const wantedRelease = normalizeText(params.release || '');
const wantedReleaseTokens = wantedRelease.split(/\s+/).filter(Boolean); 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 wantedSeason = params.type === 'tv' ? params.season : undefined;
const wantedEpisode = params.type === 'tv' ? params.episode : undefined; const wantedEpisode = params.type === 'tv' ? params.episode : undefined;
const rows = $('[class*="altsonsez"]'); const rows = $('[class*="altsonsez"]');
@@ -263,6 +326,7 @@ function pickSubPageFromMovieDetail(
season?: number; season?: number;
episode?: number; episode?: number;
isPackage: boolean; isPackage: boolean;
fps?: string | null;
}> = []; }> = [];
rows.each((_, row) => { rows.each((_, row) => {
@@ -278,6 +342,7 @@ function pickSubPageFromMovieDetail(
const isTr = $(row).find('.flagtr').length > 0; const isTr = $(row).find('.flagtr').length > 0;
const indirmeRaw = ($(row).find('.alindirme').text() || '').replace(/\./g, '').replace(/,/g, '').trim(); const indirmeRaw = ($(row).find('.alindirme').text() || '').replace(/\./g, '').replace(/,/g, '').trim();
const downloadCount = Number(indirmeRaw.replace(/[^\d]/g, '')) || 0; const downloadCount = Number(indirmeRaw.replace(/[^\d]/g, '')) || 0;
const fps = normalizeFpsText(($(row).find('.alfps').text() || '').trim());
const { season, episode, isPackage } = parseSeasonEpisodeFromRow($, row); const { season, episode, isPackage } = parseSeasonEpisodeFromRow($, row);
if (params.type === 'tv') { if (params.type === 'tv') {
@@ -312,7 +377,8 @@ function pickSubPageFromMovieDetail(
downloadCount, downloadCount,
season, season,
episode, episode,
isPackage isPackage,
fps
}); });
}); });
@@ -337,14 +403,7 @@ function pickSubPageFromMovieDetail(
} }
} }
if (!wantedRelease || forcedStrategy === 'package_fallback') { 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 } };
}
return { picked: { ...picked, strategy: 'default' } };
}
const exact = selectedPool const exact = selectedPool
.filter((c) => c.releaseExact) .filter((c) => c.releaseExact)
.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0]; .sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
@@ -358,6 +417,24 @@ function pickSubPageFromMovieDetail(
if (token) { if (token) {
return { picked: { ...token, strategy: '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' } };
}
if (params.type === 'tv') { if (params.type === 'tv') {
// TV'de once bolum dogrulugu, sonra release gelir. Release bulunamasa da en iyi bolum satirini kullan. // TV'de once bolum dogrulugu, sonra release gelir. Release bulunamasa da en iyi bolum satirini kullan.
@@ -378,23 +455,57 @@ export async function searchTurkceAltyaziReal(params: SearchParams): Promise<Rea
const q = buildFindQuery(params); const q = buildFindQuery(params);
if (!q) return []; if (!q) return [];
const searchUrl = `${env.turkcealtyaziBaseUrl}/find.php?cat=sub&find=${encodeURIComponent(q)}`; const firstSearchUrl = buildSearchUrl(q, 1);
const cookies = new Map<string, string>(); const cookies = new Map<string, string>();
taInfo('TA_SEARCH_START', 'TurkceAltyazi search started', { taInfo('TA_SEARCH_START', 'TurkceAltyazi search started', {
title: params.title, title: params.title,
year: params.year, year: params.year,
release: params.release, release: params.release,
query: q, query: q,
searchUrl searchUrl: firstSearchUrl
}); });
try { try {
const hardMaxPages = 10;
let scannedPages = 0;
let discoveredMaxPages = 1;
let pickedMovie: { movieUrl: string; movieTitle: string } | null = null;
for (let page = 1; page <= Math.min(discoveredMaxPages, hardMaxPages); page++) {
const searchUrl = buildSearchUrl(q, page);
await sleep(env.turkcealtyaziMinDelayMs); await sleep(env.turkcealtyaziMinDelayMs);
const searchRes = await getWithRetry(searchUrl, 2, cookies); const searchRes = await getWithRetry(searchUrl, 2, cookies);
mergeCookies(cookies, searchRes.setCookie); mergeCookies(cookies, searchRes.setCookie);
const pickedMovie = pickMovieLinkFromSearch(searchRes.body, params, env.turkcealtyaziBaseUrl); scannedPages += 1;
if (page === 1) {
discoveredMaxPages = Math.max(1, parseSearchMaxPage(searchRes.body, env.turkcealtyaziBaseUrl));
}
const pageLinks = extractMovieLinksFromSearch(searchRes.body, params, env.turkcealtyaziBaseUrl);
taInfo('TA_SEARCH_PAGE_SCANNED', 'TurkceAltyazi search page scanned', {
page,
pageLinks: pageLinks.length,
discoveredMaxPages
});
// TA may return HTTP 200 with an empty list for out-of-range pages.
if (pageLinks.length === 0 && page > 1) {
taInfo('TA_SEARCH_PAGE_EMPTY_STOP', 'Search page has empty list, stopping pagination', { page });
break;
}
pickedMovie = pickMovieLinkFromSearch(searchRes.body, params, env.turkcealtyaziBaseUrl);
if (pickedMovie) break;
}
if (!pickedMovie) { if (!pickedMovie) {
taInfo('TA_SEARCH_RESULT', 'Movie page not matched from search list', { title: params.title, year: params.year, query: q }); taInfo('TA_SEARCH_RESULT', 'Movie page not matched from search list', {
title: params.title,
year: params.year,
query: q,
scannedPages
});
throw new PipelineError({ throw new PipelineError({
code: 'TA_MOVIE_NOT_MATCHED', code: 'TA_MOVIE_NOT_MATCHED',
message: `Movie not matched on search list (title=${params.title}, year=${params.year ?? 'n/a'})`, message: `Movie not matched on search list (title=${params.title}, year=${params.year ?? 'n/a'})`,
@@ -439,6 +550,12 @@ export async function searchTurkceAltyaziReal(params: SearchParams): Promise<Rea
subUrl: pickedSub.subUrl, subUrl: pickedSub.subUrl,
releaseHints: pickedSub.releaseHints, releaseHints: pickedSub.releaseHints,
strategy: pickedSub.strategy, 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 isPackage: pickedSub.isPackage === true
}); });

View File

@@ -15,5 +15,7 @@ export const env = {
enableApiKey: process.env.ENABLE_API_KEY === 'true', enableApiKey: process.env.ENABLE_API_KEY === 'true',
apiKey: process.env.API_KEY ?? '', apiKey: process.env.API_KEY ?? '',
watcherDedupWindowMs: Number(process.env.CORE_WATCHER_DEDUP_WINDOW_MS ?? 15000), watcherDedupWindowMs: Number(process.env.CORE_WATCHER_DEDUP_WINDOW_MS ?? 15000),
watcherUsePolling: process.env.CORE_WATCHER_USE_POLLING === 'true',
watcherPollIntervalMs: Number(process.env.CORE_WATCHER_POLL_INTERVAL_MS ?? 2000),
isDev: (process.env.NODE_ENV ?? 'development') !== 'production' isDev: (process.env.NODE_ENV ?? 'development') !== 'production'
}; };

View File

@@ -3,6 +3,25 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile); 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> { export async function analyzeWithFfprobe(path: string): Promise<any> {
const { stdout } = await execFileAsync('ffprobe', [ const { stdout } = await execFileAsync('ffprobe', [
'-v', '-v',
@@ -25,7 +44,8 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
codec_name: video.codec_name, codec_name: video.codec_name,
width: video.width, width: video.width,
height: video.height, 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, : null,
audio, audio,
@@ -39,7 +59,7 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
export function fallbackMediaInfo(): any { export function fallbackMediaInfo(): any {
return { 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' }], audio: [{ codec_name: 'unknown', channels: 2, language: 'und' }],
format: { duration: '0', bit_rate: '0', format_name: 'matroska' } format: { duration: '0', bit_rate: '0', format_name: 'matroska' }
}; };

View File

@@ -26,7 +26,13 @@ export async function startWatcher(): Promise<void> {
const byPath = new Map(watched.map((w) => [w.path, w.kind])); const byPath = new Map(watched.map((w) => [w.path, w.kind]));
const shouldProcessEvent = createEventDeduper(env.watcherDedupWindowMs); const shouldProcessEvent = createEventDeduper(env.watcherDedupWindowMs);
const watcher = chokidar.watch(paths, { ignoreInitial: false, awaitWriteFinish: false, persistent: true }); const watcher = chokidar.watch(paths, {
ignoreInitial: false,
awaitWriteFinish: false,
persistent: true,
usePolling: env.watcherUsePolling,
interval: env.watcherPollIntervalMs
});
watcher.on('add', async (p) => { watcher.on('add', async (p) => {
if (!isVideoFile(p)) return; if (!isVideoFile(p)) return;
@@ -51,7 +57,9 @@ export async function startWatcher(): Promise<void> {
await media.MediaFileModel.updateOne({ path: p }, { status: 'MISSING', lastSeenAt: new Date() }); await media.MediaFileModel.updateOne({ path: p }, { status: 'MISSING', lastSeenAt: new Date() });
}); });
console.log(`[core] watcher started for: ${paths.join(', ')}`); console.log(
`[core] watcher started for: ${paths.join(', ')} (polling=${env.watcherUsePolling ? 'on' : 'off'}, intervalMs=${env.watcherPollIntervalMs})`
);
} }
function resolveKind(filePath: string, byPath: Map<string, 'tv' | 'movie' | 'mixed'>): 'tv' | 'movie' { function resolveKind(filePath: string, byPath: Map<string, 'tv' | 'movie' | 'mixed'>): 'tv' | 'movie' {

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,14 @@
"start": "serve -s dist -l 3000" "start": "serve -s dist -l 3000"
}, },
"dependencies": { "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": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.18", "@types/react": "^18.3.18",

View File

@@ -1,5 +1,7 @@
import { useState } from 'react'; import { Route, Routes, useNavigate, useParams } from 'react-router-dom';
import { Layout, Tab } from './components/Layout'; 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 { DashboardPage } from './pages/DashboardPage';
import { JobsPage } from './pages/JobsPage'; import { JobsPage } from './pages/JobsPage';
import { JobDetailPage } from './pages/JobDetailPage'; import { JobDetailPage } from './pages/JobDetailPage';
@@ -8,27 +10,44 @@ import { SettingsPage } from './pages/SettingsPage';
import { WatchedPathsPage } from './pages/WatchedPathsPage'; import { WatchedPathsPage } from './pages/WatchedPathsPage';
export default function App() { export default function App() {
const [tab, setTab] = useState<Tab>('dashboard'); const navigate = useNavigate();
const [selectedJob, setSelectedJob] = useState<string | null>(null);
const handleSelectJob = (jobId: string) => {
navigate(`/jobs/${jobId}`);
};
return ( return (
<Layout tab={tab} setTab={setTab}> <Layout>
{selectedJob && ( <Routes>
<div style={{ marginBottom: 12 }}> <Route path="/" element={<DashboardPage onSelectJob={handleSelectJob} />} />
<button onClick={() => setSelectedJob(null)}>Job listesine don</button> <Route path="/jobs" element={<JobsPage onSelectJob={handleSelectJob} />} />
</div> <Route path="/jobs/:id" element={<JobDetailRoute />} />
)} <Route path="/review" element={<ReviewPage onSelectJob={handleSelectJob} />} />
{selectedJob ? ( <Route path="/paths" element={<WatchedPathsPage />} />
<JobDetailPage jobId={selectedJob} /> <Route path="/settings" element={<SettingsPage />} />
) : ( </Routes>
<>
{tab === 'dashboard' && <DashboardPage onSelectJob={setSelectedJob} />}
{tab === 'jobs' && <JobsPage onSelectJob={setSelectedJob} />}
{tab === 'review' && <ReviewPage onSelectJob={setSelectedJob} />}
{tab === 'settings' && <SettingsPage />}
{tab === 'paths' && <WatchedPathsPage />}
</>
)}
</Layout> </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 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 || {}) }, headers: { 'content-type': 'application/json', ...(options?.headers || {}) },
...options ...options
}); });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
}
return res.json(); return res.json();
} }

View File

@@ -1,26 +1,50 @@
import { Badge, Paper, ScrollArea, Table, Text } from '@mantine/core';
import { Job } from '../types'; import { Job } from '../types';
import { statusColor } from '../lib/status';
export function JobTable({ jobs, onSelect }: { jobs: Job[]; onSelect: (id: string) => void }) { export function JobTable({ jobs, onSelect }: { jobs: Job[]; onSelect: (id: string) => void }) {
return ( return (
<table width="100%" cellPadding={8} style={{ borderCollapse: 'collapse', background: '#fff' }}> <Paper radius="lg" withBorder p={0} style={{ background: 'rgba(255,255,255,0.88)', borderColor: 'rgba(16,32,50,0.12)' }}>
<thead> <ScrollArea>
<tr> <Table highlightOnHover horizontalSpacing="md" verticalSpacing="sm" miw={780}>
<th align="left">ID</th> <Table.Thead>
<th align="left">Durum</th> <Table.Tr>
<th align="left">Baslik</th> <Table.Th>ID</Table.Th>
<th align="left">Guncelleme</th> <Table.Th>Durum</Table.Th>
</tr> <Table.Th>Başlık</Table.Th>
</thead> <Table.Th>Güncelleme</Table.Th>
<tbody> </Table.Tr>
{jobs.map((j) => ( </Table.Thead>
<tr key={j._id} onClick={() => onSelect(j._id)} style={{ cursor: 'pointer', borderTop: '1px solid #e2e8f0' }}> <Table.Tbody>
<td>{j._id.slice(-8)}</td> {jobs.length === 0 ? (
<td>{j.status}</td> <Table.Tr>
<td>{j.requestSnapshot?.title || '-'}</td> <Table.Td colSpan={4}>
<td>{new Date(j.updatedAt).toLocaleString()}</td> <Text c="dimmed" ta="center" py="lg">
</tr> Kayıt bulunamadı.
))} </Text>
</tbody> </Table.Td>
</table> </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; interface MenuItem {
export type Tab = (typeof tabs)[number]; 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 ( return (
<div style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', background: 'linear-gradient(120deg,#f6f8fb,#eef3ff)', minHeight: '100vh', color: '#0f172a' }}> <AppShell
<header style={{ padding: 16, borderBottom: '1px solid #dbe2f0', display: 'flex', gap: 8, flexWrap: 'wrap' }}> header={{ height: 70 }}
<strong>subwatcher</strong> navbar={{ width: 270, breakpoint: 'sm', collapsed: { mobile: !opened } }}
{tabs.map((t) => ( padding="lg"
<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' }}> styles={{
{t} main: {
</button> background: 'transparent'
))} },
</header> navbar: {
<main style={{ padding: 16 }}>{children}</main> 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> </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 React from 'react';
import ReactDOM from 'react-dom/client'; 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 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( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<MantineProvider theme={theme} defaultColorScheme="light">
<ModalsProvider>
<Notifications position="top-right" />
<BrowserRouter>
<App /> <App />
</BrowserRouter>
</ModalsProvider>
</MantineProvider>
</React.StrictMode> </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 { api } from '../api/client';
import { Job } from '../types'; import { Job } from '../types';
import { usePoll } from '../hooks/usePoll'; import { usePoll } from '../hooks/usePoll';
@@ -8,37 +10,88 @@ export function DashboardPage({ onSelectJob }: { onSelectJob: (id: string) => vo
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const load = useCallback(async () => { 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); setJobs(data.items);
}, []); }, []);
usePoll(load, 5000); usePoll(load, 5000);
const stats = useMemo(() => {
const since = Date.now() - 24 * 3600 * 1000; const since = Date.now() - 24 * 3600 * 1000;
const recent = jobs.filter((x) => new Date(x.createdAt).getTime() >= since); const recent = jobs.filter((x) => new Date(x.createdAt).getTime() >= since);
const done = recent.filter((x) => x.status === 'DONE').length; return {
const review = recent.filter((x) => x.status === 'NEEDS_REVIEW').length; recent,
const errors = recent.filter((x) => x.status === 'ERROR').length; 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 ( return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div> <div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(120px, 1fr))', gap: 12, marginBottom: 16 }}> <h1 className="sw-page-title">Operations Dashboard</h1>
<Stat label="24h Job" value={String(recent.length)} /> <Text c="dimmed">Son 24 saatin özetini ve son işleri buradan takip et.</Text>
<Stat label="DONE" value={String(done)} />
<Stat label="REVIEW" value={String(review)} />
<Stat label="ERROR" value={String(errors)} />
</div> </div>
<h3>Son Isler</h3> <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} /> <JobTable jobs={jobs} onSelect={onSelectJob} />
</div> </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 ( return (
<div style={{ border: '1px solid #cbd5e1', borderRadius: 12, padding: 12, background: '#ffffffcc' }}> <Card
<div style={{ fontSize: 12, opacity: 0.7 }}>{label}</div> radius="lg"
<div style={{ fontSize: 24, fontWeight: 700 }}>{value}</div> withBorder
</div> 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 { useEffect, useState } from 'react';
import { api } from '../api/client'; import {
import { Job, JobLog } from '../types'; 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 }) { export function JobDetailPage({ jobId }: { jobId: string }) {
const [job, setJob] = useState<Job | null>(null); const [job, setJob] = useState<Job | null>(null);
const [logs, setLogs] = useState<JobLog[]>([]); const [logs, setLogs] = useState<JobLog[]>([]);
const [override, setOverride] = useState<any>({}); const [override, setOverride] = useState<ManualOverride>({});
const [candidates, setCandidates] = useState<any[]>([]); const [candidates, setCandidates] = useState<JobCandidate[]>([]);
useEffect(() => { useEffect(() => {
let es: EventSource | null = null; let es: EventSource | null = null;
(async () => { (async () => {
const j = await api<Job>(`/api/jobs/${jobId}`); const loadedJob = await api<Job>(`/api/jobs/${jobId}`);
setJob(j); setJob(loadedJob);
const l = await api<{ items: JobLog[] }>(`/api/jobs/${jobId}/logs?limit=200`);
setLogs(l.items);
if (j.apiSnapshot?.candidates) setCandidates(j.apiSnapshot.candidates);
es = new EventSource(`/api/jobs/${jobId}/stream`); const loadedLogs = await api<{ items: JobLog[] }>(`/api/jobs/${jobId}/logs?limit=200`);
es.onmessage = (ev) => { setLogs(loadedLogs.items);
const item = JSON.parse(ev.data);
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]); setLogs((prev) => [...prev, item]);
}; };
})(); })();
@@ -30,66 +59,173 @@ export function JobDetailPage({ jobId }: { jobId: string }) {
}, [jobId]); }, [jobId]);
async function manualSearch() { async function manualSearch() {
const res = await api<any>(`/api/review/${jobId}/search`, { method: 'POST', body: JSON.stringify(override) }); try {
setCandidates(res.candidates || []); 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) { async function choose(candidate: JobCandidate) {
await api(`/api/review/${jobId}/choose`, { method: 'POST', body: JSON.stringify({ chosenCandidateId: c.id, lang: c.lang || 'tr' }) }); try {
const j = await api<Job>(`/api/jobs/${jobId}`); await api(`/api/review/${jobId}/choose`, {
setJob(j); 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 ( return (
<div style={{ display: 'grid', gap: 12 }}> <Stack gap="lg">
<h3>Job #{job._id.slice(-8)} - {job.status}</h3> <Group justify="space-between" align="flex-start">
<div style={{ border: '1px solid #cbd5e1', padding: 12, borderRadius: 8, background: '#fff' }}> <div>
<div>Baslik: {job.requestSnapshot?.title || '-'}</div> <h1 className="sw-page-title">Job #{job._id.slice(-8)}</h1>
<div>Tip: {job.requestSnapshot?.type || '-'}</div> <Text c="dimmed">{job.requestSnapshot?.path || 'manual trigger'}</Text>
<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> </div>
<Badge color={statusColor(job.status)} size="lg" variant="light">
{job.status}
</Badge>
</Group>
{job.status === 'NEEDS_REVIEW' && ( <Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
<div style={{ border: '1px solid #f59e0b', padding: 12, borderRadius: 8, background: '#fffbeb' }}> <SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
<h4>Manual Override</h4> <Detail label="Başlık" value={job.requestSnapshot?.title || '-'} />
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <Detail label="Tür" value={job.requestSnapshot?.type || '-'} />
<input placeholder="title" onChange={(e) => setOverride((x: any) => ({ ...x, title: e.target.value }))} /> <Detail label="Yıl" value={String(job.requestSnapshot?.year || '-')} />
<input placeholder="year" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, year: Number(e.target.value) }))} /> <Detail label="Release" value={job.requestSnapshot?.release || '-'} />
<input placeholder="release" onChange={(e) => setOverride((x: any) => ({ ...x, release: e.target.value }))} /> <Detail label="Season / Episode" value={`${job.requestSnapshot?.season ?? '-'} / ${job.requestSnapshot?.episode ?? '-'}`} />
<input placeholder="season" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, season: Number(e.target.value) }))} /> <Detail label="Media" value={media?.path || '-'} />
<input placeholder="episode" type="number" onChange={(e) => setOverride((x: any) => ({ ...x, episode: Number(e.target.value) }))} /> <Detail
<button onClick={manualSearch}>Search</button> 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> </div>
<ul> <Button size="xs" variant="light" leftSection={<IconCheck size={14} />} onClick={() => choose(candidate)}>
{candidates.map((c) => ( Seç
<li key={c.id}> </Button>
{c.provider} | {c.id} | score={c.score} </Group>
<button onClick={() => choose(c)}>Sec</button>
</li>
))} ))}
</ul> </Stack>
</div>
)} )}
</Stack>
</Card>
) : null}
<div style={{ border: '1px solid #cbd5e1', borderRadius: 8, background: '#fff', padding: 12 }}> <Card withBorder radius="lg" p="lg" style={{ background: 'rgba(255,255,255,0.9)', borderColor: 'rgba(16,32,50,0.12)' }}>
<h4>Canli Loglar</h4> <Stack gap="sm">
<div style={{ maxHeight: 320, overflow: 'auto', fontSize: 12 }}> <Group gap="xs">
{logs.map((l) => ( <IconTerminal2 size={18} />
<div key={l._id + l.ts} style={{ borderBottom: '1px dashed #e2e8f0', padding: '4px 0' }}> <Text fw={600} className="sw-page-title">
[{new Date(l.ts).toLocaleTimeString()}] {l.step} - {l.message} Canlı Log Akışı
{l.meta ? ` | meta=${JSON.stringify(l.meta)}` : ''} </Text>
</div> </Group>
))}
</div> <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> </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> </div>
); );
} }

View File

@@ -1,18 +1,21 @@
import { useCallback, useState } from 'react'; 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 { api } from '../api/client';
import { Job } from '../types'; import { Job } from '../types';
import { usePoll } from '../hooks/usePoll'; import { usePoll } from '../hooks/usePoll';
import { JobTable } from '../components/JobTable'; import { JobTable } from '../components/JobTable';
import { JOB_STATUSES } from '../lib/status';
export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void }) { export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void }) {
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [status, setStatus] = useState(''); const [status, setStatus] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const load = useCallback(async () => { const load = useCallback(async () => {
const q = new URLSearchParams({ limit: '100' }); const q = new URLSearchParams({ limit: '100' });
if (status) q.set('status', status); 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()}`); const data = await api<{ items: Job[] }>(`/api/jobs?${q.toString()}`);
setJobs(data.items); setJobs(data.items);
}, [status, search]); }, [status, search]);
@@ -20,13 +23,39 @@ export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void })
usePoll(load, 4000); usePoll(load, 4000);
return ( return (
<Stack gap="lg">
<div> <div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}> <h1 className="sw-page-title">Jobs Explorer</h1>
<input placeholder="status" value={status} onChange={(e) => setStatus(e.target.value)} /> <Text c="dimmed">Durum ve metin filtresiyle işleri detaylı inceleyin.</Text>
<input placeholder="title/path" value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={() => load()}>Filtrele</button>
</div> </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} /> <JobTable jobs={jobs} onSelect={onSelectJob} />
</div> </Stack>
); );
} }

View File

@@ -1,4 +1,6 @@
import { useCallback, useState } from 'react'; 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 { api } from '../api/client';
import { Job } from '../types'; import { Job } from '../types';
import { usePoll } from '../hooks/usePoll'; import { usePoll } from '../hooks/usePoll';
@@ -8,22 +10,46 @@ export function ReviewPage({ onSelectJob }: { onSelectJob: (id: string) => void
const load = useCallback(async () => { const load = useCallback(async () => {
const data = await api<Job[]>('/api/review'); const data = await api<Job[]>('/api/review');
setJobs(data); setJobs(data || []);
}, []); }, []);
usePoll(load, 5000); usePoll(load, 5000);
return ( return (
<Stack gap="lg">
<Group justify="space-between">
<div> <div>
<h3>Needs Review</h3> <h1 className="sw-page-title">Manual Review Queue</h1>
<ul> <Text c="dimmed">Belirsiz veya bulunamayan işler burada manuel karar bekler.</Text>
{jobs.map((j) => (
<li key={j._id}>
{j.requestSnapshot?.title || '-'} ({j.status})
<button onClick={() => onSelectJob(j._id)}>Ac</button>
</li>
))}
</ul>
</div> </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 { 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 { api } from '../api/client';
import { Settings } from '../types';
export function SettingsPage() { export function SettingsPage() {
const [settings, setSettings] = useState<any>(null); const [settings, setSettings] = useState<Settings | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
api<any>('/api/settings').then(setSettings); api<Settings>('/api/settings').then(setSettings);
}, []); }, []);
async function save() { async function save() {
if (!settings) return;
setSaving(true);
try {
await api('/api/settings', { method: 'POST', body: JSON.stringify(settings) }); 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 ( return (
<div style={{ display: 'grid', gap: 8, maxWidth: 760 }}> <Stack gap="lg">
<label>Languages (comma) <div>
<input value={settings.languages.join(',')} onChange={(e) => setSettings({ ...settings, languages: e.target.value.split(',').map((x) => x.trim()).filter(Boolean) })} /> <h1 className="sw-page-title">Settings</h1>
</label> <Text c="dimmed">Arama, stabilite ve yazma davranışlarını buradan yönetin.</Text>
<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> </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 { 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 { api } from '../api/client';
import { usePoll } from '../hooks/usePoll'; import { usePoll } from '../hooks/usePoll';
import { WatchedPath } from '../types';
export function WatchedPathsPage() { export function WatchedPathsPage() {
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<WatchedPath[]>([]);
const [path, setPath] = useState(''); const [path, setPath] = useState('');
const [kind, setKind] = useState('mixed'); const [kind, setKind] = useState<string>('mixed');
const load = useCallback(async () => { const load = useCallback(async () => {
const data = await api<any[]>('/api/watched-paths'); const data = await api<WatchedPath[]>('/api/watched-paths');
setItems(data); setItems(data || []);
}, []); }, []);
usePoll(load, 5000); usePoll(load, 5000);
async function add() { async function add() {
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'add', path, kind }) }); if (!path.trim()) return;
try {
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'add', path: path.trim(), kind }) });
setPath(''); setPath('');
notifications.show({ color: 'teal', title: 'Eklendi', message: 'Watched path başarıyla eklendi.' });
await load(); await load();
} catch (error) {
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Path eklenemedi.' });
}
} }
async function toggle(item: any) { async function toggle(item: WatchedPath) {
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'toggle', path: item.path, enabled: !item.enabled }) }); 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(); await load();
} catch (error) {
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Durum güncellenemedi.' });
}
} }
async function remove(item: any) { 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 }) }); 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(); await load();
} catch (error) {
notifications.show({ color: 'red', title: 'Hata', message: (error as Error).message || 'Path silinemedi.' });
}
}
});
} }
return ( return (
<Stack gap="lg">
<div> <div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}> <h1 className="sw-page-title">Watched Paths</h1>
<input placeholder="/media/custom" value={path} onChange={(e) => setPath(e.target.value)} /> <Text c="dimmed">Core watcherın izleyeceği dizinleri buradan yönetin.</Text>
<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>
</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> </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 { export interface Job {
_id: string; _id: string;
status: string; status: string;
requestSnapshot?: any; requestSnapshot?: JobRequestSnapshot;
apiSnapshot?: any; apiSnapshot?: {
result?: any; candidates?: JobCandidate[];
mediaFileId?: any; };
result?: {
subtitles?: Array<{ writtenPath: string }>;
};
mediaFileId?: {
path?: string;
mediaInfo?: {
video?: {
codec_name?: string;
width?: number;
height?: number;
};
};
};
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -16,5 +46,22 @@ export interface JobLog {
message: string; message: string;
level: 'info' | 'warn' | 'error'; level: 'info' | 'warn' | 'error';
ts: string; 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;
} }