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:
20
services/api/src/app.ts
Normal file
20
services/api/src/app.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { env } from './config/env.js';
|
||||
import { subtitleRoutes } from './routes/subtitles.js';
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({ logger: true });
|
||||
await app.register(cors, { origin: true });
|
||||
|
||||
if (env.enableApiKey && env.apiKey) {
|
||||
app.addHook('preHandler', async (req, reply) => {
|
||||
if (req.headers['x-api-key'] !== env.apiKey) {
|
||||
return reply.status(401).send({ error: 'invalid api key' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await subtitleRoutes(app);
|
||||
return app;
|
||||
}
|
||||
11
services/api/src/config/env.ts
Normal file
11
services/api/src/config/env.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const env = {
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
port: Number(process.env.API_PORT ?? 3002),
|
||||
tempRoot: process.env.TEMP_ROOT ?? '/temp',
|
||||
enableApiKey: process.env.ENABLE_API_KEY === 'true',
|
||||
apiKey: process.env.API_KEY ?? ''
|
||||
};
|
||||
26
services/api/src/index.ts
Normal file
26
services/api/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { buildApp } from './app.js';
|
||||
import { cleanupOldTemp } from './lib/subtitleEngine.js';
|
||||
import { env } from './config/env.js';
|
||||
|
||||
async function bootstrap() {
|
||||
await fs.mkdir(env.tempRoot, { recursive: true });
|
||||
const app = await buildApp();
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const deleted = await cleanupOldTemp(24);
|
||||
if (deleted > 0) console.log(`[api] cleanup removed ${deleted} temp folders`);
|
||||
} catch (e) {
|
||||
console.error('[api] cleanup failed', e);
|
||||
}
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
await app.listen({ port: env.port, host: '0.0.0.0' });
|
||||
console.log(`[api] running on :${env.port}`);
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
18
services/api/src/lib/deterministic.ts
Normal file
18
services/api/src/lib/deterministic.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function hashString(input: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h ^= input.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function seeded(seed: number): () => number {
|
||||
let t = seed;
|
||||
return () => {
|
||||
t += 0x6d2b79f5;
|
||||
let x = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
|
||||
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
51
services/api/src/lib/mockArtifact.ts
Normal file
51
services/api/src/lib/mockArtifact.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import AdmZip from 'adm-zip';
|
||||
import type { Candidate, SearchParams } from '../types/index.js';
|
||||
import { hashString, seeded } from './deterministic.js';
|
||||
|
||||
function buildSrt(title: string, season?: number, episode?: number): string {
|
||||
const ep = season && episode ? ` S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` : '';
|
||||
return `1\n00:00:01,000 --> 00:00:04,000\n${title}${ep} satir 1\n\n2\n00:00:05,000 --> 00:00:08,000\n${title}${ep} satir 2\n\n3\n00:00:09,000 --> 00:00:12,000\n${title}${ep} satir 3\n`;
|
||||
}
|
||||
|
||||
function buildAss(title: string): string {
|
||||
return `[Script Info]\nTitle: ${title}\n[Events]\nDialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Ass satiri\n`;
|
||||
}
|
||||
|
||||
export async function generateMockArtifact(candidate: Candidate, params: SearchParams, jobToken: string, downloadDir: string): Promise<{ type: 'archive' | 'direct'; filePath: string }> {
|
||||
await fs.mkdir(downloadDir, { recursive: true });
|
||||
const seed = hashString(`${jobToken}|${params.title}|${candidate.id}`);
|
||||
const rnd = seeded(seed);
|
||||
|
||||
if (candidate.downloadType === 'direct') {
|
||||
const filePath = path.join(downloadDir, `${candidate.id}.srt`);
|
||||
await fs.writeFile(filePath, buildSrt(params.title, params.season, params.episode), 'utf8');
|
||||
return { type: 'direct', filePath };
|
||||
}
|
||||
|
||||
const zip = new AdmZip();
|
||||
if (params.type === 'tv') {
|
||||
const s = params.season ?? 1;
|
||||
const e = params.episode ?? 1;
|
||||
const base = params.title.replace(/\s+/g, '.');
|
||||
zip.addFile(`${base}.S${String(s).padStart(2, '0')}E${String(e).padStart(2, '0')}.1080p.srt`, Buffer.from(buildSrt(params.title, s, e)));
|
||||
zip.addFile(`${base}.S${String(s).padStart(2, '0')}E${String(e + 1).padStart(2, '0')}.srt`, Buffer.from(buildSrt(params.title, s, e + 1)));
|
||||
zip.addFile(`${base}.S${String(s).padStart(2, '0')}E${String(Math.max(1, e - 1)).padStart(2, '0')}.srt`, Buffer.from(buildSrt(params.title, s, Math.max(1, e - 1))));
|
||||
if (rnd() > 0.5) {
|
||||
zip.addFile(`${base}.S${String(s).padStart(2, '0')}E${String(e).padStart(2, '0')}.ass`, Buffer.from(buildAss(params.title)));
|
||||
}
|
||||
} else {
|
||||
const name = `${params.title.replace(/\s+/g, '.')}.${params.year ?? 2020}`;
|
||||
zip.addFile(`${name}.tr.srt`, Buffer.from(buildSrt(params.title)));
|
||||
if (rnd() > 0.3) {
|
||||
zip.addFile(`${name}.txt`, Buffer.from('this is not subtitle'));
|
||||
}
|
||||
}
|
||||
|
||||
zip.addFile('invalid.bin', Buffer.from([0, 159, 255, 0, 18]));
|
||||
|
||||
const archivePath = path.join(downloadDir, `${candidate.id}.zip`);
|
||||
zip.writeZip(archivePath);
|
||||
return { type: 'archive', filePath: archivePath };
|
||||
}
|
||||
89
services/api/src/lib/scoring.ts
Normal file
89
services/api/src/lib/scoring.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import path from 'node:path';
|
||||
import type { Candidate, SearchParams } from '../types/index.js';
|
||||
|
||||
export interface ScoredSubtitle {
|
||||
id: string;
|
||||
candidateId: string;
|
||||
provider: string;
|
||||
filePath: string;
|
||||
ext: 'srt' | 'ass';
|
||||
lang: string;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
function tokenize(s?: string): string[] {
|
||||
if (!s) return [];
|
||||
return s
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function scoreCandidateFile(filePath: string, ext: 'srt' | 'ass', candidate: Candidate, params: SearchParams): ScoredSubtitle | null {
|
||||
const fn = path.basename(filePath).toLowerCase();
|
||||
let score = 0;
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (params.type === 'tv') {
|
||||
const sePattern = /s(\d{1,2})e(\d{1,2})/i;
|
||||
const match = fn.match(sePattern);
|
||||
if (!match || Number(match[1]) !== params.season || Number(match[2]) !== params.episode) {
|
||||
return null;
|
||||
}
|
||||
score += 100;
|
||||
reasons.push('season_episode_match');
|
||||
}
|
||||
|
||||
const releaseTokens = tokenize(params.release);
|
||||
const fileTokens = tokenize(fn).concat(candidate.releaseHints.map((x) => x.toLowerCase()));
|
||||
const releaseMatches = releaseTokens.filter((t) => fileTokens.includes(t)).length;
|
||||
score += Math.min(25, releaseMatches * 6);
|
||||
if (releaseMatches > 0) reasons.push('release_match');
|
||||
|
||||
if (candidate.lang === (params.languages[0] || 'tr')) {
|
||||
score += 10;
|
||||
reasons.push('lang_match');
|
||||
}
|
||||
|
||||
const height = params.mediaInfo?.video?.height;
|
||||
if (height && (fn.includes(String(height)) || candidate.releaseHints.includes(`${height}p`))) {
|
||||
score += 8;
|
||||
reasons.push('resolution_match');
|
||||
}
|
||||
|
||||
if (params.preferHI && candidate.isHI) {
|
||||
score += 4;
|
||||
reasons.push('prefer_hi');
|
||||
}
|
||||
|
||||
if (params.preferForced && candidate.isForced) {
|
||||
score += 4;
|
||||
reasons.push('prefer_forced');
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${candidate.id}:${path.basename(filePath)}`,
|
||||
candidateId: candidate.id,
|
||||
provider: candidate.provider,
|
||||
filePath,
|
||||
ext,
|
||||
lang: candidate.lang,
|
||||
score,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
export function chooseBest(scored: ScoredSubtitle[]): { status: 'FOUND' | 'AMBIGUOUS' | 'NOT_FOUND'; best?: ScoredSubtitle; confidence?: number; candidates: ScoredSubtitle[] } {
|
||||
if (scored.length === 0) return { status: 'NOT_FOUND', candidates: [] };
|
||||
const sorted = [...scored].sort((a, b) => b.score - a.score);
|
||||
const best = sorted[0];
|
||||
const second = sorted[1];
|
||||
|
||||
if (second && Math.abs(best.score - second.score) <= 3) {
|
||||
return { status: 'AMBIGUOUS', candidates: sorted.slice(0, 10) };
|
||||
}
|
||||
|
||||
const confidence = Math.max(0.5, Math.min(0.99, best.score / 130));
|
||||
return { status: 'FOUND', best, confidence, candidates: sorted.slice(0, 10) };
|
||||
}
|
||||
30
services/api/src/lib/security.ts
Normal file
30
services/api/src/lib/security.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function ensureInsideRoot(root: string, candidatePath: string): Promise<boolean> {
|
||||
const [rootReal, candReal] = await Promise.all([fs.realpath(root), fs.realpath(candidatePath)]);
|
||||
return candReal === rootReal || candReal.startsWith(rootReal + path.sep);
|
||||
}
|
||||
|
||||
export async function collectFilesRecursive(dir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const e of entries) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) files.push(...(await collectFilesRecursive(p)));
|
||||
else if (e.isFile()) files.push(p);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function validateExtractionLimits(files: string[], limits: { maxFiles: number; maxTotalBytes: number; maxSingleBytes: number }): Promise<{ ok: boolean; reason?: string; totalBytes: number }> {
|
||||
if (files.length > limits.maxFiles) return { ok: false, reason: 'max files exceeded', totalBytes: 0 };
|
||||
let total = 0;
|
||||
for (const file of files) {
|
||||
const st = await fs.stat(file);
|
||||
if (st.size > limits.maxSingleBytes) return { ok: false, reason: `single file exceeded: ${file}`, totalBytes: total };
|
||||
total += st.size;
|
||||
if (total > limits.maxTotalBytes) return { ok: false, reason: 'total bytes exceeded', totalBytes: total };
|
||||
}
|
||||
return { ok: true, totalBytes: total };
|
||||
}
|
||||
200
services/api/src/lib/subtitleEngine.ts
Normal file
200
services/api/src/lib/subtitleEngine.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import fse from 'fs-extra';
|
||||
import { env } from '../config/env.js';
|
||||
import type { SearchParams, TraceLog } from '../types/index.js';
|
||||
import { SubtitleProvider, Candidate } from '../types/index.js';
|
||||
import { TurkceAltyaziProvider } from '../providers/TurkceAltyaziProvider.js';
|
||||
import { OpenSubtitlesProvider } from '../providers/OpenSubtitlesProvider.js';
|
||||
import { collectFilesRecursive, ensureInsideRoot, validateExtractionLimits } from './security.js';
|
||||
import { detectSubtitleType, isProbablyText } from './validators.js';
|
||||
import { chooseBest, scoreCandidateFile } from './scoring.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const providers: SubtitleProvider[] = [new TurkceAltyaziProvider(), new OpenSubtitlesProvider()];
|
||||
|
||||
function defaultLimits() {
|
||||
return { maxFiles: 300, maxTotalBytes: 250 * 1024 * 1024, maxSingleBytes: 10 * 1024 * 1024 };
|
||||
}
|
||||
|
||||
async function ensureJobDirs(jobToken: string) {
|
||||
const base = path.join(env.tempRoot, jobToken);
|
||||
const download = path.join(base, 'download');
|
||||
const extracted = path.join(base, 'extracted');
|
||||
await fs.mkdir(download, { recursive: true });
|
||||
await fs.mkdir(extracted, { recursive: true });
|
||||
return { base, download, extracted };
|
||||
}
|
||||
|
||||
async function extractArchive(archivePath: string, extractedDir: string, trace: TraceLog[]): Promise<string[]> {
|
||||
trace.push({ level: 'info', step: 'EXTRACT_STARTED', message: archivePath });
|
||||
await execFileAsync('7z', ['x', '-y', archivePath, `-o${extractedDir}`]);
|
||||
const files = await collectFilesRecursive(extractedDir);
|
||||
trace.push({ level: 'info', step: 'EXTRACT_DONE', message: `Extracted ${files.length} files` });
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function searchSubtitles(input: SearchParams) {
|
||||
const jobToken = input.jobToken ?? `job-${Date.now()}`;
|
||||
const trace: TraceLog[] = [];
|
||||
const limits = input.securityLimits ?? defaultLimits();
|
||||
const dirs = await ensureJobDirs(jobToken);
|
||||
|
||||
const allCandidates: Candidate[] = [];
|
||||
for (const p of providers) {
|
||||
const c = await p.search(input);
|
||||
allCandidates.push(...c);
|
||||
}
|
||||
|
||||
const scored: any[] = [];
|
||||
|
||||
for (const candidate of allCandidates) {
|
||||
const provider = providers.find((p: any) => p.constructor.name.toLowerCase().includes(candidate.provider === 'turkcealtyazi' ? 'turkce' : 'open'));
|
||||
if (!provider) continue;
|
||||
|
||||
const dl = await provider.download(candidate, input, jobToken);
|
||||
trace.push({ level: 'info', step: 'ARCHIVE_DOWNLOADED', message: `${candidate.provider}:${candidate.id}`, meta: { path: dl.filePath, type: dl.type } });
|
||||
|
||||
let files: string[] = [];
|
||||
if (dl.type === 'archive') {
|
||||
const perCandidateExtractDir = path.join(dirs.extracted, candidate.id);
|
||||
await fs.mkdir(perCandidateExtractDir, { recursive: true });
|
||||
files = await extractArchive(dl.filePath, perCandidateExtractDir, trace);
|
||||
|
||||
for (const file of files) {
|
||||
const inside = await ensureInsideRoot(perCandidateExtractDir, file);
|
||||
if (!inside) {
|
||||
trace.push({ level: 'warn', step: 'ZIPSLIP_REJECTED', message: `Rejected path traversal candidate: ${file}` });
|
||||
await fse.remove(file);
|
||||
}
|
||||
}
|
||||
|
||||
files = await collectFilesRecursive(perCandidateExtractDir);
|
||||
const lim = await validateExtractionLimits(files, limits);
|
||||
if (!lim.ok) {
|
||||
trace.push({ level: 'warn', step: 'LIMIT_REJECTED', message: lim.reason ?? 'limit rejected' });
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
files = [dl.filePath];
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const buf = await fs.readFile(file);
|
||||
if (!isProbablyText(buf)) {
|
||||
await fse.remove(file);
|
||||
trace.push({ level: 'warn', step: 'INVALID_SUBTITLE_DELETED', message: `Deleted binary/invalid: ${file}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = buf.toString('utf8');
|
||||
const ext = detectSubtitleType(text);
|
||||
if (!ext) {
|
||||
await fse.remove(file);
|
||||
trace.push({ level: 'warn', step: 'INVALID_SUBTITLE_DELETED', message: `Deleted unknown subtitle content: ${file}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const s = scoreCandidateFile(file, ext, candidate, input);
|
||||
if (s) scored.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
trace.push({ level: 'info', step: 'CANDIDATES_SCANNED', message: `Scored ${scored.length} subtitle files` });
|
||||
|
||||
const decision = chooseBest(scored);
|
||||
const manifestPath = path.join(dirs.base, 'manifest.json');
|
||||
await fs.writeFile(manifestPath, JSON.stringify({ jobToken, input, scored: decision.candidates }, null, 2), 'utf8');
|
||||
|
||||
if (decision.status === 'FOUND' && decision.best) {
|
||||
const bestPath = path.join(dirs.base, `best.${decision.best.ext}`);
|
||||
await fs.copyFile(decision.best.filePath, bestPath);
|
||||
trace.push({ level: 'info', step: 'BEST_SELECTED', message: `Selected ${decision.best.filePath}`, meta: { score: decision.best.score } });
|
||||
|
||||
return {
|
||||
status: 'FOUND',
|
||||
jobToken,
|
||||
bestPath,
|
||||
confidence: decision.confidence,
|
||||
source: decision.best.provider,
|
||||
candidates: decision.candidates,
|
||||
trace
|
||||
};
|
||||
}
|
||||
|
||||
if (decision.status === 'AMBIGUOUS') {
|
||||
trace.push({ level: 'warn', step: 'AMBIGUOUS_NEEDS_REVIEW', message: 'Top candidates too close' });
|
||||
return {
|
||||
status: 'AMBIGUOUS',
|
||||
jobToken,
|
||||
confidence: 0.5,
|
||||
source: 'multi',
|
||||
candidates: decision.candidates,
|
||||
trace
|
||||
};
|
||||
}
|
||||
|
||||
trace.push({ level: 'warn', step: 'NOT_FOUND_NEEDS_REVIEW', message: 'No valid subtitle file found' });
|
||||
return {
|
||||
status: 'NOT_FOUND',
|
||||
jobToken,
|
||||
confidence: 0,
|
||||
source: 'none',
|
||||
candidates: [],
|
||||
trace
|
||||
};
|
||||
}
|
||||
|
||||
export async function chooseSubtitle(jobToken: string, chosenCandidateId?: string, chosenPath?: string) {
|
||||
const base = path.join(env.tempRoot, jobToken);
|
||||
const manifestPath = path.join(base, 'manifest.json');
|
||||
const raw = await fs.readFile(manifestPath, 'utf8');
|
||||
const manifest = JSON.parse(raw);
|
||||
const list = manifest.scored ?? [];
|
||||
|
||||
const found = chosenPath
|
||||
? list.find((x: any) => x.filePath === chosenPath || x.id === chosenPath)
|
||||
: list.find((x: any) => x.id === chosenCandidateId || x.candidateId === chosenCandidateId);
|
||||
|
||||
if (!found) {
|
||||
return { status: 'NOT_FOUND', message: 'Chosen candidate not found' };
|
||||
}
|
||||
|
||||
const bestPath = path.join(base, `best.${found.ext}`);
|
||||
await fs.copyFile(found.filePath, bestPath);
|
||||
|
||||
return {
|
||||
status: 'FOUND',
|
||||
bestPath,
|
||||
confidence: Math.max(0.5, Math.min(0.98, found.score / 130)),
|
||||
source: found.provider
|
||||
};
|
||||
}
|
||||
|
||||
export async function cleanupJobToken(jobToken: string) {
|
||||
const dir = path.join(env.tempRoot, jobToken);
|
||||
await fse.remove(dir);
|
||||
}
|
||||
|
||||
export async function cleanupOldTemp(hours = 24): Promise<number> {
|
||||
await fs.mkdir(env.tempRoot, { recursive: true });
|
||||
const entries = await fs.readdir(env.tempRoot, { withFileTypes: true });
|
||||
const now = Date.now();
|
||||
let count = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const p = path.join(env.tempRoot, e.name);
|
||||
const st = await fs.stat(p);
|
||||
const ageHours = (now - st.mtimeMs) / 1000 / 3600;
|
||||
if (ageHours > hours) {
|
||||
await fse.remove(p);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
25
services/api/src/lib/validators.ts
Normal file
25
services/api/src/lib/validators.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function isProbablyText(buffer: Buffer): boolean {
|
||||
if (buffer.includes(0x00)) return false;
|
||||
let nonPrintable = 0;
|
||||
for (const b of buffer) {
|
||||
const printable = b === 9 || b === 10 || b === 13 || (b >= 32 && b <= 126) || b >= 160;
|
||||
if (!printable) nonPrintable += 1;
|
||||
}
|
||||
return buffer.length === 0 ? false : nonPrintable / buffer.length < 0.2;
|
||||
}
|
||||
|
||||
export function validateSrt(text: string): boolean {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const tc = lines.filter((l) => /^\d{2}:\d{2}:\d{2},\d{3}\s-->\s\d{2}:\d{2}:\d{2},\d{3}$/.test(l.trim()));
|
||||
return tc.length >= 3;
|
||||
}
|
||||
|
||||
export function validateAss(text: string): boolean {
|
||||
return text.includes('[Script Info]') && text.includes('[Events]') && /Dialogue:/.test(text);
|
||||
}
|
||||
|
||||
export function detectSubtitleType(text: string): 'srt' | 'ass' | null {
|
||||
if (validateSrt(text)) return 'srt';
|
||||
if (validateAss(text)) return 'ass';
|
||||
return null;
|
||||
}
|
||||
45
services/api/src/providers/OpenSubtitlesProvider.ts
Normal file
45
services/api/src/providers/OpenSubtitlesProvider.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Candidate, SearchParams, SubtitleProvider } from '../types/index.js';
|
||||
import { generateMockArtifact } from '../lib/mockArtifact.js';
|
||||
import { hashString, seeded } from '../lib/deterministic.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export class OpenSubtitlesProvider implements SubtitleProvider {
|
||||
async search(params: SearchParams): Promise<Candidate[]> {
|
||||
// TODO(v2): real OpenSubtitles API integration.
|
||||
const key = `${params.title}|${params.year}|${params.season}|${params.episode}|os`;
|
||||
const rnd = seeded(hashString(key));
|
||||
const base = params.title.replace(/\s+/g, '.');
|
||||
const directForMovie = params.type === 'movie' && rnd() > 0.4;
|
||||
return [
|
||||
{
|
||||
id: `os-${hashString(`${key}-a`)}`,
|
||||
provider: 'opensubtitles',
|
||||
displayName: `OS ${base} Official`,
|
||||
downloadType: directForMovie ? 'direct' : 'archiveZip',
|
||||
downloadUrl: directForMovie ? `mock://os/${base}/direct.srt` : `mock://os/${base}/archive.zip`,
|
||||
lang: 'tr',
|
||||
releaseHints: ['1080p', rnd() > 0.5 ? 'x265' : 'x264', 'flux'],
|
||||
scoreHints: ['api_match'],
|
||||
isHI: rnd() > 0.8,
|
||||
isForced: rnd() > 0.92
|
||||
},
|
||||
{
|
||||
id: `os-${hashString(`${key}-b`)}`,
|
||||
provider: 'opensubtitles',
|
||||
displayName: `OS ${base} Backup`,
|
||||
downloadType: 'archiveZip',
|
||||
downloadUrl: `mock://os/${base}/backup.zip`,
|
||||
lang: 'tr',
|
||||
releaseHints: ['720p', 'x264'],
|
||||
scoreHints: ['backup'],
|
||||
isHI: false,
|
||||
isForced: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async download(candidate: Candidate, params: SearchParams, jobToken: string) {
|
||||
const artifact = await generateMockArtifact(candidate, params, jobToken, `${env.tempRoot}/${jobToken}/download`);
|
||||
return { type: artifact.type, filePath: artifact.filePath, candidateId: candidate.id };
|
||||
}
|
||||
}
|
||||
44
services/api/src/providers/TurkceAltyaziProvider.ts
Normal file
44
services/api/src/providers/TurkceAltyaziProvider.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Candidate, SearchParams, SubtitleProvider } from '../types/index.js';
|
||||
import { generateMockArtifact } from '../lib/mockArtifact.js';
|
||||
import { hashString, seeded } from '../lib/deterministic.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export class TurkceAltyaziProvider implements SubtitleProvider {
|
||||
async search(params: SearchParams): Promise<Candidate[]> {
|
||||
// TODO(v2): real TurkceAltyazi scraping implementation.
|
||||
const key = `${params.title}|${params.year}|${params.season}|${params.episode}|ta`;
|
||||
const rnd = seeded(hashString(key));
|
||||
const base = params.title.replace(/\s+/g, '.');
|
||||
return [
|
||||
{
|
||||
id: `ta-${hashString(`${key}-a`)}`,
|
||||
provider: 'turkcealtyazi',
|
||||
displayName: `TA ${base} Ana Surum`,
|
||||
downloadType: 'archiveZip',
|
||||
downloadUrl: `mock://ta/${base}/a.zip`,
|
||||
lang: 'tr',
|
||||
releaseHints: [rnd() > 0.4 ? '1080p' : '720p', 'x265', 'flux'],
|
||||
scoreHints: ['trusted', 'crowd'],
|
||||
isHI: rnd() > 0.7,
|
||||
isForced: false
|
||||
},
|
||||
{
|
||||
id: `ta-${hashString(`${key}-b`)}`,
|
||||
provider: 'turkcealtyazi',
|
||||
displayName: `TA ${base} Alternatif`,
|
||||
downloadType: 'archiveZip',
|
||||
downloadUrl: `mock://ta/${base}/b.zip`,
|
||||
lang: 'tr',
|
||||
releaseHints: ['webrip', 'x264'],
|
||||
scoreHints: ['alt'],
|
||||
isHI: false,
|
||||
isForced: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async download(candidate: Candidate, params: SearchParams, jobToken: string) {
|
||||
const artifact = await generateMockArtifact(candidate, params, jobToken, `${env.tempRoot}/${jobToken}/download`);
|
||||
return { type: artifact.type, filePath: artifact.filePath, candidateId: candidate.id };
|
||||
}
|
||||
}
|
||||
65
services/api/src/routes/subtitles.ts
Normal file
65
services/api/src/routes/subtitles.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { chooseSubtitle, cleanupJobToken, searchSubtitles } from '../lib/subtitleEngine.js';
|
||||
|
||||
const SearchSchema = z.object({
|
||||
jobToken: z.string().optional(),
|
||||
type: z.enum(['movie', 'tv']),
|
||||
title: z.string().min(1),
|
||||
year: z.number().optional(),
|
||||
release: z.string().optional(),
|
||||
languages: z.array(z.string()).min(1),
|
||||
season: z.number().optional(),
|
||||
episode: z.number().optional(),
|
||||
mediaInfo: z.any().optional(),
|
||||
preferHI: z.boolean().optional(),
|
||||
preferForced: z.boolean().optional(),
|
||||
securityLimits: z
|
||||
.object({
|
||||
maxFiles: z.number().min(1),
|
||||
maxTotalBytes: z.number().min(1024),
|
||||
maxSingleBytes: z.number().min(1024)
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
const ChooseSchema = z.object({
|
||||
jobToken: z.string().min(1),
|
||||
chosenCandidateId: z.string().optional(),
|
||||
chosenPath: z.string().optional()
|
||||
});
|
||||
|
||||
export async function subtitleRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/v1/health', async () => ({ ok: true, service: 'api' }));
|
||||
|
||||
app.post('/v1/subtitles/search', async (req, reply) => {
|
||||
const parsed = SearchSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
|
||||
|
||||
try {
|
||||
const result = await searchSubtitles(parsed.data);
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
return reply.status(500).send({ status: 'ERROR', message: err.message, trace: [{ level: 'error', step: 'JOB_ERROR', message: err.message }] });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/subtitles/choose', async (req, reply) => {
|
||||
const parsed = ChooseSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.status(400).send({ error: parsed.error.flatten() });
|
||||
|
||||
try {
|
||||
const result = await chooseSubtitle(parsed.data.jobToken, parsed.data.chosenCandidateId, parsed.data.chosenPath);
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
return reply.status(500).send({ status: 'ERROR', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/subtitles/cleanup', async (req, reply) => {
|
||||
const body = z.object({ jobToken: z.string().min(1) }).safeParse(req.body);
|
||||
if (!body.success) return reply.status(400).send({ error: body.error.flatten() });
|
||||
await cleanupJobToken(body.data.jobToken);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
49
services/api/src/types/index.ts
Normal file
49
services/api/src/types/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface SearchParams {
|
||||
jobToken?: string;
|
||||
type: 'movie' | 'tv';
|
||||
title: string;
|
||||
year?: number;
|
||||
release?: string;
|
||||
languages: string[];
|
||||
season?: number;
|
||||
episode?: number;
|
||||
mediaInfo?: any;
|
||||
preferHI?: boolean;
|
||||
preferForced?: boolean;
|
||||
securityLimits?: {
|
||||
maxFiles: number;
|
||||
maxTotalBytes: number;
|
||||
maxSingleBytes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Candidate {
|
||||
id: string;
|
||||
provider: 'turkcealtyazi' | 'opensubtitles';
|
||||
displayName: string;
|
||||
downloadType: 'archiveZip' | 'direct';
|
||||
downloadUrl: string;
|
||||
lang: string;
|
||||
releaseHints: string[];
|
||||
scoreHints: string[];
|
||||
isHI: boolean;
|
||||
isForced: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadedArtifact {
|
||||
type: 'archive' | 'direct';
|
||||
filePath: string;
|
||||
candidateId: string;
|
||||
}
|
||||
|
||||
export interface SubtitleProvider {
|
||||
search(params: SearchParams): Promise<Candidate[]>;
|
||||
download(candidate: Candidate, params: SearchParams, jobToken: string): Promise<DownloadedArtifact>;
|
||||
}
|
||||
|
||||
export interface TraceLog {
|
||||
level: 'info' | 'warn' | 'error';
|
||||
step: string;
|
||||
message: string;
|
||||
meta?: any;
|
||||
}
|
||||
Reference in New Issue
Block a user