mirror of
https://github.com/Inside4ndroid/TMDB-Embed-API.git
synced 2026-10-22 17:45:11 +00:00
- 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.
334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
const { createDecipheriv } = require('crypto');
|
|
const { getDetails, resolveImdbId } = require('../utils/tmdb');
|
|
const { getEpisodesPerSeason } = require('../utils/cinemetaEpisodes');
|
|
const { findBestMatch } = require('../utils/titleMatch');
|
|
const { tmdbTitleToImdbId } = require('../utils/tmdbTitleToImdb');
|
|
|
|
const MAIN_URL = 'https://api3.devcorp.me';
|
|
const AES_KEY = Buffer.from('im72charPasswordofdInitVectorStm', 'utf8');
|
|
const AES_IV = Buffer.from('im72charPassword', 'utf8');
|
|
const FETCH_HEADERS = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
'Referer': 'https://onetouchtv.xyz/'
|
|
};
|
|
const FETCH_TIMEOUT_MS = 15000;
|
|
const FETCH_RETRIES = 2;
|
|
const FETCH_RETRY_DELAY_MS = 400;
|
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
const cache = new Map();
|
|
|
|
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
|
|
|
function fromCache(key) {
|
|
const entry = cache.get(key);
|
|
if (!entry) return null;
|
|
if (Date.now() - entry.ts > CACHE_TTL_MS) {
|
|
cache.delete(key);
|
|
return null;
|
|
}
|
|
return entry.data;
|
|
}
|
|
|
|
function toCache(key, data) {
|
|
cache.set(key, { data, ts: Date.now() });
|
|
}
|
|
|
|
function decrypt(encoded) {
|
|
let s = encoded
|
|
.replace(/-_\./g, '/')
|
|
.replace(/@/g, '+')
|
|
.replace(/\s+/g, '');
|
|
const pad = s.length % 4;
|
|
if (pad !== 0) s += '='.repeat(4 - pad);
|
|
|
|
const buf = Buffer.from(s, 'base64');
|
|
const decipher = createDecipheriv('aes-256-cbc', AES_KEY, AES_IV);
|
|
const dec = Buffer.concat([decipher.update(buf), decipher.final()]);
|
|
return JSON.parse(dec.toString('utf8'));
|
|
}
|
|
|
|
async function fetchEncryptedOnce(url) {
|
|
const res = await fetch(url, {
|
|
headers: FETCH_HEADERS,
|
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
|
|
const text = await res.text();
|
|
const parsed = decrypt(text);
|
|
return parsed.result;
|
|
}
|
|
|
|
async function fetchEncrypted(path, cacheTtlMs = CACHE_TTL_MS) {
|
|
const url = path.startsWith('https://') ? path : `${MAIN_URL}${path}`;
|
|
|
|
if (cacheTtlMs > 0) {
|
|
const cached = fromCache(url);
|
|
if (cached !== null) return cached;
|
|
}
|
|
|
|
let lastErr;
|
|
for (let attempt = 0; attempt <= FETCH_RETRIES; attempt++) {
|
|
try {
|
|
const data = await fetchEncryptedOnce(url);
|
|
if (cacheTtlMs > 0) toCache(url, data);
|
|
return data;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
if (attempt < FETCH_RETRIES) {
|
|
console.warn(`[OneTouchTV] fetch failed (attempt ${attempt + 1}): ${err.message}`);
|
|
await sleep(FETCH_RETRY_DELAY_MS * (attempt + 1));
|
|
}
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
const TYPE_MAP = {
|
|
movie: new Set(['movie']),
|
|
series: new Set(['drama', 'variety', 'anime'])
|
|
};
|
|
|
|
function isTypeCompatible(stremioType, onetouchType) {
|
|
const allowed = TYPE_MAP[stremioType];
|
|
if (!allowed) return true;
|
|
return allowed.has(onetouchType.toLowerCase());
|
|
}
|
|
|
|
function findBestOttMatch(results, title, year, stremioType, targetSeason) {
|
|
if (results.length === 0) return null;
|
|
|
|
const filtered = results.filter((r) => isTypeCompatible(stremioType, r.type));
|
|
|
|
if (filtered.length === 0) return null;
|
|
|
|
const candidates = filtered.map((r) => ({
|
|
title: r.title,
|
|
year: parseInt(r.year, 10) || undefined,
|
|
type: stremioType,
|
|
season: targetSeason ?? undefined,
|
|
raw: r
|
|
}));
|
|
|
|
const { best } = findBestMatch(
|
|
{
|
|
title,
|
|
year: year ?? undefined,
|
|
type: stremioType,
|
|
season: targetSeason ?? undefined
|
|
},
|
|
candidates,
|
|
{ provider: 'OneTouchTV' }
|
|
);
|
|
|
|
return best ? best.raw : null;
|
|
}
|
|
|
|
async function searchContent(keyword) {
|
|
const path = `/vod/search?keyword=${encodeURIComponent(keyword)}`;
|
|
try {
|
|
const results = await fetchEncrypted(path);
|
|
return Array.isArray(results) ? results : [];
|
|
} catch (err) {
|
|
console.error(`[OneTouchTV] search failed for "${keyword}": ${err.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function getDetail(id) {
|
|
try {
|
|
const result = await fetchEncrypted(`/vod/${id}/detail`);
|
|
return result ?? null;
|
|
} catch (err) {
|
|
console.error(`[OneTouchTV] detail failed for ${id}: ${err.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getEpisodeStreams(id, playId) {
|
|
try {
|
|
const result = await fetchEncrypted(`/vod/${id}/episode/${playId}`, 0);
|
|
return result ?? null;
|
|
} catch (err) {
|
|
console.error(`[OneTouchTV] episode fetch failed ${id}/${playId}: ${err.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveQuality(quality) {
|
|
if (!quality) return 'Unknown';
|
|
const q = quality.toLowerCase();
|
|
if (q === 'auto') return 'Auto';
|
|
if (q.includes('1080')) return '1080p';
|
|
if (q.includes('720')) return '720p';
|
|
if (q.includes('480')) return '480p';
|
|
if (q.includes('360')) return '360p';
|
|
return quality;
|
|
}
|
|
|
|
async function resolveAbsoluteEpisode(imdbId, season, episode) {
|
|
if (season <= 1) return episode;
|
|
|
|
const episodesPerSeason = await getEpisodesPerSeason(imdbId);
|
|
if (episodesPerSeason.length < season - 1) return null;
|
|
|
|
let offset = 0;
|
|
for (let s = 0; s < season - 1; s++) {
|
|
const count = episodesPerSeason[s] ?? 0;
|
|
if (count === 0) return null;
|
|
offset += count;
|
|
}
|
|
return offset + episode;
|
|
}
|
|
|
|
async function getOnetouchtvStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
|
console.log(`[OneTouchTV] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
|
|
|
try {
|
|
const type = mediaType === 'tv' ? 'series' : 'movie';
|
|
const tmdbType = mediaType === 'tv' ? 'tv' : 'movie';
|
|
const details = await getDetails(tmdbType, tmdbId);
|
|
const title = (details && (details.title || details.name)) || '';
|
|
const year = (details && (details.release_date || details.first_air_date || '').slice(0, 4)) || null;
|
|
if (!title) {
|
|
console.log(`[OneTouchTV] No TMDB title resolved for ${tmdbId}`);
|
|
return [];
|
|
}
|
|
const imdbId = await resolveImdbId(tmdbType, tmdbId);
|
|
|
|
const season = seasonNum || 1;
|
|
const episode = episodeNum || 1;
|
|
|
|
const searchKeywords = [title];
|
|
if (type === 'series' && season > 1) {
|
|
searchKeywords.unshift(`${title} Season ${season}`);
|
|
}
|
|
|
|
let matchResult = null;
|
|
for (const keyword of searchKeywords) {
|
|
const results = await searchContent(keyword);
|
|
const searchYear = keyword !== title && season > 1 ? null : year;
|
|
const match = findBestOttMatch(results, keyword, searchYear, type, season);
|
|
if (match) {
|
|
matchResult = match;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!matchResult) {
|
|
console.log(`[OneTouchTV] "${title}" not found`);
|
|
return [];
|
|
}
|
|
|
|
if (imdbId && imdbId.startsWith('tt')) {
|
|
const matchedYear = matchResult.year ? parseInt(matchResult.year, 10) : undefined;
|
|
const resolvedId = await tmdbTitleToImdbId(
|
|
matchResult.title,
|
|
Number.isFinite(matchedYear) ? matchedYear : undefined,
|
|
type
|
|
).catch(() => null);
|
|
if (resolvedId && resolvedId !== imdbId) {
|
|
console.log(`[OneTouchTV] IMDB ID mismatch — rejecting match: expected ${imdbId}, resolved ${resolvedId}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
const detail = await getDetail(matchResult.id);
|
|
if (!detail || !detail.episodes || detail.episodes.length === 0) {
|
|
console.log(`[OneTouchTV] no episodes in detail for "${matchResult.title}"`);
|
|
return [];
|
|
}
|
|
|
|
let targetEpisode = null;
|
|
|
|
if (type === 'movie') {
|
|
targetEpisode = detail.episodes[0] ?? null;
|
|
} else {
|
|
const targetSeasonInTitle = new RegExp(`\\bseason\\s+${season}\\b`, 'i');
|
|
const anySeasonInTitle = /\bseason\s+(\d+)\b/i;
|
|
const matchedSeasonNum = anySeasonInTitle.exec(matchResult.title);
|
|
const entryIsForTargetSeason =
|
|
season === 1 || targetSeasonInTitle.test(matchResult.title);
|
|
|
|
if (
|
|
season > 1 &&
|
|
matchedSeasonNum !== null &&
|
|
parseInt(matchedSeasonNum[1], 10) !== season
|
|
) {
|
|
console.warn(`[OneTouchTV] matched title names a different season — aborting`);
|
|
return [];
|
|
}
|
|
|
|
if (entryIsForTargetSeason) {
|
|
targetEpisode = detail.episodes.find((e) => parseInt(e.episode, 10) === episode) ?? null;
|
|
} else {
|
|
let absEp = null;
|
|
if (imdbId) {
|
|
absEp = await resolveAbsoluteEpisode(imdbId, season, episode);
|
|
}
|
|
|
|
if (absEp !== null) {
|
|
targetEpisode = detail.episodes.find((e) => parseInt(e.episode, 10) === absEp) ?? null;
|
|
}
|
|
|
|
if (!targetEpisode && season === 1) {
|
|
targetEpisode = detail.episodes.find((e) => parseInt(e.episode, 10) === episode) ?? null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!targetEpisode) {
|
|
console.log(`[OneTouchTV] episode not found (S${season}E${episode})`);
|
|
return [];
|
|
}
|
|
|
|
const streamData = await getEpisodeStreams(matchResult.id, targetEpisode.playId);
|
|
if (!streamData) return [];
|
|
|
|
const sources = streamData.sources ?? [];
|
|
if (sources.length === 0) {
|
|
console.log(`[OneTouchTV] no sources in episode for "${matchResult.title}"`);
|
|
return [];
|
|
}
|
|
|
|
const subtitles = [];
|
|
for (const track of streamData.track ?? []) {
|
|
if (track.file && track.name) {
|
|
subtitles.push({ url: track.file, lang: track.name });
|
|
}
|
|
}
|
|
|
|
const out = [];
|
|
for (const src of sources) {
|
|
if (!src.url) continue;
|
|
const quality = resolveQuality(src.quality);
|
|
const sourceName = src.name ? ` | ${src.name}` : '';
|
|
out.push({
|
|
name: `OneTouchTV${sourceName}`,
|
|
title: `${matchResult.title}\n${quality} · ${(src.type ?? 'HLS').toUpperCase()}`,
|
|
url: src.url,
|
|
quality,
|
|
provider: 'OneTouchTV',
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0',
|
|
'Referer': `${MAIN_URL}/`
|
|
},
|
|
...(subtitles.length ? { subtitles } : {})
|
|
});
|
|
}
|
|
|
|
const seen = new Set();
|
|
const streams = out.filter((s) => {
|
|
if (seen.has(s.url)) return false;
|
|
seen.add(s.url);
|
|
return true;
|
|
});
|
|
console.log(`[OneTouchTV] Got ${streams.length} stream(s) for "${matchResult.title}"`);
|
|
return streams;
|
|
} catch (err) {
|
|
console.error(`[OneTouchTV] Error: ${err.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
module.exports = { getOnetouchtvStreams }; |