feat: altyazı otomasyon sistemi MVP'sini ekle

Docker tabanlı mikro servis mimarisi ile altyazı otomasyon sistemi altyapısı kuruldu.

- Core (Node.js): Chokidar dosya izleyici, BullMQ iş kuyrukları, ffprobe medya analizi, MongoDB entegrasyonu ve dosya yazma işlemleri.
- API (Fastify): Mock sağlayıcılar, arşiv güvenliği (zip-slip), altyazı doğrulama, puanlama ve aday seçim motoru.
- UI (React/Vite): İş yönetimi paneli, canlı SSE log akışı, manuel inceleme arayüzü ve sistem ayarları.
- Altyapı: Docker Compose (dev/prod), Redis, Mongo ve çevresel değişken yapılandırmaları.
This commit is contained in:
2026-02-15 23:12:24 +03:00
commit f1a1f093e6
72 changed files with 2882 additions and 0 deletions

34
services/ui/src/App.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { useState } from 'react';
import { Layout, Tab } from './components/Layout';
import { DashboardPage } from './pages/DashboardPage';
import { JobsPage } from './pages/JobsPage';
import { JobDetailPage } from './pages/JobDetailPage';
import { ReviewPage } from './pages/ReviewPage';
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);
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>
);
}

View File

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

View File

@@ -0,0 +1,26 @@
import { Job } from '../types';
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>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
const tabs = ['dashboard', 'jobs', 'review', 'settings', 'paths'] as const;
export type Tab = (typeof tabs)[number];
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>
);
}

View File

@@ -0,0 +1,16 @@
import { useEffect } from 'react';
export function usePoll(fn: () => void | Promise<void>, ms: number) {
useEffect(() => {
let active = true;
const tick = async () => {
if (!active) return;
await fn();
setTimeout(tick, ms);
};
tick();
return () => {
active = false;
};
}, [fn, ms]);
}

9
services/ui/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,44 @@
import { useCallback, useState } from 'react';
import { api } from '../api/client';
import { Job } from '../types';
import { usePoll } from '../hooks/usePoll';
import { JobTable } from '../components/JobTable';
export function DashboardPage({ onSelectJob }: { onSelectJob: (id: string) => void }) {
const [jobs, setJobs] = useState<Job[]>([]);
const load = useCallback(async () => {
const data = await api<{ items: Job[] }>('/api/jobs?limit=20');
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;
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>
);
}
function Stat({ label, value }: { label: string; value: string }) {
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>
);
}

View File

@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { Job, JobLog } from '../types';
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[]>([]);
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);
es = new EventSource(`/api/jobs/${jobId}/stream`);
es.onmessage = (ev) => {
const item = JSON.parse(ev.data);
setLogs((prev) => [...prev, item]);
};
})();
return () => {
if (es) es.close();
};
}, [jobId]);
async function manualSearch() {
const res = await api<any>(`/api/review/${jobId}/search`, { method: 'POST', body: JSON.stringify(override) });
setCandidates(res.candidates || []);
}
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);
}
if (!job) return <div>Yukleniyor...</div>;
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>
</div>
)}
<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>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { useCallback, useState } from 'react';
import { api } from '../api/client';
import { Job } from '../types';
import { usePoll } from '../hooks/usePoll';
import { JobTable } from '../components/JobTable';
export function JobsPage({ onSelectJob }: { onSelectJob: (id: string) => void }) {
const [jobs, setJobs] = useState<Job[]>([]);
const [status, setStatus] = useState('');
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);
const data = await api<{ items: Job[] }>(`/api/jobs?${q.toString()}`);
setJobs(data.items);
}, [status, search]);
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>
</div>
<JobTable jobs={jobs} onSelect={onSelectJob} />
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { useCallback, useState } from 'react';
import { api } from '../api/client';
import { Job } from '../types';
import { usePoll } from '../hooks/usePoll';
export function ReviewPage({ onSelectJob }: { onSelectJob: (id: string) => void }) {
const [jobs, setJobs] = useState<Job[]>([]);
const load = useCallback(async () => {
const data = await api<Job[]>('/api/review');
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>
);
}

View File

@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
export function SettingsPage() {
const [settings, setSettings] = useState<any>(null);
useEffect(() => {
api<any>('/api/settings').then(setSettings);
}, []);
async function save() {
await api('/api/settings', { method: 'POST', body: JSON.stringify(settings) });
}
if (!settings) return <div>Yukleniyor...</div>;
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>
);
}

View File

@@ -0,0 +1,60 @@
import { useCallback, useState } from 'react';
import { api } from '../api/client';
import { usePoll } from '../hooks/usePoll';
export function WatchedPathsPage() {
const [items, setItems] = useState<any[]>([]);
const [path, setPath] = useState('');
const [kind, setKind] = useState('mixed');
const load = useCallback(async () => {
const data = await api<any[]>('/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();
}
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 remove(item: any) {
await api('/api/watched-paths', { method: 'POST', body: JSON.stringify({ action: 'remove', path: item.path }) });
await load();
}
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>
</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>
);
}

20
services/ui/src/types.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface Job {
_id: string;
status: string;
requestSnapshot?: any;
apiSnapshot?: any;
result?: any;
mediaFileId?: any;
createdAt: string;
updatedAt: string;
}
export interface JobLog {
_id: string;
jobId: string;
step: string;
message: string;
level: 'info' | 'warn' | 'error';
ts: string;
meta?: any;
}