Providers
+
+
+
+
+
+
+
diff --git a/public/index.js b/public/index.js
index fee3c2e..d42760c 100644
--- a/public/index.js
+++ b/public/index.js
@@ -337,7 +337,8 @@ async function clearAll(){
enablePStreamApi: true,
disableUrlValidation: false,
disable4khdhubUrlValidation: false,
- showboxCacheDir: null
+ showboxCacheDir: null,
+ providerCheckTmdbId: null
};
try {
const r = await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
@@ -411,6 +412,8 @@ async function loadServerStatus(){
const j = await r.json();
if(!j.success) throw new Error('Status error');
metricsEl.textContent = JSON.stringify(j.metrics, null, 2);
+ const checkTmdbEl = document.getElementById('providerCheckTmdbId');
+ if(checkTmdbEl) checkTmdbEl.value = j.providerCheckTmdbId || '278';
if(endpointsEl) endpointsEl.textContent = (j.endpoints||[]).join('\n');
if(provEnhanced){
provEnhanced.innerHTML = '';
@@ -438,7 +441,8 @@ async function loadServerStatus(){
}
}
async function runProviderFunctionalChecks(){
- const targetTmdb = '550'; // TMDB ID provided
+ const checkTmdbEl = document.getElementById('providerCheckTmdbId');
+ const targetTmdb = (checkTmdbEl && checkTmdbEl.value.trim()) || '278'; // TMDB ID (editable on status page)
const provEnhanced = document.getElementById('statusProvidersEnhanced');
const summary = document.getElementById('providerCheckSummary');
if(!provEnhanced) return;
@@ -478,6 +482,18 @@ async function runProviderFunctionalChecks(){
}
summary.textContent = `Checks complete: ${passed}/${rows.length} passed`;
}
+async function saveProviderCheckTmdbId(){
+ const input = document.getElementById('providerCheckTmdbId');
+ const val = (input ? input.value : '').trim();
+ if(!val){ setStatus('Enter a TMDB ID to save', true); return; }
+ if(!/^\d+$/.test(val)){ setStatus('TMDB ID must be numeric', true); return; }
+ try {
+ const r = await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ providerCheckTmdbId: val })});
+ const js = await r.json();
+ if(!js.success) throw new Error(js.error||'Save failed');
+ setStatus('Provider check TMDB ID saved: '+val);
+ } catch(e){ setStatus(e.message, true); }
+}
document.addEventListener('DOMContentLoaded', ()=> {
initTheme();
attachCoreListeners();
@@ -533,6 +549,8 @@ document.addEventListener('DOMContentLoaded', ()=> {
if(rsb) rsb.addEventListener('click', loadServerStatus);
const rpc = document.getElementById('runProviderChecksBtn');
if(rpc) rpc.addEventListener('click', runProviderFunctionalChecks);
+ const spc = document.getElementById('saveProviderCheckTmdbBtn');
+ if(spc) spc.addEventListener('click', saveProviderCheckTmdbId);
});
function toggleSidebar(force){
const sb = document.getElementById('sidebar');
diff --git a/restart.trigger b/restart.trigger
index 964ce21..8250c93 100644
--- a/restart.trigger
+++ b/restart.trigger
@@ -1 +1 @@
-1778930133625
\ No newline at end of file
+1787175846578
\ No newline at end of file
diff --git a/utils/cinemetaEpisodes.js b/utils/cinemetaEpisodes.js
new file mode 100644
index 0000000..51d8960
--- /dev/null
+++ b/utils/cinemetaEpisodes.js
@@ -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 };
\ No newline at end of file
diff --git a/utils/config.js b/utils/config.js
index e2052e9..60ee321 100644
--- a/utils/config.js
+++ b/utils/config.js
@@ -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
diff --git a/utils/titleMatch.js b/utils/titleMatch.js
new file mode 100644
index 0000000..16887d7
--- /dev/null
+++ b/utils/titleMatch.js
@@ -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 };
\ No newline at end of file
diff --git a/utils/tmdb.js b/utils/tmdb.js
index db1f784..8b47cb4 100644
--- a/utils/tmdb.js
+++ b/utils/tmdb.js
@@ -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();
diff --git a/utils/tmdbTitleToImdb.js b/utils/tmdbTitleToImdb.js
new file mode 100644
index 0000000..01366ef
--- /dev/null
+++ b/utils/tmdbTitleToImdb.js
@@ -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 };
\ No newline at end of file
diff --git a/utils/user-config.json b/utils/user-config.json
index 81fdd12..d62967c 100644
--- a/utils/user-config.json
+++ b/utils/user-config.json
@@ -16,6 +16,8 @@
"enableUhdmoviesProvider": false,
"enableVidlinkProvider": true,
"enableVideasyProvider": true,
- "enableLordflixProvider": true,
- "enableNotorrentProvider": true
-}
+ "enableLordflixProvider": false,
+ "enableNotorrentProvider": false,
+ "enableDahmermoviesProvider": true,
+ "port": 8787
+}
\ No newline at end of file