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:
Inside4ndroid
2026-08-19 23:35:13 +01:00
parent 01f06f807e
commit 2c9a2cabbf
30 changed files with 2791 additions and 516 deletions
+264
View File
@@ -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 };