feat(api): turkcealtyazi gerçek modunu stabil hale getir ve mock altyapısını kaldır
Mock fallback mantığını ve determinantik mock üretim kodlarını kaldırarak TurkceAltyazi sağlayıcısını tamamen gerçek moda geçirdi. İyileştirilmiş arama, indirme ve çerez yönetimi ile sağlam bir entegrasyon sağlandı. - MockArtifact ve deterministic modüllerini kaldır - TurkceAltyaziProvider'da mock fallback mantığını tamamen kaldır - HTTP çerez yönetimi, retry mantığı ve hata işleme iyileştirmeleri - ENABLE_TA_STEP_LOGS yapılandırması ile adım adım loglama - TURKCEALTYAZI_ALLOW_MOCK_FALLBACK ortam değişkenini kaldır - Dokümantasyonu gerçek mod reflektif olarak güncelle - OpenSubtitles sağlayıcını gerçek entegrasyon tamamlanana kadar pasif yap - Varsayılan kaynak etiketini 'mock' yerine 'unknown' olarak güncelle
This commit is contained in:
@@ -12,8 +12,8 @@ MEDIA_TV_PATH=/media/tv
|
|||||||
MEDIA_MOVIE_PATH=/media/movie
|
MEDIA_MOVIE_PATH=/media/movie
|
||||||
ENABLE_API_KEY=false
|
ENABLE_API_KEY=false
|
||||||
API_KEY=
|
API_KEY=
|
||||||
|
ENABLE_TA_STEP_LOGS=false
|
||||||
ENABLE_TURKCEALTYAZI_REAL=false
|
ENABLE_TURKCEALTYAZI_REAL=false
|
||||||
TURKCEALTYAZI_ALLOW_MOCK_FALLBACK=true
|
|
||||||
TURKCEALTYAZI_BASE_URL=https://turkcealtyazi.org
|
TURKCEALTYAZI_BASE_URL=https://turkcealtyazi.org
|
||||||
TURKCEALTYAZI_TIMEOUT_MS=12000
|
TURKCEALTYAZI_TIMEOUT_MS=12000
|
||||||
TURKCEALTYAZI_MIN_DELAY_MS=300
|
TURKCEALTYAZI_MIN_DELAY_MS=300
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -3,7 +3,7 @@
|
|||||||
Docker tabanli altyazi otomasyon sistemi.
|
Docker tabanli altyazi otomasyon sistemi.
|
||||||
|
|
||||||
- `core`: watcher + ffprobe + BullMQ + Mongo job/log API + review akisi
|
- `core`: watcher + ffprobe + BullMQ + Mongo job/log API + review akisi
|
||||||
- `api`: mock provider subtitle engine (TurkceAltyazi/OpenSubtitles stub) + archive extraction + security + scoring
|
- `api`: gercek provider subtitle engine + archive extraction + security + scoring
|
||||||
- `ui`: React/Vite panel (dashboard, jobs, detail live logs, review, settings, watched paths)
|
- `ui`: React/Vite panel (dashboard, jobs, detail live logs, review, settings, watched paths)
|
||||||
|
|
||||||
## Mimari
|
## Mimari
|
||||||
@@ -18,30 +18,18 @@ Docker tabanli altyazi otomasyon sistemi.
|
|||||||
- UI -> Core API: `http://localhost:3001/api` (CORS acik)
|
- UI -> Core API: `http://localhost:3001/api` (CORS acik)
|
||||||
- Temp alan: `/temp/{jobToken}`
|
- Temp alan: `/temp/{jobToken}`
|
||||||
|
|
||||||
## Mock Provider Notu
|
|
||||||
|
|
||||||
Gercek scraping/API cagrilari varsayilan olarak kapali gelir.
|
|
||||||
|
|
||||||
- `TurkceAltyaziProvider`: mock + TODO
|
|
||||||
- `OpenSubtitlesProvider`: mock + TODO
|
|
||||||
|
|
||||||
Deterministik candidate uretimi vardir (aynı input = ayni aday davranisi).
|
|
||||||
|
|
||||||
## TurkceAltyazi Gercek Modu (v2)
|
## TurkceAltyazi Gercek Modu (v2)
|
||||||
|
|
||||||
Gercek entegrasyon feature flag ile acilabilir:
|
Gercek entegrasyon feature flag ile acilabilir:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
ENABLE_TURKCEALTYAZI_REAL=true
|
ENABLE_TURKCEALTYAZI_REAL=true
|
||||||
TURKCEALTYAZI_ALLOW_MOCK_FALLBACK=true
|
|
||||||
TURKCEALTYAZI_BASE_URL=https://turkcealtyazi.org
|
TURKCEALTYAZI_BASE_URL=https://turkcealtyazi.org
|
||||||
TURKCEALTYAZI_TIMEOUT_MS=12000
|
TURKCEALTYAZI_TIMEOUT_MS=12000
|
||||||
TURKCEALTYAZI_MIN_DELAY_MS=300
|
TURKCEALTYAZI_MIN_DELAY_MS=300
|
||||||
```
|
```
|
||||||
|
|
||||||
- `ENABLE_TURKCEALTYAZI_REAL=true`: `TurkceAltyaziProvider` gercek HTTP+HTML parse dener.
|
- `ENABLE_TURKCEALTYAZI_REAL=true`: `TurkceAltyaziProvider` gercek HTTP+HTML parse dener.
|
||||||
- `TURKCEALTYAZI_ALLOW_MOCK_FALLBACK=true`: real akista hata olursa mock adaptere doner.
|
|
||||||
- `false` yaparsan real hata durumunda job tarafina hata/not found olarak yansir.
|
|
||||||
|
|
||||||
## Gelistirme (Dev)
|
## Gelistirme (Dev)
|
||||||
|
|
||||||
@@ -171,6 +159,5 @@ Kapsam:
|
|||||||
|
|
||||||
## Gelecek (v2)
|
## Gelecek (v2)
|
||||||
|
|
||||||
- Gercek TurkceAltyazi scraping
|
|
||||||
- Gercek OpenSubtitles API entegrasyonu
|
- Gercek OpenSubtitles API entegrasyonu
|
||||||
- ClamAV tarama (feature flag hazir)
|
- ClamAV tarama (feature flag hazir)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
- VITE_PUBLIC_CORE_URL=http://localhost:3001
|
- VITE_PUBLIC_CORE_URL=http://localhost:3001
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
command: sh -c "npm install && npm run dev"
|
command: sh -c "rm -rf /app/node_modules/* && npm install --include=optional --no-package-lock && npm run dev"
|
||||||
volumes:
|
volumes:
|
||||||
- ./services/ui:/app
|
- ./services/ui:/app
|
||||||
- ui_node_modules:/app/node_modules
|
- ui_node_modules:/app/node_modules
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ Providerlar:
|
|||||||
- `OpenSubtitlesProvider`
|
- `OpenSubtitlesProvider`
|
||||||
|
|
||||||
Not:
|
Not:
|
||||||
- OpenSubtitles su an mock.
|
- TurkceAltyazi provider gercek HTTP+HTML akisi ile calisir.
|
||||||
- TurkceAltyazi mock + feature-flag ile real deneme moduna sahip.
|
- OpenSubtitles provider real entegrasyon tamamlanana kadar pasiftir.
|
||||||
|
|
||||||
## `services/ui`
|
## `services/ui`
|
||||||
|
|
||||||
@@ -80,4 +80,3 @@ Sayfalar:
|
|||||||
6. API provider adaylarini indirir/isler.
|
6. API provider adaylarini indirir/isler.
|
||||||
7. best secilirse core `finalizeWrite` ile dosyayi yazar.
|
7. best secilirse core `finalizeWrite` ile dosyayi yazar.
|
||||||
8. Job `DONE` olur, loglar UI'da canli akar.
|
8. Job `DONE` olur, loglar UI'da canli akar.
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ Bu belge, 16 Subat 2026 tarihine kadar subwatcher projesinde tamamlanan calismal
|
|||||||
- Review listesi + manuel secim akisi
|
- Review listesi + manuel secim akisi
|
||||||
- Settings
|
- Settings
|
||||||
- Watched Paths
|
- Watched Paths
|
||||||
- API tarafinda mock provider altyapisi aktif:
|
- API tarafinda provider altyapisi aktif.
|
||||||
- TurkceAltyazi (mock)
|
|
||||||
- OpenSubtitles (mock)
|
|
||||||
- Archive extraction/security aktif:
|
- Archive extraction/security aktif:
|
||||||
- 7z extraction
|
- 7z extraction
|
||||||
- zip slip kontrolu
|
- zip slip kontrolu
|
||||||
@@ -51,7 +49,6 @@ Bu belge, 16 Subat 2026 tarihine kadar subwatcher projesinde tamamlanan calismal
|
|||||||
|
|
||||||
- Feature flag eklendi (API env):
|
- Feature flag eklendi (API env):
|
||||||
- `ENABLE_TURKCEALTYAZI_REAL`
|
- `ENABLE_TURKCEALTYAZI_REAL`
|
||||||
- `TURKCEALTYAZI_ALLOW_MOCK_FALLBACK`
|
|
||||||
- `TURKCEALTYAZI_BASE_URL`
|
- `TURKCEALTYAZI_BASE_URL`
|
||||||
- `TURKCEALTYAZI_TIMEOUT_MS`
|
- `TURKCEALTYAZI_TIMEOUT_MS`
|
||||||
- `TURKCEALTYAZI_MIN_DELAY_MS`
|
- `TURKCEALTYAZI_MIN_DELAY_MS`
|
||||||
@@ -88,4 +85,3 @@ Bu belge, 16 Subat 2026 tarihine kadar subwatcher projesinde tamamlanan calismal
|
|||||||
2. TurkceAltyazi parserini fixture testlerle sabitle.
|
2. TurkceAltyazi parserini fixture testlerle sabitle.
|
||||||
3. TA hata tiplerini ayir (network/parsing/rate-limit/blocked).
|
3. TA hata tiplerini ayir (network/parsing/rate-limit/blocked).
|
||||||
4. Core tarafinda retry/policy netlestir.
|
4. Core tarafinda retry/policy netlestir.
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ Guncel durum tarihi: **16 Subat 2026**
|
|||||||
|
|
||||||
## Hedef
|
## Hedef
|
||||||
|
|
||||||
Mock yerine TurkceAltyazi kaynagindan gercek aday bulma ve indirme akisini aktif etmek.
|
TurkceAltyazi kaynagindan gercek aday bulma ve indirme akisini stabil hale getirmek.
|
||||||
|
|
||||||
## Tamamlananlar
|
## Tamamlananlar
|
||||||
|
|
||||||
1. Feature flags tanimli:
|
1. Feature flags tanimli:
|
||||||
- `ENABLE_TURKCEALTYAZI_REAL`
|
- `ENABLE_TURKCEALTYAZI_REAL`
|
||||||
- `TURKCEALTYAZI_ALLOW_MOCK_FALLBACK`
|
|
||||||
- `TURKCEALTYAZI_BASE_URL`
|
- `TURKCEALTYAZI_BASE_URL`
|
||||||
- `TURKCEALTYAZI_TIMEOUT_MS`
|
- `TURKCEALTYAZI_TIMEOUT_MS`
|
||||||
- `TURKCEALTYAZI_MIN_DELAY_MS`
|
- `TURKCEALTYAZI_MIN_DELAY_MS`
|
||||||
@@ -22,10 +21,9 @@ Mock yerine TurkceAltyazi kaynagindan gercek aday bulma ve indirme akisini aktif
|
|||||||
- Detail sayfasinda download link cikarimi
|
- Detail sayfasinda download link cikarimi
|
||||||
- Binary indirme
|
- Binary indirme
|
||||||
|
|
||||||
3. Provider real/mok gecisi var:
|
3. Provider real akisla calisiyor:
|
||||||
- `services/api/src/providers/TurkceAltyaziProvider.ts`
|
- `services/api/src/providers/TurkceAltyaziProvider.ts`
|
||||||
- Real aciksa once real dener
|
- Real aciksa search/download adimlari gercek kaynakta calisir
|
||||||
- Basarisiz olursa fallback policy'e gore mock'a duser
|
|
||||||
|
|
||||||
4. Trace log adimlari var:
|
4. Trace log adimlari var:
|
||||||
- `TA_SEARCH_REQUEST`
|
- `TA_SEARCH_REQUEST`
|
||||||
@@ -62,7 +60,6 @@ Mock yerine TurkceAltyazi kaynagindan gercek aday bulma ve indirme akisini aktif
|
|||||||
1. `.env`:
|
1. `.env`:
|
||||||
```env
|
```env
|
||||||
ENABLE_TURKCEALTYAZI_REAL=true
|
ENABLE_TURKCEALTYAZI_REAL=true
|
||||||
TURKCEALTYAZI_ALLOW_MOCK_FALLBACK=true
|
|
||||||
```
|
```
|
||||||
2. Servisleri yeniden baslat:
|
2. Servisleri yeniden baslat:
|
||||||
```bash
|
```bash
|
||||||
@@ -70,7 +67,7 @@ docker compose -f compose.dev.yml up -d --build api core
|
|||||||
```
|
```
|
||||||
3. Yeni bir movie job tetikle.
|
3. Yeni bir movie job tetikle.
|
||||||
4. Job logda TA adimlarini dogrula.
|
4. Job logda TA adimlarini dogrula.
|
||||||
5. Mock'a dustuyse sebebi logla ve parseri iyilestir.
|
5. Hata varsa sebebi logla ve parseri iyilestir.
|
||||||
|
|
||||||
## Onemli dosyalar
|
## Onemli dosyalar
|
||||||
|
|
||||||
@@ -79,4 +76,3 @@ docker compose -f compose.dev.yml up -d --build api core
|
|||||||
- `services/api/src/lib/subtitleEngine.ts`
|
- `services/api/src/lib/subtitleEngine.ts`
|
||||||
- `.env`
|
- `.env`
|
||||||
- `.env.example`
|
- `.env.example`
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export const env = {
|
|||||||
tempRoot: process.env.TEMP_ROOT ?? '/temp',
|
tempRoot: process.env.TEMP_ROOT ?? '/temp',
|
||||||
enableApiKey: process.env.ENABLE_API_KEY === 'true',
|
enableApiKey: process.env.ENABLE_API_KEY === 'true',
|
||||||
apiKey: process.env.API_KEY ?? '',
|
apiKey: process.env.API_KEY ?? '',
|
||||||
|
enableTaStepLogs: process.env.ENABLE_TA_STEP_LOGS === 'true',
|
||||||
enableTurkcealtyaziReal: process.env.ENABLE_TURKCEALTYAZI_REAL === 'true',
|
enableTurkcealtyaziReal: process.env.ENABLE_TURKCEALTYAZI_REAL === 'true',
|
||||||
turkcealtyaziAllowMockFallback: process.env.TURKCEALTYAZI_ALLOW_MOCK_FALLBACK !== 'false',
|
|
||||||
turkcealtyaziBaseUrl: process.env.TURKCEALTYAZI_BASE_URL ?? 'https://turkcealtyazi.org',
|
turkcealtyaziBaseUrl: process.env.TURKCEALTYAZI_BASE_URL ?? 'https://turkcealtyazi.org',
|
||||||
turkcealtyaziTimeoutMs: Number(process.env.TURKCEALTYAZI_TIMEOUT_MS ?? 12000),
|
turkcealtyaziTimeoutMs: Number(process.env.TURKCEALTYAZI_TIMEOUT_MS ?? 12000),
|
||||||
turkcealtyaziMinDelayMs: Number(process.env.TURKCEALTYAZI_MIN_DELAY_MS ?? 300)
|
turkcealtyaziMinDelayMs: Number(process.env.TURKCEALTYAZI_MIN_DELAY_MS ?? 300)
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -60,7 +60,7 @@ export async function searchSubtitles(input: SearchParams) {
|
|||||||
level: 'info',
|
level: 'info',
|
||||||
step: 'TA_SEARCH_PARSED',
|
step: 'TA_SEARCH_PARSED',
|
||||||
message: `TurkceAltyazi candidates parsed`,
|
message: `TurkceAltyazi candidates parsed`,
|
||||||
meta: { total: c.length, real: realCount, mock: c.length - realCount }
|
meta: { total: c.length, real: realCount }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
allCandidates.push(...c);
|
allCandidates.push(...c);
|
||||||
|
|||||||
21
services/api/src/lib/taLog.ts
Normal file
21
services/api/src/lib/taLog.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
function oneLine(input: unknown): string {
|
||||||
|
if (input === undefined || input === null) return '';
|
||||||
|
return String(input).replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taInfo(step: string, message: string, meta?: Record<string, unknown>) {
|
||||||
|
if (!env.enableTaStepLogs) return;
|
||||||
|
const base = `[TA] step=${oneLine(step)} msg="${oneLine(message)}"`;
|
||||||
|
const metaPart = meta ? ` meta=${oneLine(JSON.stringify(meta))}` : '';
|
||||||
|
console.log(`${base}${metaPart}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taError(step: string, error: unknown, meta?: Record<string, unknown>) {
|
||||||
|
if (!env.enableTaStepLogs) return;
|
||||||
|
const reason = error instanceof Error ? error.message : oneLine(error);
|
||||||
|
const base = `[TA][ERROR] step=${oneLine(step)} reason="${oneLine(reason)}"`;
|
||||||
|
const metaPart = meta ? ` meta=${oneLine(JSON.stringify(meta))}` : '';
|
||||||
|
console.error(`${base}${metaPart}`);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import type { SearchParams } from '../types/index.js';
|
import type { SearchParams } from '../types/index.js';
|
||||||
|
import { taError, taInfo } from './taLog.js';
|
||||||
|
|
||||||
export interface RealTaCandidate {
|
export interface RealTaCandidate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,15 +31,64 @@ function sleep(ms: number) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getWithRetry(url: string, retries = 2): Promise<string> {
|
interface HttpResultText {
|
||||||
|
body: string;
|
||||||
|
finalUrl: string;
|
||||||
|
setCookie: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpResultBinary {
|
||||||
|
body: Buffer;
|
||||||
|
finalUrl: string;
|
||||||
|
setCookie: string[];
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSetCookie(setCookie: string[]): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const raw of setCookie) {
|
||||||
|
const first = raw.split(';')[0]?.trim();
|
||||||
|
if (!first) continue;
|
||||||
|
const idx = first.indexOf('=');
|
||||||
|
if (idx <= 0) continue;
|
||||||
|
const k = first.slice(0, idx).trim();
|
||||||
|
const v = first.slice(idx + 1).trim();
|
||||||
|
if (k) out.set(k, v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieHeader(cookies: Map<string, string>): string {
|
||||||
|
return [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCookies(target: Map<string, string>, setCookie: string[]) {
|
||||||
|
const parsed = parseSetCookie(setCookie);
|
||||||
|
for (const [k, v] of parsed.entries()) target.set(k, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWithRetry(url: string, retries = 2, cookies?: Map<string, string>): Promise<HttpResultText> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (let i = 0; i <= retries; i++) {
|
for (let i = 0; i <= retries; i++) {
|
||||||
try {
|
try {
|
||||||
if (i > 0) await sleep(250 * i);
|
if (i > 0) await sleep(250 * i);
|
||||||
const res = await client.get(url);
|
taInfo('HTTP_GET_START', 'HTTP GET started', { url, attempt: i + 1, retries: retries + 1 });
|
||||||
return typeof res.data === 'string' ? res.data : String(res.data);
|
const res = await client.get(url, {
|
||||||
|
headers: cookies && cookies.size > 0 ? { cookie: cookieHeader(cookies) } : undefined
|
||||||
|
});
|
||||||
|
taInfo('HTTP_GET_RESULT', 'HTTP GET completed', {
|
||||||
|
url,
|
||||||
|
finalUrl: (res.request as any)?.res?.responseUrl || url,
|
||||||
|
contentType: res.headers['content-type']
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
body: typeof res.data === 'string' ? res.data : String(res.data),
|
||||||
|
finalUrl: (res.request as any)?.res?.responseUrl || url,
|
||||||
|
setCookie: Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : []
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
|
taError('HTTP_GET_FAILED', err, { url, attempt: i + 1, retries: retries + 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError;
|
throw lastError;
|
||||||
@@ -56,110 +107,252 @@ function abs(base: string, maybeRelative: string): string {
|
|||||||
return new URL(maybeRelative, base).toString();
|
return new URL(maybeRelative, base).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCandidateNodes(html: string, baseUrl: string): RealTaCandidate[] {
|
function normalizeText(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ç/g, 'c')
|
||||||
|
.replace(/ğ/g, 'g')
|
||||||
|
.replace(/ı/g, 'i')
|
||||||
|
.replace(/ö/g, 'o')
|
||||||
|
.replace(/ş/g, 's')
|
||||||
|
.replace(/ü/g, 'u')
|
||||||
|
.replace(/[^a-z0-9\s]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(input: string): string[] {
|
||||||
|
return normalizeText(input)
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFindQuery(params: SearchParams): string {
|
||||||
|
const toks = tokenize(params.title).filter((t) => !/^\d+$/.test(t));
|
||||||
|
return toks.slice(0, 2).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMovieLinkFromSearch(html: string, params: SearchParams, baseUrl: string): { movieUrl: string; movieTitle: string } | null {
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const results: RealTaCandidate[] = [];
|
const wantedYear = params.year;
|
||||||
|
const wantedTitleTokens = tokenize(params.title);
|
||||||
|
const links: Array<{ url: string; title: string; year?: number; score: number }> = [];
|
||||||
|
|
||||||
$('a[href]').each((_, el) => {
|
$('a[href^="/mov/"]').each((_, el) => {
|
||||||
const href = ($(el).attr('href') || '').trim();
|
const href = ($(el).attr('href') || '').trim();
|
||||||
const text = $(el).text().replace(/\s+/g, ' ').trim();
|
if (!href) return;
|
||||||
|
|
||||||
if (!href || text.length < 3) return;
|
const title = ($(el).attr('title') || $(el).text() || '').replace(/\s+/g, ' ').trim();
|
||||||
const looksLikeSubtitle = /(altyazi|subtitle|sub|s\d{1,2}e\d{1,2}|\b\d{4}\b)/i.test(text + ' ' + href);
|
if (!title) return;
|
||||||
if (!looksLikeSubtitle) return;
|
|
||||||
|
|
||||||
const full = abs(baseUrl, href);
|
const containerText = ($(el).closest('div').parent().text() || '').replace(/\s+/g, ' ').trim();
|
||||||
if (!/turkcealtyazi\.org/i.test(full)) return;
|
const yearMatch = containerText.match(/\((19\d{2}|20\d{2})\)/);
|
||||||
|
const year = yearMatch ? Number(yearMatch[1]) : undefined;
|
||||||
|
|
||||||
const id = `ta-real-${Buffer.from(full).toString('base64').slice(0, 18)}`;
|
const titleTokens = tokenize(title);
|
||||||
const lowered = (text + ' ' + href).toLowerCase();
|
const overlap = wantedTitleTokens.filter((t) => titleTokens.includes(t)).length;
|
||||||
|
let score = overlap;
|
||||||
|
if (wantedYear && year === wantedYear) score += 10;
|
||||||
|
|
||||||
results.push({
|
links.push({
|
||||||
id,
|
url: abs(baseUrl, href),
|
||||||
title: text,
|
title,
|
||||||
detailUrl: full,
|
year,
|
||||||
lang: /\btr\b|turkce|türkçe/i.test(lowered) ? 'tr' : 'tr',
|
score
|
||||||
releaseHints: normalizeReleaseHints(text),
|
|
||||||
isHI: /\bhi\b|isitme|hearing/i.test(lowered),
|
|
||||||
isForced: /forced|zorunlu/i.test(lowered)
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const uniq = new Map<string, RealTaCandidate>();
|
const dedup = new Map<string, { url: string; title: string; year?: number; score: number }>();
|
||||||
for (const r of results) {
|
for (const item of links) {
|
||||||
if (!uniq.has(r.detailUrl)) uniq.set(r.detailUrl, r);
|
const prev = dedup.get(item.url);
|
||||||
|
if (!prev || item.score > prev.score) dedup.set(item.url, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...uniq.values()].slice(0, 12);
|
const ordered = [...dedup.values()].sort((a, b) => b.score - a.score);
|
||||||
|
if (ordered.length === 0) return null;
|
||||||
|
|
||||||
|
const best = ordered[0];
|
||||||
|
if (wantedYear && best.year && best.year !== wantedYear) return null;
|
||||||
|
return { movieUrl: best.url, movieTitle: best.title };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSubPageFromMovieDetail(html: string, movieUrl: string, params: SearchParams): { subUrl: string; title: string; releaseHints: string[]; isHI: boolean } | null {
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const wantedRelease = normalizeText(params.release || '');
|
||||||
|
const rows = $('.altsonsez2');
|
||||||
|
const candidates: Array<{ subUrl: string; title: string; releaseHints: string[]; isHI: boolean; score: number }> = [];
|
||||||
|
|
||||||
|
rows.each((_, row) => {
|
||||||
|
const linkEl = $(row).find('a[href^="/sub/"]').first();
|
||||||
|
const href = (linkEl.attr('href') || '').trim();
|
||||||
|
if (!href) return;
|
||||||
|
|
||||||
|
const title = (linkEl.text() || '').replace(/\s+/g, ' ').trim() || (linkEl.attr('title') || '').trim();
|
||||||
|
const ripText = ($(row).find('.ripdiv').text() || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const relHints = normalizeReleaseHints(ripText);
|
||||||
|
const normalizedRip = normalizeText(ripText);
|
||||||
|
const isHI = /(sdh|hearing|isitme|hi)/i.test(ripText);
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (wantedRelease) {
|
||||||
|
if (normalizedRip.includes(wantedRelease)) score += 20;
|
||||||
|
const releaseToken = wantedRelease.split(/\s+/).find(Boolean);
|
||||||
|
if (releaseToken && normalizedRip.includes(releaseToken)) score += 15;
|
||||||
|
} else {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($(row).find('.flagtr').length > 0) score += 3;
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
subUrl: abs(movieUrl, href),
|
||||||
|
title,
|
||||||
|
releaseHints: relHints,
|
||||||
|
isHI,
|
||||||
|
score
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const picked = candidates.sort((a, b) => b.score - a.score)[0];
|
||||||
|
if (wantedRelease && picked.score < 10) return null;
|
||||||
|
return picked;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchTurkceAltyaziReal(params: SearchParams): Promise<RealTaCandidate[]> {
|
export async function searchTurkceAltyaziReal(params: SearchParams): Promise<RealTaCandidate[]> {
|
||||||
const q = [params.title, params.year, params.type === 'tv' ? `S${String(params.season ?? 1).padStart(2, '0')}E${String(params.episode ?? 1).padStart(2, '0')}` : '']
|
if (params.type !== 'movie') return [];
|
||||||
.filter(Boolean)
|
const q = buildFindQuery(params);
|
||||||
.join(' ');
|
if (!q) return [];
|
||||||
|
|
||||||
const candidatesPages = [
|
const searchUrl = `${env.turkcealtyaziBaseUrl}/find.php?cat=sub&find=${encodeURIComponent(q)}`;
|
||||||
`${env.turkcealtyaziBaseUrl}/arama?q=${encodeURIComponent(q)}`,
|
const cookies = new Map<string, string>();
|
||||||
`${env.turkcealtyaziBaseUrl}/find.php?cat=sub&find=${encodeURIComponent(q)}`
|
taInfo('TA_SEARCH_START', 'TurkceAltyazi search started', {
|
||||||
];
|
title: params.title,
|
||||||
|
year: params.year,
|
||||||
const merged: RealTaCandidate[] = [];
|
release: params.release,
|
||||||
for (const url of candidatesPages) {
|
query: q,
|
||||||
try {
|
searchUrl
|
||||||
await sleep(env.turkcealtyaziMinDelayMs);
|
|
||||||
const html = await getWithRetry(url, 2);
|
|
||||||
merged.push(...parseCandidateNodes(html, env.turkcealtyaziBaseUrl));
|
|
||||||
if (merged.length >= 8) break;
|
|
||||||
} catch {
|
|
||||||
// bir sonraki endpoint denenecek
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniq = new Map<string, RealTaCandidate>();
|
|
||||||
for (const item of merged) {
|
|
||||||
if (!uniq.has(item.detailUrl)) uniq.set(item.detailUrl, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...uniq.values()].slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveTurkceAltyaziDownloadUrl(detailUrl: string): Promise<string> {
|
|
||||||
await sleep(env.turkcealtyaziMinDelayMs);
|
|
||||||
const html = await getWithRetry(detailUrl, 2);
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
|
|
||||||
const linkCandidates: string[] = [];
|
|
||||||
|
|
||||||
$('a[href]').each((_, el) => {
|
|
||||||
const href = ($(el).attr('href') || '').trim();
|
|
||||||
const text = $(el).text().trim();
|
|
||||||
if (!href) return;
|
|
||||||
|
|
||||||
const looksDownload = /(indir|download|\.zip|\.rar|\.7z|\.srt|\.ass)/i.test(`${href} ${text}`);
|
|
||||||
if (!looksDownload) return;
|
|
||||||
|
|
||||||
linkCandidates.push(abs(detailUrl, href));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const preferred =
|
try {
|
||||||
linkCandidates.find((l) => /\.(zip|rar|7z)(\?|$)/i.test(l)) ||
|
await sleep(env.turkcealtyaziMinDelayMs);
|
||||||
linkCandidates.find((l) => /\.(srt|ass)(\?|$)/i.test(l)) ||
|
const searchRes = await getWithRetry(searchUrl, 2, cookies);
|
||||||
linkCandidates[0];
|
mergeCookies(cookies, searchRes.setCookie);
|
||||||
|
const pickedMovie = pickMovieLinkFromSearch(searchRes.body, params, env.turkcealtyaziBaseUrl);
|
||||||
|
if (!pickedMovie) {
|
||||||
|
taInfo('TA_SEARCH_RESULT', 'Movie page not matched from search list', { title: params.title, year: params.year, query: q });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
taInfo('TA_MOVIE_SELECTED', 'Movie detail page selected', { movieUrl: pickedMovie.movieUrl, movieTitle: pickedMovie.movieTitle });
|
||||||
|
|
||||||
if (!preferred) {
|
await sleep(env.turkcealtyaziMinDelayMs);
|
||||||
throw new Error('TA detail page download link parse failed');
|
const movieRes = await getWithRetry(pickedMovie.movieUrl, 2, cookies);
|
||||||
|
mergeCookies(cookies, movieRes.setCookie);
|
||||||
|
const pickedSub = pickSubPageFromMovieDetail(movieRes.body, pickedMovie.movieUrl, params);
|
||||||
|
if (!pickedSub) {
|
||||||
|
taInfo('TA_SEARCH_RESULT', 'Subtitle sub-page not matched by release', {
|
||||||
|
movieUrl: pickedMovie.movieUrl,
|
||||||
|
release: params.release
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
taInfo('TA_SUB_SELECTED', 'Subtitle sub-page selected', {
|
||||||
|
subUrl: pickedSub.subUrl,
|
||||||
|
releaseHints: pickedSub.releaseHints
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = `ta-real-${Buffer.from(pickedSub.subUrl).toString('base64').slice(0, 18)}`;
|
||||||
|
const result = [{
|
||||||
|
id,
|
||||||
|
title: pickedSub.title || pickedMovie.movieTitle,
|
||||||
|
detailUrl: pickedSub.subUrl,
|
||||||
|
lang: 'tr',
|
||||||
|
releaseHints: pickedSub.releaseHints,
|
||||||
|
isHI: pickedSub.isHI,
|
||||||
|
isForced: false
|
||||||
|
}];
|
||||||
|
taInfo('TA_SEARCH_RESULT', 'TurkceAltyazi search completed', { candidateCount: result.length, subUrl: pickedSub.subUrl });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
taError('TA_SEARCH_FAILED', err, { title: params.title, year: params.year, release: params.release, query: q });
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return preferred;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadTurkceAltyaziFile(url: string): Promise<{ buffer: Buffer; finalUrl: string; contentType?: string }> {
|
function parseDownloadForm(html: string): { idid: string; altid: string; sidid: string } | null {
|
||||||
await sleep(env.turkcealtyaziMinDelayMs);
|
const $ = cheerio.load(html);
|
||||||
const res = await client.get<ArrayBuffer>(url, { responseType: 'arraybuffer' });
|
const idid = ($('input[name="idid"]').attr('value') || '').trim();
|
||||||
const buffer = Buffer.from(res.data);
|
const altid = ($('input[name="altid"]').attr('value') || '').trim();
|
||||||
return {
|
const sidid = ($('input[name="sidid"]').attr('value') || '').trim();
|
||||||
buffer,
|
if (!idid || !altid || !sidid) return null;
|
||||||
finalUrl: (res.request as any)?.res?.responseUrl || url,
|
return { idid, altid, sidid };
|
||||||
contentType: res.headers['content-type']
|
}
|
||||||
};
|
|
||||||
|
async function postIndWithRetry(subPageUrl: string, payload: { idid: string; altid: string; sidid: string }, cookies: Map<string, string>, retries = 2): Promise<HttpResultBinary> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let i = 0; i <= retries; i++) {
|
||||||
|
try {
|
||||||
|
if (i > 0) await sleep(250 * i);
|
||||||
|
const form = new URLSearchParams(payload).toString();
|
||||||
|
const indUrl = `${env.turkcealtyaziBaseUrl}/ind`;
|
||||||
|
taInfo('TA_IND_POST_START', 'POST /ind started', { subPageUrl, indUrl, attempt: i + 1, retries: retries + 1, altid: payload.altid });
|
||||||
|
const res = await client.post<ArrayBuffer>(indUrl, form, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/x-www-form-urlencoded',
|
||||||
|
origin: env.turkcealtyaziBaseUrl,
|
||||||
|
referer: subPageUrl,
|
||||||
|
cookie: cookieHeader(cookies)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: Buffer.from(res.data),
|
||||||
|
finalUrl: (res.request as any)?.res?.responseUrl || indUrl,
|
||||||
|
setCookie: Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : [],
|
||||||
|
contentType: res.headers['content-type']
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
taError('TA_IND_POST_FAILED', err, { subPageUrl, attempt: i + 1, retries: retries + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadTurkceAltyaziFile(subPageUrl: string): Promise<{ buffer: Buffer; finalUrl: string; contentType?: string }> {
|
||||||
|
const cookies = new Map<string, string>();
|
||||||
|
taInfo('TA_DOWNLOAD_START', 'TurkceAltyazi subtitle download started', { subPageUrl });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(env.turkcealtyaziMinDelayMs);
|
||||||
|
const subPageRes = await getWithRetry(subPageUrl, 2, cookies);
|
||||||
|
mergeCookies(cookies, subPageRes.setCookie);
|
||||||
|
const form = parseDownloadForm(subPageRes.body);
|
||||||
|
if (!form) {
|
||||||
|
const err = new Error('TA sub page download form parse failed');
|
||||||
|
taError('TA_FORM_PARSE_FAILED', err, { subPageUrl });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
taInfo('TA_FORM_PARSED', 'Download form parsed', { subPageUrl, altid: form.altid, idid: form.idid });
|
||||||
|
|
||||||
|
await sleep(env.turkcealtyaziMinDelayMs);
|
||||||
|
const res = await postIndWithRetry(subPageUrl, form, cookies, 2);
|
||||||
|
mergeCookies(cookies, res.setCookie);
|
||||||
|
|
||||||
|
taInfo('TA_DOWNLOAD_RESULT', 'Subtitle download completed', {
|
||||||
|
subPageUrl,
|
||||||
|
finalUrl: res.finalUrl,
|
||||||
|
contentType: res.contentType,
|
||||||
|
bytes: res.body.byteLength
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
buffer: res.body,
|
||||||
|
finalUrl: res.finalUrl,
|
||||||
|
contentType: res.contentType
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
taError('TA_DOWNLOAD_FAILED', err, { subPageUrl });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,12 @@
|
|||||||
import type { Candidate, SearchParams, SubtitleProvider } from '../types/index.js';
|
import type { Candidate, DownloadedArtifact, 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 {
|
export class OpenSubtitlesProvider implements SubtitleProvider {
|
||||||
async search(params: SearchParams): Promise<Candidate[]> {
|
async search(_params: SearchParams): Promise<Candidate[]> {
|
||||||
// TODO(v2): real OpenSubtitles API integration.
|
// Real OpenSubtitles entegrasyonu tamamlanana kadar provider pasif.
|
||||||
const key = `${params.title}|${params.year}|${params.season}|${params.episode}|os`;
|
return [];
|
||||||
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) {
|
async download(_candidate: Candidate, _params: SearchParams, _jobToken: string): Promise<DownloadedArtifact> {
|
||||||
const artifact = await generateMockArtifact(candidate, params, jobToken, `${env.tempRoot}/${jobToken}/download`);
|
throw new Error('OpenSubtitles real download not implemented');
|
||||||
return { type: artifact.type, filePath: artifact.filePath, candidateId: candidate.id };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { Candidate, SearchParams, SubtitleProvider } from '../types/index.js';
|
import type { Candidate, DownloadedArtifact, 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';
|
import { env } from '../config/env.js';
|
||||||
import {
|
import {
|
||||||
downloadTurkceAltyaziFile,
|
downloadTurkceAltyaziFile,
|
||||||
resolveTurkceAltyaziDownloadUrl,
|
|
||||||
searchTurkceAltyaziReal
|
searchTurkceAltyaziReal
|
||||||
} from '../lib/turkcealtyaziReal.js';
|
} from '../lib/turkcealtyaziReal.js';
|
||||||
|
import { taError, taInfo } from '../lib/taLog.js';
|
||||||
|
|
||||||
function extensionFromDownload(url: string, contentType?: string): 'zip' | 'rar' | '7z' | 'srt' | 'ass' {
|
function extensionFromDownload(url: string, contentType?: string): 'zip' | 'rar' | '7z' | 'srt' | 'ass' {
|
||||||
const lowerUrl = url.toLowerCase();
|
const lowerUrl = url.toLowerCase();
|
||||||
@@ -22,84 +20,72 @@ function extensionFromDownload(url: string, contentType?: string): 'zip' | 'rar'
|
|||||||
|
|
||||||
export class TurkceAltyaziProvider implements SubtitleProvider {
|
export class TurkceAltyaziProvider implements SubtitleProvider {
|
||||||
async search(params: SearchParams): Promise<Candidate[]> {
|
async search(params: SearchParams): Promise<Candidate[]> {
|
||||||
if (env.enableTurkcealtyaziReal) {
|
if (!env.enableTurkcealtyaziReal) return [];
|
||||||
try {
|
|
||||||
const real = await searchTurkceAltyaziReal(params);
|
|
||||||
if (real.length > 0) {
|
|
||||||
return real.map((item, index) => ({
|
|
||||||
id: item.id || `ta-real-${index}`,
|
|
||||||
provider: 'turkcealtyazi',
|
|
||||||
displayName: item.title,
|
|
||||||
downloadType: 'archiveZip',
|
|
||||||
downloadUrl: item.detailUrl,
|
|
||||||
lang: item.lang || 'tr',
|
|
||||||
releaseHints: item.releaseHints,
|
|
||||||
scoreHints: ['real_provider'],
|
|
||||||
isHI: item.isHI,
|
|
||||||
isForced: item.isForced
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!env.turkcealtyaziAllowMockFallback) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${params.title}|${params.year}|${params.season}|${params.episode}|ta`;
|
taInfo('TA_PROVIDER_SEARCH_START', 'Provider search started', {
|
||||||
const rnd = seeded(hashString(key));
|
title: params.title,
|
||||||
const base = params.title.replace(/\s+/g, '.');
|
year: params.year,
|
||||||
return [
|
release: params.release
|
||||||
{
|
});
|
||||||
id: `ta-${hashString(`${key}-a`)}`,
|
try {
|
||||||
|
const real = await searchTurkceAltyaziReal(params);
|
||||||
|
taInfo('TA_PROVIDER_SEARCH_RESULT', 'Provider search completed', { candidateCount: real.length });
|
||||||
|
return real.map((item, index) => ({
|
||||||
|
id: item.id || `ta-real-${index}`,
|
||||||
provider: 'turkcealtyazi',
|
provider: 'turkcealtyazi',
|
||||||
displayName: `TA ${base} Ana Surum`,
|
displayName: item.title,
|
||||||
downloadType: 'archiveZip',
|
downloadType: 'archiveZip',
|
||||||
downloadUrl: `mock://ta/${base}/a.zip`,
|
downloadUrl: item.detailUrl,
|
||||||
lang: 'tr',
|
lang: item.lang || 'tr',
|
||||||
releaseHints: [rnd() > 0.4 ? '1080p' : '720p', 'x265', 'flux'],
|
releaseHints: item.releaseHints,
|
||||||
scoreHints: ['trusted', 'crowd'],
|
scoreHints: ['real_provider'],
|
||||||
isHI: rnd() > 0.7,
|
isHI: item.isHI,
|
||||||
isForced: false
|
isForced: item.isForced
|
||||||
},
|
}));
|
||||||
{
|
} catch (err) {
|
||||||
id: `ta-${hashString(`${key}-b`)}`,
|
taError('TA_PROVIDER_SEARCH_FAILED', err, { title: params.title, year: params.year, release: params.release });
|
||||||
provider: 'turkcealtyazi',
|
throw err;
|
||||||
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) {
|
async download(candidate: Candidate, _params: SearchParams, jobToken: string): Promise<DownloadedArtifact> {
|
||||||
if (env.enableTurkcealtyaziReal && /^https?:\/\//i.test(candidate.downloadUrl)) {
|
if (!/^https?:\/\//i.test(candidate.downloadUrl)) {
|
||||||
const downloadDir = `${env.tempRoot}/${jobToken}/download`;
|
throw new Error('TurkceAltyazi candidate download URL must be http(s)');
|
||||||
await fs.mkdir(downloadDir, { recursive: true });
|
}
|
||||||
const trace: Array<{ level: 'info' | 'warn' | 'error'; step: string; message: string; meta?: any }> = [];
|
|
||||||
|
|
||||||
trace.push({ level: 'info', step: 'TA_DETAIL_FETCHED', message: candidate.downloadUrl });
|
const downloadDir = `${env.tempRoot}/${jobToken}/download`;
|
||||||
const resolved = await resolveTurkceAltyaziDownloadUrl(candidate.downloadUrl);
|
await fs.mkdir(downloadDir, { recursive: true });
|
||||||
trace.push({ level: 'info', step: 'TA_DOWNLOAD_URL_RESOLVED', message: resolved });
|
const trace: Array<{ level: 'info' | 'warn' | 'error'; step: string; message: string; meta?: any }> = [];
|
||||||
const downloaded = await downloadTurkceAltyaziFile(resolved);
|
|
||||||
|
taInfo('TA_PROVIDER_DOWNLOAD_START', 'Provider download started', {
|
||||||
|
candidateId: candidate.id,
|
||||||
|
subUrl: candidate.downloadUrl,
|
||||||
|
jobToken
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
trace.push({ level: 'info', step: 'TA_SUB_PAGE_FETCHED', message: candidate.downloadUrl });
|
||||||
|
const downloaded = await downloadTurkceAltyaziFile(candidate.downloadUrl);
|
||||||
|
trace.push({ level: 'info', step: 'TA_IND_POST_DONE', message: downloaded.finalUrl });
|
||||||
const ext = extensionFromDownload(downloaded.finalUrl, downloaded.contentType);
|
const ext = extensionFromDownload(downloaded.finalUrl, downloaded.contentType);
|
||||||
const filePath = path.join(downloadDir, `${candidate.id}.${ext}`);
|
const filePath = path.join(downloadDir, `${candidate.id}.${ext}`);
|
||||||
await fs.writeFile(filePath, downloaded.buffer);
|
await fs.writeFile(filePath, downloaded.buffer);
|
||||||
|
|
||||||
|
const type: 'direct' | 'archive' = ext === 'srt' || ext === 'ass' ? 'direct' : 'archive';
|
||||||
|
taInfo('TA_PROVIDER_DOWNLOAD_RESULT', 'Provider download completed', {
|
||||||
|
candidateId: candidate.id,
|
||||||
|
filePath,
|
||||||
|
type,
|
||||||
|
ext
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
type: ext === 'srt' || ext === 'ass' ? 'direct' : 'archive',
|
type,
|
||||||
filePath,
|
filePath,
|
||||||
candidateId: candidate.id,
|
candidateId: candidate.id,
|
||||||
trace
|
trace
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
taError('TA_PROVIDER_DOWNLOAD_FAILED', err, { candidateId: candidate.id, subUrl: candidate.downloadUrl, jobToken });
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifact = await generateMockArtifact(candidate, params, jobToken, `${env.tempRoot}/${jobToken}/download`);
|
|
||||||
return { type: artifact.type, filePath: artifact.filePath, candidateId: candidate.id };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const candidate: any = {
|
|||||||
provider: 'opensubtitles',
|
provider: 'opensubtitles',
|
||||||
displayName: 'x',
|
displayName: 'x',
|
||||||
downloadType: 'archiveZip',
|
downloadType: 'archiveZip',
|
||||||
downloadUrl: 'mock://x',
|
downloadUrl: 'https://example.com/subtitle.zip',
|
||||||
lang: 'tr',
|
lang: 'tr',
|
||||||
releaseHints: ['1080p', 'x265', 'flux'],
|
releaseHints: ['1080p', 'x265', 'flux'],
|
||||||
scoreHints: [],
|
scoreHints: [],
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function startWorkers(): void {
|
|||||||
mediaFileId,
|
mediaFileId,
|
||||||
bestPath: data.bestPath,
|
bestPath: data.bestPath,
|
||||||
lang: (payload.languages[0] ?? 'tr') as string,
|
lang: (payload.languages[0] ?? 'tr') as string,
|
||||||
source: data.source ?? 'mock',
|
source: data.source ?? 'unknown',
|
||||||
confidence: data.confidence ?? 0.8
|
confidence: data.confidence ?? 0.8
|
||||||
} satisfies FinalizeData);
|
} satisfies FinalizeData);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user