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

24
services/api/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:20-bookworm AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends p7zip-full unrar-free && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
FROM base AS dev
RUN npm install
COPY . .
EXPOSE 3002
CMD ["npm", "run", "dev"]
FROM base AS build
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-bookworm AS prod
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends p7zip-full unrar-free && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
EXPOSE 3002
CMD ["npm", "run", "start"]

28
services/api/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "subwatcher-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@fastify/cors": "^11.0.0",
"adm-zip": "^0.5.16",
"dotenv": "^16.4.7",
"fastify": "^5.2.1",
"fs-extra": "^11.3.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.13.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

20
services/api/src/app.ts Normal file
View 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;
}

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { chooseBest, scoreCandidateFile } from '../src/lib/scoring.js';
const candidate: any = {
id: 'c1',
provider: 'opensubtitles',
displayName: 'x',
downloadType: 'archiveZip',
downloadUrl: 'mock://x',
lang: 'tr',
releaseHints: ['1080p', 'x265', 'flux'],
scoreHints: [],
isHI: false,
isForced: false
};
describe('scoring', () => {
it('scores tv season/episode match strongly', () => {
const s = scoreCandidateFile('/tmp/show.S01E02.1080p.srt', 'srt', candidate, {
type: 'tv',
title: 'show',
season: 1,
episode: 2,
release: 'FLUX',
languages: ['tr']
});
expect(s?.score).toBeGreaterThan(100);
});
it('disqualifies wrong episode for tv', () => {
const s = scoreCandidateFile('/tmp/show.S01E03.srt', 'srt', candidate, {
type: 'tv',
title: 'show',
season: 1,
episode: 2,
languages: ['tr']
});
expect(s).toBeNull();
});
it('returns ambiguous when top scores are close', () => {
const d = chooseBest([
{ id: 'a', candidateId: 'a', provider: 'x', filePath: '/a', ext: 'srt', lang: 'tr', score: 90, reasons: [] },
{ id: 'b', candidateId: 'b', provider: 'x', filePath: '/b', ext: 'srt', lang: 'tr', score: 88, reasons: [] }
] as any);
expect(d.status).toBe('AMBIGUOUS');
});
});

View File

@@ -0,0 +1,14 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { ensureInsideRoot } from '../src/lib/security.js';
describe('zip slip helper', () => {
it('accepts path inside root', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'sw-root-'));
const file = path.join(root, 'a.txt');
await fs.writeFile(file, 'x');
expect(await ensureInsideRoot(root, file)).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { detectSubtitleType, isProbablyText, validateAss, validateSrt } from '../src/lib/validators.js';
describe('subtitle validators', () => {
it('validates srt content', () => {
const srt = `1\n00:00:01,000 --> 00:00:02,000\na\n\n2\n00:00:03,000 --> 00:00:04,000\nb\n\n3\n00:00:05,000 --> 00:00:06,000\nc\n`;
expect(validateSrt(srt)).toBe(true);
expect(detectSubtitleType(srt)).toBe('srt');
});
it('validates ass content', () => {
const ass = `[Script Info]\n[Events]\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,x`;
expect(validateAss(ass)).toBe(true);
expect(detectSubtitleType(ass)).toBe('ass');
});
it('rejects binary for text detector', () => {
const b = Buffer.from([0, 255, 3, 0, 9]);
expect(isProbablyText(b)).toBe(false);
});
});

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*"]
}