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,417 @@
|
||||
const { createDecipheriv } = require('crypto');
|
||||
const { getDetails } = require('../utils/tmdb');
|
||||
|
||||
const CASTLE_BASE = 'https://api.hlowb.com';
|
||||
const PKG = 'com.external.castle';
|
||||
const CHANNEL = 'IndiaA';
|
||||
const CLIENT = '1';
|
||||
const LANG = 'en-US';
|
||||
|
||||
const API_HEADERS = {
|
||||
'User-Agent': 'okhttp/4.9.3',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Connection': 'Keep-Alive',
|
||||
'Referer': CASTLE_BASE
|
||||
};
|
||||
|
||||
const PLAYBACK_HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||
'Accept': 'video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'identity',
|
||||
'Connection': 'keep-alive',
|
||||
'Sec-Fetch-Dest': 'video',
|
||||
'Sec-Fetch-Mode': 'no-cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'DNT': '1'
|
||||
};
|
||||
|
||||
function castleSafeParse(text) {
|
||||
const safe = text.replace(/([:{[,]\s*)(\d{16,})/g, '$1"$2"');
|
||||
return JSON.parse(safe);
|
||||
}
|
||||
|
||||
function deriveKey(securityKey) {
|
||||
const keyBytes = Buffer.from(securityKey, 'base64');
|
||||
const suffix = Buffer.from('T!BgJB', 'utf8');
|
||||
const combined = Buffer.concat([keyBytes, suffix]);
|
||||
if (combined.length < 16) {
|
||||
return Buffer.concat([combined, Buffer.alloc(16 - combined.length, 0)]);
|
||||
}
|
||||
return combined.subarray(0, 16);
|
||||
}
|
||||
|
||||
function decryptCastle(cipherText, securityKey) {
|
||||
const key = deriveKey(securityKey);
|
||||
const decipher = createDecipheriv('aes-128-cbc', key, key);
|
||||
decipher.setAutoPadding(true);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(cipherText, 'base64')),
|
||||
decipher.final()
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
|
||||
async function castleRequest(url, options = {}) {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...API_HEADERS, ...(options.headers || {}) },
|
||||
signal: AbortSignal.timeout(12000)
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`[CastleTV] HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function extractCipher(res) {
|
||||
const text = await res.text();
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) throw new Error('[CastleTV] Empty response body');
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed.data && typeof parsed.data === 'string') {
|
||||
return parsed.data.trim();
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — raw cipher text
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function unwrap(obj) {
|
||||
if (obj && obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
|
||||
return obj.data;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
async function getSecurityKey() {
|
||||
const url = `${CASTLE_BASE}/v0.1/system/getSecurityKey/1?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}`;
|
||||
const res = await castleRequest(url);
|
||||
const json = await res.json();
|
||||
if (json.code !== 200 || !json.data) {
|
||||
throw new Error(`[CastleTV] Security key error: ${JSON.stringify(json)}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function searchCastle(secKey, keyword) {
|
||||
const params = new URLSearchParams({
|
||||
channel: CHANNEL,
|
||||
clientType: CLIENT,
|
||||
keyword,
|
||||
lang: LANG,
|
||||
mode: '1',
|
||||
packageName: PKG,
|
||||
page: '1',
|
||||
size: '30'
|
||||
});
|
||||
const res = await castleRequest(`${CASTLE_BASE}/film-api/v1.1.0/movie/searchByKeyword?${params}`);
|
||||
const cipher = await extractCipher(res);
|
||||
return castleSafeParse(decryptCastle(cipher, secKey));
|
||||
}
|
||||
|
||||
async function getCastleDetails(secKey, movieId) {
|
||||
const url = `${CASTLE_BASE}/film-api/v1.9.9/movie?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}&movieId=${movieId}&packageName=${PKG}`;
|
||||
const res = await castleRequest(url);
|
||||
const cipher = await extractCipher(res);
|
||||
return castleSafeParse(decryptCastle(cipher, secKey));
|
||||
}
|
||||
|
||||
async function getVideoByLanguage(secKey, movieId, episodeId, languageId, resolution) {
|
||||
const body = {
|
||||
mode: '1',
|
||||
appMarket: 'GuanWang',
|
||||
clientType: CLIENT,
|
||||
woolUser: 'false',
|
||||
apkSignKey: 'ED0955EB04E67A1D9F3305B95454FED485261475',
|
||||
androidVersion: '13',
|
||||
movieId,
|
||||
episodeId,
|
||||
languageId,
|
||||
isNewUser: 'true',
|
||||
resolution: resolution.toString(),
|
||||
packageName: PKG
|
||||
};
|
||||
const url = `${CASTLE_BASE}/film-api/v2.0.1/movie/getVideo2?clientType=${CLIENT}&packageName=${PKG}&channel=${CHANNEL}&lang=${LANG}`;
|
||||
const res = await castleRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const cipher = await extractCipher(res);
|
||||
return castleSafeParse(decryptCastle(cipher, secKey));
|
||||
}
|
||||
|
||||
async function getVideoShared(secKey, movieId, episodeId, resolution) {
|
||||
const body = {
|
||||
mode: '1',
|
||||
appMarket: 'GuanWang',
|
||||
clientType: CLIENT,
|
||||
woolUser: 'false',
|
||||
apkSignKey: 'ED0955EB04E67A1D9F3305B95454FED485261475',
|
||||
androidVersion: '13',
|
||||
movieId,
|
||||
episodeId,
|
||||
isNewUser: 'true',
|
||||
resolution: resolution.toString(),
|
||||
packageName: PKG
|
||||
};
|
||||
const url = `${CASTLE_BASE}/film-api/v2.0.1/movie/getVideo2?clientType=${CLIENT}&packageName=${PKG}&channel=${CHANNEL}&lang=${LANG}`;
|
||||
const res = await castleRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const cipher = await extractCipher(res);
|
||||
return castleSafeParse(decryptCastle(cipher, secKey));
|
||||
}
|
||||
|
||||
function resolutionLabel(res) {
|
||||
const map = { 1: '480p', 2: '720p', 3: '1080p' };
|
||||
return map[res] || `${res}p`;
|
||||
}
|
||||
|
||||
const KNOWN_HEIGHTS = new Set([240, 360, 480, 540, 576, 720, 1080, 1440, 2160]);
|
||||
|
||||
function knownHeight(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && KNOWN_HEIGHTS.has(n);
|
||||
}
|
||||
|
||||
function resolutionNumToLabel(num) {
|
||||
const map = { 1: '480p', 2: '720p', 3: '1080p', 4: '4K' };
|
||||
return map[num] || null;
|
||||
}
|
||||
|
||||
function streamQuality(url, description, resolutionNum, defaultQual) {
|
||||
if (description) {
|
||||
const m = /(?:SD|HD|FHD|UHD|4K)?\s*(\d{3,4})\s*p?/i.exec(String(description).trim());
|
||||
if (m && knownHeight(m[1])) {
|
||||
return `${m[1]}p`;
|
||||
}
|
||||
if (/4k|uhd/i.test(String(description))) return '4K';
|
||||
}
|
||||
const numLabel = resolutionNumToLabel(Number(resolutionNum));
|
||||
if (numLabel) return numLabel;
|
||||
if (url) {
|
||||
const tokens = url.match(/[^/a-z](?:(\d{3,4})\s*p?)[^a-z]/gi);
|
||||
if (tokens) {
|
||||
for (const t of tokens) {
|
||||
const m = /(\d{3,4})/i.exec(t);
|
||||
if (m && knownHeight(m[1])) return `${m[1]}p`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultQual;
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (typeof bytes !== 'number' || bytes <= 0) return 'Unknown';
|
||||
if (bytes > 1000000000) return `${(bytes / 1000000000).toFixed(2)} GB`;
|
||||
return `${(bytes / 1000000).toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function buildCastleStreams(raw, langLabel, titleLine, resolution) {
|
||||
const data = unwrap(raw);
|
||||
if (!data.videoUrl && !((data.videos || []).length)) return [];
|
||||
|
||||
const defaultQual = resolutionLabel(resolution);
|
||||
|
||||
const subtitles = (data.subtitles || [])
|
||||
.filter((s) => typeof s.url === 'string' && s.url.length > 0)
|
||||
.map((s, i) => ({
|
||||
url: s.url.replace(/ /g, '%20'),
|
||||
lang: s.abbreviate || s.title || 'Unknown',
|
||||
id: `castle-${s.languageId || s.abbreviate || i}`
|
||||
}));
|
||||
|
||||
const streams = [];
|
||||
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
const bestByUrl = new Map();
|
||||
for (const v of data.videos) {
|
||||
const videoUrl = v.url || data.videoUrl;
|
||||
if (!videoUrl) continue;
|
||||
const resNum = Number(v.resolution) || 0;
|
||||
const qual = streamQuality(
|
||||
videoUrl,
|
||||
v.resolutionDescription,
|
||||
resNum,
|
||||
defaultQual
|
||||
);
|
||||
const existing = bestByUrl.get(videoUrl);
|
||||
if (existing && existing.resNum >= resNum) continue;
|
||||
const nameTag = langLabel ? `CastleTV ${langLabel}` : 'CastleTV';
|
||||
bestByUrl.set(videoUrl, {
|
||||
resNum,
|
||||
stream: {
|
||||
name: `${nameTag} | ${qual}`,
|
||||
title: `${titleLine}\n${qual} | ${formatSize(v.size)} | Castle`,
|
||||
url: videoUrl,
|
||||
quality: qual,
|
||||
provider: 'CastleTV',
|
||||
headers: PLAYBACK_HEADERS,
|
||||
...(subtitles.length ? { subtitles } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const { stream } of bestByUrl.values()) {
|
||||
streams.push(stream);
|
||||
}
|
||||
} else {
|
||||
const videoUrl = data.videoUrl;
|
||||
if (!videoUrl) return [];
|
||||
const qual = streamQuality(videoUrl, data.resolutionDescription, 0, defaultQual);
|
||||
const nameTag = langLabel ? `CastleTV ${langLabel}` : 'CastleTV';
|
||||
streams.push({
|
||||
name: `${nameTag} | ${qual}`,
|
||||
title: `${titleLine}\n${qual} | ${formatSize(data.size)} | Castle`,
|
||||
url: videoUrl,
|
||||
quality: qual,
|
||||
provider: 'CastleTV',
|
||||
headers: PLAYBACK_HEADERS,
|
||||
...(subtitles.length ? { subtitles } : {})
|
||||
});
|
||||
}
|
||||
|
||||
return streams;
|
||||
}
|
||||
|
||||
function pickPreferredTracks(tracks) {
|
||||
if (!tracks || !tracks.length) return tracks || [];
|
||||
const withVideo = tracks.filter((t) => t.existIndividualVideo === true);
|
||||
return withVideo.length > 0 ? withVideo : tracks;
|
||||
}
|
||||
|
||||
async function getCastletvStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||
console.log(`[CastleTV] 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(`[CastleTV] No TMDB title resolved for ${tmdbId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const season = seasonNum || 1;
|
||||
const episode = episodeNum || 1;
|
||||
const titleLine = type === 'series'
|
||||
? `${title} S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}${year ? ` (${year})` : ''}`
|
||||
: `${title}${year ? ` (${year})` : ''}`;
|
||||
|
||||
const secKey = await getSecurityKey();
|
||||
|
||||
const keyword = year ? `${title} ${year}` : title;
|
||||
const searchResult = await searchCastle(secKey, keyword);
|
||||
const rows = (unwrap(searchResult).rows || []);
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log(`[CastleTV] No search results for "${keyword}"`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const titleLc = title.toLowerCase();
|
||||
const match = rows.find((r) => {
|
||||
const name = (r.title || r.name || '').toLowerCase();
|
||||
return name.includes(titleLc) || titleLc.includes(name);
|
||||
}) || rows[0];
|
||||
|
||||
if (!match) return [];
|
||||
const castleId = (match.id || match.redirectId || match.redirectIdStr || '').toString();
|
||||
if (!castleId) return [];
|
||||
|
||||
let castleDetails = await getCastleDetails(secKey, castleId);
|
||||
let activeId = castleId;
|
||||
|
||||
if (type === 'series') {
|
||||
const seasons = (unwrap(castleDetails).seasons || []);
|
||||
const seasonEntry = seasons.find((s) => s.number === season);
|
||||
if (seasonEntry && seasonEntry.movieId && seasonEntry.movieId.toString() !== castleId) {
|
||||
castleDetails = await getCastleDetails(secKey, seasonEntry.movieId.toString());
|
||||
activeId = seasonEntry.movieId.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const episodes = (unwrap(castleDetails).episodes || []);
|
||||
let episodeId = null;
|
||||
|
||||
if (type === 'series') {
|
||||
const ep = episodes.find((e) => e.number === episode);
|
||||
episodeId = ep && ep.id ? ep.id.toString() : null;
|
||||
} else {
|
||||
episodeId = episodes[0] && episodes[0].id ? episodes[0].id.toString() : null;
|
||||
}
|
||||
|
||||
if (!episodeId) {
|
||||
console.log(`[CastleTV] No episode found (${season}/${episode})`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const epEntry = episodes.find((e) => e.id && e.id.toString() === episodeId);
|
||||
const allTracks = (epEntry && epEntry.tracks) || [];
|
||||
const tracks = pickPreferredTracks(allTracks);
|
||||
|
||||
const streams = [];
|
||||
const seenUrls = new Set();
|
||||
|
||||
if (tracks.length > 0) {
|
||||
const langJobs = tracks.map((track) =>
|
||||
Promise.allSettled(
|
||||
[3, 2, 1].map((resolution) =>
|
||||
getVideoByLanguage(secKey, activeId, episodeId, String(track.languageId), resolution)
|
||||
.then((raw) => ({ raw, resolution, track }))
|
||||
)
|
||||
)
|
||||
);
|
||||
const allResults = await Promise.all(langJobs);
|
||||
|
||||
for (const trackResults of allResults) {
|
||||
for (const r of trackResults) {
|
||||
if (r.status !== 'fulfilled') continue;
|
||||
const { raw, resolution, track } = r.value;
|
||||
const langLabel = `[${track.languageName || track.abbreviate || 'Unknown'}]`;
|
||||
for (const s of buildCastleStreams(raw, langLabel, titleLine, resolution)) {
|
||||
if (!seenUrls.has(s.url)) {
|
||||
seenUrls.add(s.url);
|
||||
streams.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streams.length === 0) {
|
||||
const sharedResults = await Promise.allSettled(
|
||||
[3, 2, 1].map((resolution) =>
|
||||
getVideoShared(secKey, activeId, episodeId, resolution)
|
||||
.then((raw) => ({ raw, resolution }))
|
||||
)
|
||||
);
|
||||
for (const r of sharedResults) {
|
||||
if (r.status !== 'fulfilled') continue;
|
||||
const { raw, resolution } = r.value;
|
||||
for (const s of buildCastleStreams(raw, '', titleLine, resolution)) {
|
||||
if (!seenUrls.has(s.url)) {
|
||||
seenUrls.add(s.url);
|
||||
streams.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CastleTV] Got ${streams.length} stream(s) for "${title}"`);
|
||||
return streams;
|
||||
} catch (err) {
|
||||
console.error(`[CastleTV] Error: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getCastletvStreams };
|
||||
Reference in New Issue
Block a user