mirror of
https://github.com/Inside4ndroid/TMDB-Embed-API.git
synced 2026-10-22 09:35: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.
417 lines
15 KiB
JavaScript
417 lines
15 KiB
JavaScript
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 }; |