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:
@@ -16,7 +16,7 @@ export interface RealTaCandidate {
|
|||||||
releaseHints: string[];
|
releaseHints: string[];
|
||||||
isHI: boolean;
|
isHI: boolean;
|
||||||
isForced: boolean;
|
isForced: boolean;
|
||||||
strategy?: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback';
|
strategy?: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
|
||||||
isPackage?: boolean;
|
isPackage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +113,34 @@ function normalizeReleaseHints(raw: string): string[] {
|
|||||||
.slice(0, 10);
|
.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 {
|
function abs(base: string, maybeRelative: string): string {
|
||||||
return new URL(maybeRelative, base).toString();
|
return new URL(maybeRelative, base).toString();
|
||||||
}
|
}
|
||||||
@@ -268,7 +296,8 @@ function pickSubPageFromMovieDetail(
|
|||||||
title: string;
|
title: string;
|
||||||
releaseHints: string[];
|
releaseHints: string[];
|
||||||
isHI: boolean;
|
isHI: boolean;
|
||||||
strategy: 'exact' | 'token' | 'fallback' | 'default' | 'package_fallback';
|
strategy: 'exact' | 'token' | 'fps' | 'fallback' | 'default' | 'package_fallback';
|
||||||
|
fps?: string | null;
|
||||||
isPackage?: boolean;
|
isPackage?: boolean;
|
||||||
};
|
};
|
||||||
noMatchReason?: 'episode_not_matched' | 'release_not_matched' | 'no_sub_rows';
|
noMatchReason?: 'episode_not_matched' | 'release_not_matched' | 'no_sub_rows';
|
||||||
@@ -276,6 +305,11 @@ function pickSubPageFromMovieDetail(
|
|||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
const wantedRelease = normalizeText(params.release || '');
|
const wantedRelease = normalizeText(params.release || '');
|
||||||
const wantedReleaseTokens = wantedRelease.split(/\s+/).filter(Boolean);
|
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 wantedSeason = params.type === 'tv' ? params.season : undefined;
|
||||||
const wantedEpisode = params.type === 'tv' ? params.episode : undefined;
|
const wantedEpisode = params.type === 'tv' ? params.episode : undefined;
|
||||||
const rows = $('[class*="altsonsez"]');
|
const rows = $('[class*="altsonsez"]');
|
||||||
@@ -292,6 +326,7 @@ function pickSubPageFromMovieDetail(
|
|||||||
season?: number;
|
season?: number;
|
||||||
episode?: number;
|
episode?: number;
|
||||||
isPackage: boolean;
|
isPackage: boolean;
|
||||||
|
fps?: string | null;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
rows.each((_, row) => {
|
rows.each((_, row) => {
|
||||||
@@ -307,6 +342,7 @@ function pickSubPageFromMovieDetail(
|
|||||||
const isTr = $(row).find('.flagtr').length > 0;
|
const isTr = $(row).find('.flagtr').length > 0;
|
||||||
const indirmeRaw = ($(row).find('.alindirme').text() || '').replace(/\./g, '').replace(/,/g, '').trim();
|
const indirmeRaw = ($(row).find('.alindirme').text() || '').replace(/\./g, '').replace(/,/g, '').trim();
|
||||||
const downloadCount = Number(indirmeRaw.replace(/[^\d]/g, '')) || 0;
|
const downloadCount = Number(indirmeRaw.replace(/[^\d]/g, '')) || 0;
|
||||||
|
const fps = normalizeFpsText(($(row).find('.alfps').text() || '').trim());
|
||||||
const { season, episode, isPackage } = parseSeasonEpisodeFromRow($, row);
|
const { season, episode, isPackage } = parseSeasonEpisodeFromRow($, row);
|
||||||
|
|
||||||
if (params.type === 'tv') {
|
if (params.type === 'tv') {
|
||||||
@@ -341,7 +377,8 @@ function pickSubPageFromMovieDetail(
|
|||||||
downloadCount,
|
downloadCount,
|
||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
isPackage
|
isPackage,
|
||||||
|
fps
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -366,28 +403,39 @@ function pickSubPageFromMovieDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!wantedRelease || forcedStrategy === 'package_fallback') {
|
if (wantedRelease && forcedStrategy !== 'package_fallback') {
|
||||||
const picked = selectedPool.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
const exact = selectedPool
|
||||||
if (forcedStrategy === 'package_fallback') {
|
.filter((c) => c.releaseExact)
|
||||||
return { picked: { ...picked, strategy: 'package_fallback', isPackage: true } };
|
.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
||||||
|
if (exact) {
|
||||||
|
return { picked: { ...exact, strategy: 'exact' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = selectedPool
|
||||||
|
.filter((c) => c.releaseTokenHits > 0)
|
||||||
|
.sort((a, b) => b.releaseTokenHits - a.releaseTokenHits || b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
||||||
|
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' } };
|
return { picked: { ...picked, strategy: 'default' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const exact = selectedPool
|
|
||||||
.filter((c) => c.releaseExact)
|
|
||||||
.sort((a, b) => b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
|
||||||
if (exact) {
|
|
||||||
return { picked: { ...exact, strategy: 'exact' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = selectedPool
|
|
||||||
.filter((c) => c.releaseTokenHits > 0)
|
|
||||||
.sort((a, b) => b.releaseTokenHits - a.releaseTokenHits || b.score - a.score || b.downloadCount - a.downloadCount)[0];
|
|
||||||
if (token) {
|
|
||||||
return { picked: { ...token, strategy: 'token' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.type === 'tv') {
|
if (params.type === 'tv') {
|
||||||
// TV'de once bolum dogrulugu, sonra release gelir. Release bulunamasa da en iyi bolum satirini kullan.
|
// TV'de once bolum dogrulugu, sonra release gelir. Release bulunamasa da en iyi bolum satirini kullan.
|
||||||
const tvFallback = selectedPool
|
const tvFallback = selectedPool
|
||||||
@@ -502,6 +550,12 @@ export async function searchTurkceAltyaziReal(params: SearchParams): Promise<Rea
|
|||||||
subUrl: pickedSub.subUrl,
|
subUrl: pickedSub.subUrl,
|
||||||
releaseHints: pickedSub.releaseHints,
|
releaseHints: pickedSub.releaseHints,
|
||||||
strategy: pickedSub.strategy,
|
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
|
isPackage: pickedSub.isPackage === true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ import { promisify } from 'node:util';
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
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> {
|
export async function analyzeWithFfprobe(path: string): Promise<any> {
|
||||||
const { stdout } = await execFileAsync('ffprobe', [
|
const { stdout } = await execFileAsync('ffprobe', [
|
||||||
'-v',
|
'-v',
|
||||||
@@ -25,7 +44,8 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
|
|||||||
codec_name: video.codec_name,
|
codec_name: video.codec_name,
|
||||||
width: video.width,
|
width: video.width,
|
||||||
height: video.height,
|
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,
|
: null,
|
||||||
audio,
|
audio,
|
||||||
@@ -39,7 +59,7 @@ export async function analyzeWithFfprobe(path: string): Promise<any> {
|
|||||||
|
|
||||||
export function fallbackMediaInfo(): any {
|
export function fallbackMediaInfo(): any {
|
||||||
return {
|
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' }],
|
audio: [{ codec_name: 'unknown', channels: 2, language: 'und' }],
|
||||||
format: { duration: '0', bit_rate: '0', format_name: 'matroska' }
|
format: { duration: '0', bit_rate: '0', format_name: 'matroska' }
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user