mirror of
https://github.com/Inside4ndroid/TMDB-Embed-API.git
synced 2026-10-22 09:35:11 +00:00
Add new providers for streaming services and utility functions
- Implement StreamFlix provider for fetching movie and episode streams. - Add VaPlayer provider to retrieve streaming URLs for movies and TV shows. - Introduce ZXCStreams provider for accessing various streaming links. - Create utility functions for fetching episode counts from Cinemeta. - Develop a title matching system for improved search results. - Implement TMDB title to IMDB ID resolution for better integration with TMDB API.
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// Universal search-result matching system.
|
||||
// Ported from Infinite-streams (src/utils/match.ts) for title-search providers.
|
||||
|
||||
const QUALITY_TAGS =
|
||||
/\b(4k|2160p|1080p|720p|480p|360p|hd|fhd|uhd|hdr|web[-\s]?dl|webrip|web|bluray|blu-ray|brrip|bdrip|dvdrip|hdrip|hdtv|hdcam|cam|dual audio|multi audio|dual[-\s]?audio|multi[-\s]?audio|x264|x265|hevc|h264|h265|10bit|esub|esubs|msubs|amzn|nf|hin|eng|hindi|english|dubbed|dub|subbed|sub|season\s*\d+|s\d{1,2}|complete|full)\b/gi;
|
||||
|
||||
const STOP_WORDS = new Set(['the', 'a', 'an', 'of', 'in', 'at', 'to', 'with', 'and', 'or', 'for', 'on']);
|
||||
|
||||
function baseNormalize(s) {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/([a-z])\./g, '$1')
|
||||
.replace(/[^\w\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeStripQuality(s) {
|
||||
return baseNormalize(String(s).replace(QUALITY_TAGS, ' '));
|
||||
}
|
||||
|
||||
function tokenize(s) {
|
||||
return baseNormalize(s).split(' ').filter(Boolean);
|
||||
}
|
||||
|
||||
function significantTokens(s) {
|
||||
return tokenize(s).filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
||||
}
|
||||
|
||||
function bigrams(s) {
|
||||
const clean = baseNormalize(s).replace(/\s+/g, '');
|
||||
const out = [];
|
||||
for (let i = 0; i < clean.length - 1; i++) out.push(clean.slice(i, i + 2));
|
||||
return out;
|
||||
}
|
||||
|
||||
function diceCoefficient(a, b) {
|
||||
const ba = bigrams(a);
|
||||
const bb = bigrams(b);
|
||||
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length ? 1 : 0;
|
||||
const counts = new Map();
|
||||
for (const g of ba) counts.set(g, (counts.get(g) || 0) + 1);
|
||||
let matches = 0;
|
||||
for (const g of bb) {
|
||||
const c = counts.get(g) || 0;
|
||||
if (c > 0) {
|
||||
matches++;
|
||||
counts.set(g, c - 1);
|
||||
}
|
||||
}
|
||||
return (2 * matches) / (ba.length + bb.length);
|
||||
}
|
||||
|
||||
function scoreTitleText(query, candidate) {
|
||||
if (!query || !candidate) {
|
||||
return { exact: 0, normalized: 0, fuzzy: 0, wholeWord: 0, startsWith: 0, score: 0 };
|
||||
}
|
||||
const exact = String(query).trim().toLowerCase() === String(candidate).trim().toLowerCase() ? 1 : 0;
|
||||
|
||||
const nq = normalizeStripQuality(query);
|
||||
const nc = normalizeStripQuality(candidate);
|
||||
let normalized = nq === nc ? 1 : 0;
|
||||
if (!normalized && nq.replace(/\s+/g, '') === nc.replace(/\s+/g, '')) normalized = 0.92;
|
||||
|
||||
const tokenJaccardOf = () => {
|
||||
const qa = new Set(significantTokens(nq));
|
||||
const ca = new Set(significantTokens(nc));
|
||||
if (qa.size === 0 || ca.size === 0) return 0;
|
||||
let intersection = 0;
|
||||
for (const t of qa) if (ca.has(t)) intersection++;
|
||||
const union = qa.size + ca.size - intersection;
|
||||
return union === 0 ? 0 : intersection / union;
|
||||
};
|
||||
const fuzzy = 0.5 * tokenJaccardOf() + 0.5 * diceCoefficient(nq, nc);
|
||||
|
||||
const qSig = significantTokens(nq);
|
||||
const cSigSet = new Set(significantTokens(nc));
|
||||
const wholeWord = qSig.length === 0 ? 0 : qSig.filter((w) => cSigSet.has(w)).length / qSig.length;
|
||||
|
||||
let startsWith = 0;
|
||||
if (nc.startsWith(nq) || nq.startsWith(nc)) startsWith = 1;
|
||||
else {
|
||||
const qFirst = nq.split(' ')[0];
|
||||
const cFirst = nc.split(' ')[0];
|
||||
if (qFirst && cFirst && qFirst === cFirst) startsWith = 0.5;
|
||||
}
|
||||
|
||||
const score = exact * 0.35 + normalized * 0.25 + fuzzy * 0.2 + wholeWord * 0.1 + startsWith * 0.1;
|
||||
return { exact, normalized, fuzzy, wholeWord, startsWith, score: Math.min(1, score) };
|
||||
}
|
||||
|
||||
function bestTitleScore(query, candidate) {
|
||||
const attempts = [{ label: 'title', text: candidate.title }];
|
||||
if (candidate.originalTitle) attempts.push({ label: 'originalTitle', text: candidate.originalTitle });
|
||||
for (const alias of candidate.aliases || []) attempts.push({ label: `alias:${alias}`, text: alias });
|
||||
|
||||
const queryTitles = [query.title, query.originalTitle, ...(query.aliases || [])].filter((t) => !!t);
|
||||
|
||||
let best = null;
|
||||
for (const q of queryTitles) {
|
||||
for (const a of attempts) {
|
||||
const breakdown = scoreTitleText(q, a.text);
|
||||
if (!best || breakdown.score > best.score) {
|
||||
best = { score: breakdown.score, breakdown, matchedOn: a.label };
|
||||
}
|
||||
}
|
||||
}
|
||||
return best || { score: 0, breakdown: scoreTitleText('', ''), matchedOn: 'title' };
|
||||
}
|
||||
|
||||
function scoreYear(query, candidate) {
|
||||
if (!query || !candidate) return 0.5;
|
||||
const diff = Math.abs(query - candidate);
|
||||
if (diff === 0) return 1;
|
||||
if (diff === 1) return 0.6;
|
||||
if (diff === 2) return 0.3;
|
||||
return 0.05;
|
||||
}
|
||||
|
||||
function scoreType(query, candidate) {
|
||||
if (!query || !candidate) return 0.5;
|
||||
return query === candidate ? 1 : 0;
|
||||
}
|
||||
|
||||
function scoreSeasonEpisode(query, candidate) {
|
||||
if (query.type !== 'series') return 1;
|
||||
if (query.season == null || candidate.season == null) return 0.7;
|
||||
if (query.season !== candidate.season) return 0.15;
|
||||
if (query.episode == null || candidate.episode == null) return 1;
|
||||
return query.episode === candidate.episode ? 1 : 0.6;
|
||||
}
|
||||
|
||||
function round(n) {
|
||||
return Math.round(n * 1000) / 1000;
|
||||
}
|
||||
|
||||
function scoreCandidate(query, candidate) {
|
||||
const text = bestTitleScore(query, candidate);
|
||||
const year = scoreYear(query.year, candidate.year);
|
||||
const type = scoreType(query.type, candidate.type);
|
||||
const seasonEpisode = scoreSeasonEpisode(query, candidate);
|
||||
const score = text.score * 0.6 + year * 0.1 + type * 0.2 + seasonEpisode * 0.1;
|
||||
return {
|
||||
score: Math.min(1, Math.max(0, score)),
|
||||
matchedOn: text.matchedOn,
|
||||
breakdown: {
|
||||
textScore: round(text.score),
|
||||
exact: round(text.breakdown.exact),
|
||||
normalized: round(text.breakdown.normalized),
|
||||
fuzzy: round(text.breakdown.fuzzy),
|
||||
wholeWord: round(text.breakdown.wholeWord),
|
||||
startsWith: round(text.breakdown.startsWith),
|
||||
year: round(year),
|
||||
type: round(type),
|
||||
seasonEpisode: round(seasonEpisode),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD = 0.45;
|
||||
|
||||
function findBestMatch(query, candidates, options) {
|
||||
const threshold = options.threshold || DEFAULT_THRESHOLD;
|
||||
const ranked = candidates
|
||||
.map((candidate) => {
|
||||
const { score, breakdown, matchedOn } = scoreCandidate(query, candidate);
|
||||
return { candidate, score, breakdown, matchedOn };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (!options.quiet) {
|
||||
console.log(`[Match:${options.provider}] scored candidates`, {
|
||||
provider: options.provider,
|
||||
query: options.query || query.title,
|
||||
resultCount: candidates.length,
|
||||
candidates: ranked.map((r) => ({
|
||||
title: r.candidate.title,
|
||||
year: r.candidate.year,
|
||||
type: r.candidate.type,
|
||||
score: round(r.score),
|
||||
matchedOn: r.matchedOn,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const top = ranked[0];
|
||||
if (!top) {
|
||||
console.log(`[Match:${options.provider}] no candidates returned by search`);
|
||||
return { best: null, score: 0, breakdown: {}, matchedOn: '', reason: 'no candidates returned by search', ranked };
|
||||
}
|
||||
|
||||
const passed = top.score >= threshold;
|
||||
const reason = passed
|
||||
? `best candidate "${top.candidate.title}" scored ${round(top.score)} (>= threshold ${threshold}) via ${top.matchedOn}`
|
||||
: `best candidate "${top.candidate.title}" scored ${round(top.score)} which is below threshold ${threshold} — rejected as a likely mismatch`;
|
||||
|
||||
console.log(`[Match:${options.provider}] ${passed ? 'selected result' : 'rejected — no match above threshold'}`, {
|
||||
provider: options.provider,
|
||||
query: options.query || query.title,
|
||||
resultCount: candidates.length,
|
||||
selected: passed ? top.candidate.title : null,
|
||||
score: round(top.score),
|
||||
threshold,
|
||||
breakdown: top.breakdown,
|
||||
reason,
|
||||
});
|
||||
|
||||
if (!passed) {
|
||||
return { best: null, score: top.score, breakdown: top.breakdown, matchedOn: top.matchedOn, reason, ranked };
|
||||
}
|
||||
return { best: top.candidate, score: top.score, breakdown: top.breakdown, matchedOn: top.matchedOn, reason, ranked };
|
||||
}
|
||||
|
||||
async function findBestMatchWithRetry(query, variantTitles, search, options) {
|
||||
const threshold = options.threshold || DEFAULT_THRESHOLD;
|
||||
const tried = new Set();
|
||||
let bestSoFar = null;
|
||||
|
||||
for (const variant of variantTitles) {
|
||||
const key = String(variant).trim().toLowerCase();
|
||||
if (!key || tried.has(key)) continue;
|
||||
tried.add(key);
|
||||
|
||||
let candidates;
|
||||
try {
|
||||
candidates = await search(variant);
|
||||
} catch (err) {
|
||||
console.warn(`[Match:${options.provider}] retry search failed:`, err.message);
|
||||
continue;
|
||||
}
|
||||
if (!candidates || !candidates.length) continue;
|
||||
|
||||
const result = findBestMatch({ ...query, title: variant }, candidates, { ...options, query: variant });
|
||||
if (!bestSoFar || result.score > bestSoFar.score) bestSoFar = result;
|
||||
if (result.best && result.score >= threshold) {
|
||||
console.log(`[Match:${options.provider}] retry succeeded with variant "${variant}"`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestSoFar && bestSoFar.score > 0) {
|
||||
console.log(`[Match:${options.provider}] no variant cleared threshold — treating as not found (near-miss logged, not returned)`);
|
||||
} else {
|
||||
console.log(`[Match:${options.provider}] no results found across any title variant`);
|
||||
}
|
||||
|
||||
return {
|
||||
best: null,
|
||||
score: bestSoFar ? bestSoFar.score : 0,
|
||||
breakdown: bestSoFar ? bestSoFar.breakdown : {},
|
||||
matchedOn: bestSoFar ? bestSoFar.matchedOn : '',
|
||||
reason: bestSoFar
|
||||
? `best candidate across all variants scored ${round(bestSoFar.score)}, below threshold ${threshold} — rejected`
|
||||
: 'no results found across any title variant',
|
||||
ranked: bestSoFar ? bestSoFar.ranked : [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildRetryTitleVariants(opts) {
|
||||
const variants = [opts.title, opts.originalTitle, ...(opts.aliases || [])].filter((t) => !!t && String(t).trim().length > 0);
|
||||
return [...new Set(variants)];
|
||||
}
|
||||
|
||||
module.exports = { findBestMatch, findBestMatchWithRetry, buildRetryTitleVariants, scoreCandidate, normalizeStripQuality };
|
||||
Reference in New Issue
Block a user