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
+66
View File
@@ -0,0 +1,66 @@
// Episode count per season from Cinemeta.
// Ported from Infinite-streams (src/lib/cinemeta.ts getEpisodesPerSeason).
const CINEMETA_BASE = 'https://v3-cinemeta.strem.io';
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 12000;
const episodeCountCache = new Map();
async function getEpisodesPerSeason(imdbId) {
const cacheKey = `eps:${imdbId}`;
const cached = episodeCountCache.get(cacheKey);
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
return cached.data;
}
try {
const url = `${CINEMETA_BASE}/meta/series/${imdbId}.json`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Stremio Addon' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
episodeCountCache.set(cacheKey, { data: [], ts: Date.now() });
return [];
}
const json = await res.json();
const videos = (json.meta && json.meta.videos) || [];
if (videos.length === 0) {
episodeCountCache.set(cacheKey, { data: [], ts: Date.now() });
return [];
}
const seasonMaxEp = new Map();
for (const v of videos) {
if (!v.season || v.season <= 0) continue;
const ep = v.number || v.episode || 0;
if (ep > 0) {
const prev = seasonMaxEp.get(v.season) || 0;
seasonMaxEp.set(v.season, Math.max(prev, ep));
}
}
if (seasonMaxEp.size === 0) {
episodeCountCache.set(cacheKey, { data: [], ts: Date.now() });
return [];
}
const maxSeason = Math.max(...seasonMaxEp.keys());
const result = [];
for (let s = 1; s <= maxSeason; s++) {
result.push(seasonMaxEp.get(s) || 0);
}
episodeCountCache.set(cacheKey, { data: result, ts: Date.now() });
return result;
} catch (err) {
console.error(`[Cinemeta] getEpisodesPerSeason error: ${err.message}`);
episodeCountCache.set(cacheKey, { data: [], ts: Date.now() });
return [];
}
}
module.exports = { getEpisodesPerSeason };
+6 -1
View File
@@ -114,6 +114,8 @@ function normalizeConfig(base) {
// Proxy features removed; always use direct connections
if (cfg.disableUrlValidation === undefined) cfg.disableUrlValidation = false;
if (cfg.disable4khdhubUrlValidation === undefined) cfg.disable4khdhubUrlValidation = false;
// TMDB ID used by the dashboard's "Run Provider Functional Checks" feature
if (!cfg.providerCheckTmdbId) cfg.providerCheckTmdbId = '278';
return cfg;
}
@@ -144,6 +146,8 @@ function applyConfigToEnv(cfg){
process.env.DISABLE_URL_VALIDATION = cfg.disableUrlValidation ? 'true':'false';
process.env.DISABLE_4KHDHUB_URL_VALIDATION = cfg.disable4khdhubUrlValidation ? 'true':'false';
process.env.ENABLE_PROXY = cfg.enableProxy ? 'true':'false';
if (cfg.providerCheckTmdbId) process.env.PROVIDER_CHECK_TMDB_ID = String(cfg.providerCheckTmdbId);
else delete process.env.PROVIDER_CHECK_TMDB_ID;
// Showbox specific
if (cfg.showboxCacheDir) process.env.SHOWBOX_CACHE_DIR = cfg.showboxCacheDir; else delete process.env.SHOWBOX_CACHE_DIR;
// Proxy settings removed; ensure legacy envs are cleared
@@ -174,7 +178,8 @@ function loadConfig() {
// Proxy/env paths removed; always direct connections
disableUrlValidation: process.env.DISABLE_URL_VALIDATION,
disable4khdhubUrlValidation: process.env.DISABLE_4KHDHUB_URL_VALIDATION,
enableProxy: process.env.ENABLE_PROXY
enableProxy: process.env.ENABLE_PROXY,
providerCheckTmdbId: process.env.PROVIDER_CHECK_TMDB_ID || null
};
// Dynamic provider enable flags from env
+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 };
+4 -3
View File
@@ -1,5 +1,5 @@
const fetch = (...args) => import('node-fetch').then(m => m.default(...args));
const { config } = require('./config');
const { getTmdbApiKey } = require('./tmdbKey');
const cache = {
imdbByTmdb: new Map(),
@@ -9,9 +9,10 @@ const TTL_MS = 6 * 60 * 60 * 1000;
function isFresh(entry) { return entry && (Date.now() - entry.ts) < TTL_MS; }
async function tmdbFetchJson(url) {
if (!config.tmdbApiKey) throw new Error('TMDB_API_KEY missing');
const apiKey = getTmdbApiKey();
if (!apiKey) throw new Error('TMDB_API_KEY missing');
const sep = url.includes('?') ? '&' : '?';
const full = `${url}${sep}api_key=${config.tmdbApiKey}`;
const full = `${url}${sep}api_key=${apiKey}`;
const res = await fetch(full, { timeout: 15000 });
if (!res.ok) throw new Error(`TMDB request failed ${res.status}`);
return res.json();
+72
View File
@@ -0,0 +1,72 @@
// Resolve the IMDB ID TMDB associates with a given matched title + year.
// Ported from Infinite-streams (src/lib/tmdb-verify.ts). Uses the repo's
// configured TMDB key. Returns null when inconclusive — callers must treat
// null as "pass through", never reject on it.
const { getTmdbApiKey } = require('./tmdbKey');
const { findBestMatch } = require('./titleMatch');
const TMDB_BASE = 'https://api.themoviedb.org/3';
const cache = new Map();
const TTL = 24 * 60 * 60 * 1000;
async function tmdbTitleToImdbId(title, year, type) {
const key = `${title.toLowerCase()}|${year || ''}|${type}`;
const hit = cache.get(key);
if (hit && Date.now() - hit.ts < TTL) return hit.imdbId;
const set = (v) => {
cache.set(key, { imdbId: v, ts: Date.now() });
return v;
};
const apiKey = getTmdbApiKey();
if (!apiKey) return set(null);
try {
const tmdbType = type === 'series' ? 'tv' : 'movie';
const yearParam = year ? `&${type === 'series' ? 'first_air_date_year' : 'year'}=${year}` : '';
const searchUrl =
`${TMDB_BASE}/search/${tmdbType}` +
`?api_key=${apiKey}&query=${encodeURIComponent(title)}${yearParam}&page=1`;
const searchRes = await fetch(searchUrl, { signal: AbortSignal.timeout(8000) });
if (!searchRes.ok) return set(null);
const searchData = await searchRes.json();
const results = searchData.results || [];
if (results.length === 0) return set(null);
const candidates = results.slice(0, 10).map((r) => {
const dateStr = r.first_air_date || r.release_date;
const candidateYear = dateStr ? parseInt(dateStr.slice(0, 4), 10) : undefined;
return {
title: r.name || r.title || '',
year: Number.isFinite(candidateYear) ? candidateYear : undefined,
type,
raw: r,
};
});
const { best } = findBestMatch(
{ title, year, type },
candidates,
{ provider: 'tmdb-verify', query: title, quiet: true },
);
if (!best) return set(null);
const topId = best.raw.id;
const extUrl = `${TMDB_BASE}/${tmdbType}/${topId}/external_ids?api_key=${apiKey}`;
const extRes = await fetch(extUrl, { signal: AbortSignal.timeout(8000) });
if (!extRes.ok) return set(null);
const extData = await extRes.json();
return set(extData.imdb_id || null);
} catch (err) {
console.warn(`[tmdb-verify] lookup failed: ${err.message}`);
return set(null);
}
}
module.exports = { tmdbTitleToImdbId };
+5 -3
View File
@@ -16,6 +16,8 @@
"enableUhdmoviesProvider": false,
"enableVidlinkProvider": true,
"enableVideasyProvider": true,
"enableLordflixProvider": true,
"enableNotorrentProvider": true
}
"enableLordflixProvider": false,
"enableNotorrentProvider": false,
"enableDahmermoviesProvider": true,
"port": 8787
}