feat(api): fps tabanlı altyazı eşleştirmesi ekle

Video ve altyazı FPS değerlerini karşılaştırarak daha doğru eşleştirme
yapar. Tam eşleşme ve token eşleşmesi bulunamadığında FPS uyumlu
altyazıları önceliklendirir. FFprobe çıktısından FPS değerini normalize
eder ve karşılaştırma için kullanır.
This commit is contained in:
2026-02-16 17:05:51 +03:00
parent 2c60c669c0
commit 0dd8f60626
2 changed files with 97 additions and 23 deletions

View File

@@ -16,7 +16,7 @@ export interface RealTaCandidate {
releaseHints: string[];
isHI: boolean;
isForced: boolean;
strategy?: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback';
strategy?: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
isPackage?: boolean;
}
@@ -113,6 +113,34 @@ function normalizeReleaseHints(raw: string): string[] {
.slice(0, 10);
}
function normalizeFpsText(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) return null;
const s = String(raw).trim().replace(/,/g, '.').replace(/\s+/g, '');
if (!s) return null;
if (s.includes('/')) {
const [a, b] = s.split('/');
const n = Number(a);
const d = Number(b);
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null;
const v = Number((n / d).toFixed(3));
return Number.isInteger(v) ? String(v) : String(v);
}
const v = Number(s);
if (!Number.isFinite(v)) return null;
const rounded = Number(v.toFixed(3));
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
}
function fpsEquals(a?: string | null, b?: string | null): boolean {
if (!a || !b) return false;
const an = Number(a);
const bn = Number(b);
if (!Number.isFinite(an) || !Number.isFinite(bn)) return false;
return Math.abs(an - bn) <= 0.01;
}
function abs(base: string, maybeRelative: string): string {
return new URL(maybeRelative, base).toString();
}
@@ -268,7 +296,8 @@ function pickSubPageFromMovieDetail(
title: string;
releaseHints: string[];
isHI: boolean;
strategy: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback';
strategy: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
fps?: string | null;
isPackage?: boolean;
};
noMatchReason?: 'episode_not_matched' | 'release_not_matched' | 'no_sub_rows';
@@ -276,6 +305,11 @@ function pickSubPageFromMovieDetail(
const $ = cheerio.load(html);
const wantedRelease = normalizeText(params.release || '');
const wantedReleaseTokens = wantedRelease.split(/\s+/).filter(Boolean);
const wantedFps = normalizeFpsText(
params.mediaInfo?.video?.fps ??
params.mediaInfo?.video?.avg_frame_rate ??
params.mediaInfo?.video?.r_frame_rate
);
const wantedSeason = params.type === 'tv' ? params.season : undefined;
const wantedEpisode = params.type === 'tv' ? params.episode : undefined;
const rows = $('[class*="altsonsez"]');
@@ -292,6 +326,7 @@ function pickSubPageFromMovieDetail(
season?: number;
episode?: number;
isPackage: boolean;
fps?: string | null;
}> = [];
rows.each((_, row) => {
@@ -307,6 +342,7 @@ function pickSubPageFromMovieDetail(
const isTr = $(row).find('.flagtr').length > 0;
const indirmeRaw = ($(row).find('.alindirme').text() || '').replace(/\./g, '').replace(/,/g, '').trim();
const downloadCount = Number(indirmeRaw.replace(/[^\d]/g, '')) || 0;
const fps = normalizeFpsText(($(row).find('.alfps').text() || '').trim());
const { season, episode, isPackage } = parseSeasonEpisodeFromRow($, row);
if (params.type === 'tv') {
@@ -341,7 +377,8 @@ function pickSubPageFromMovieDetail(
downloadCount,
season,
episode,
isPackage
isPackage,
fps
});
});
@@ -366,14 +403,7 @@ function pickSubPageFromMovieDetail(
}
}
if (!wantedRelease || forcedStrategy === 'package_fallback') {
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
if (forcedStrategy === 'package_fallback') {
return { picked: { ...picked, strategy: 'package_fallback', isPackage: true } };
}
return { picked: { ...picked, strategy: 'default' } };
}
if (wantedRelease && forcedStrategy !== 'package_fallback') {
const exact = selectedPool
.filter((c) => c.releaseExact)
.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
@@ -387,6 +417,24 @@ function pickSubPageFromMovieDetail(
if (token) {
return { picked: { ...token, strategy: 'token' } };
}
}
const fpsMatched = wantedFps
? selectedPool.find((c) => fpsEquals(c.fps || null, wantedFps))
: undefined;
if (fpsMatched) {
return { picked: { ...fpsMatched, strategy: 'fps' } };
}
if (forcedStrategy === 'package_fallback') {
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
return { picked: { ...picked, strategy: 'package_fallback', isPackage: true } };
}
if (!wantedRelease) {
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
return { picked: { ...picked, strategy: 'default' } };
}
if (params.type === 'tv') {
// TV'de once bolum dogrulugu, sonra release gelir. Release bulunamasa da en iyi bolum satirini kullan.
@@ -502,6 +550,12 @@ export async function searchTurkceAltyaziReal(params: SearchParams): Promise<Rea
subUrl: pickedSub.subUrl,
releaseHints: pickedSub.releaseHints,
strategy: pickedSub.strategy,
fps: pickedSub.fps,
wantedFps: normalizeFpsText(
params.mediaInfo?.video?.fps ??
params.mediaInfo?.video?.avg_frame_rate ??
params.mediaInfo?.video?.r_frame_rate
),
isPackage: pickedSub.isPackage === true
});

View File

@@ -3,6 +3,25 @@ import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
function parseFraction(raw?: string): number | null {
if (!raw) return null;
const parts = raw.split('/');
if (parts.length === 2) {
const n = Number(parts[0]);
const d = Number(parts[1]);
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null;
return n / d;
}
const v = Number(raw);
return Number.isFinite(v) ? v : null;
}
function normalizeFpsValue(raw?: string): number | null {
const v = parseFraction(raw);
if (v === null) return null;
return Number(v.toFixed(3));
}
export async function analyzeWithFfprobe(path: string): Promise<any> {
const { stdout } = await execFileAsync('ffprobe', [
'-v',
@@ -25,7 +44,8 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
codec_name: video.codec_name,
width: video.width,
height: video.height,
r_frame_rate: video.r_frame_rate
r_frame_rate: video.r_frame_rate,
fps: normalizeFpsValue(video.avg_frame_rate || video.r_frame_rate)
}
: null,
audio,
@@ -39,7 +59,7 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
export function fallbackMediaInfo(): any {
return {
video: { codec_name: 'unknown', width: 1920, height: 1080, r_frame_rate: '24/1' },
video: { codec_name: 'unknown', width: 1920, height: 1080, r_frame_rate: '24/1', fps: 24 },
audio: [{ codec_name: 'unknown', channels: 2, language: 'und' }],
format: { duration: '0', bit_rate: '0', format_name: 'matroska' }
};