diff --git a/src/config.js b/src/config.js index 4db7bae..83ad820 100644 --- a/src/config.js +++ b/src/config.js @@ -18,6 +18,25 @@ export const SOURCES = [ }], }, + { + key: '4movies', + label: '4Movies', + sourceFile: '4movies', + proxyParam: '4m', + timeout: 25000, + jitter: 500, + retries: 2, + multiUrl: true, + skipProxy: true, + cdnHeaders: [{ + pattern: /4movie\.in|aapanel\.devcorp\.me/i, + headers: { + Referer: 'https://4movie.in/', + Origin: 'https://4movie.in', + }, + }], + }, + { key: '123anime', label: '123Anime', @@ -56,6 +75,37 @@ export const SOURCES = [ }], }, + { + key: 'aether', + label: 'Aether', + sourceFile: 'aether', + proxyParam: 'aet', + timeout: 25000, + jitter: 500, + retries: 2, + multiUrl: true, + skipProxy: true, + cdnHeaders: [{ + pattern: /raccoonunbutteredbagel\.zip|1x2\.space|kaxate\.fun|mechanicalserenity\.xyz|reelvault\.click|visionaryfounderslab\.site|goldenkite\.top|aether\.ist/i, + headers: { + Referer: 'https://aether.st/', + Origin: 'https://aether.st', + }, + }], + }, + + { + key: 'castle', + label: 'Castle', + sourceFile: 'castle', + proxyParam: 'cst', + timeout: 25000, + jitter: 500, + retries: 2, + multiUrl: true, + skipProxy: true, + }, + { key: 'cinemaarmy', label: 'CinemaArmy', @@ -151,6 +201,25 @@ export const SOURCES = [ }], }, + { + key: 'framextv', + label: 'FrameXTV', + sourceFile: 'framextv', + proxyParam: 'fxt', + timeout: 25000, + jitter: 500, + retries: 2, + multiUrl: true, + skipProxy: true, + cdnHeaders: [{ + pattern: /framextv\.cc|streamos\.top|workers\.dev|films365\.org/i, + headers: { + Referer: 'https://framextv.cc/', + Origin: 'https://framextv.cc', + }, + }], + }, + { key: 'frost', label: 'FrostStream', @@ -278,6 +347,25 @@ export const SOURCES = [ }, }, + { + key: 'meowtv', + label: 'MeowTV', + sourceFile: 'meowtv', + proxyParam: 'mtv', + timeout: 25000, + jitter: 500, + retries: 2, + multiUrl: true, + skipProxy: true, + cdnHeaders: [{ + pattern: /meowtv\.ru/i, + headers: { + Referer: 'https://meowtv.ru/', + Origin: 'https://meowtv.ru', + }, + }], + }, + { key: 'movenerds', label: 'MoverNerds', @@ -481,6 +569,25 @@ export const SOURCES = [ multiUrl: true, }, + { + key: 'vidora', + label: 'Vidora', + sourceFile: 'vidora', + proxyParam: 'vd', + timeout: 25000, + jitter: 500, + retries: 2, + multiUrl: true, + skipProxy: true, + cdnHeaders: [{ + pattern: /vidoraplayer\.duckdns\.org/i, + headers: { + Referer: 'https://vidoraplayer.duckdns.org/', + Origin: 'https://vidoraplayer.duckdns.org', + }, + }], + }, + { key: 'vidup', label: 'VidUp', diff --git a/src/health.js b/src/health.js index 3de34d4..af54684 100644 --- a/src/health.js +++ b/src/health.js @@ -1,6 +1,15 @@ -import { HEALTH_PROBE_ID } from "./config.js"; -const withTimeout = (promise, ms) => Promise.race([promise, new Promise(r => setTimeout(() => r(null), ms))]); +const MOVIE_PROBE_ID = "936075"; +const TV_PROBE_ID = "37854"; +const TV_PROBE_SEASON = "1"; +const TV_PROBE_EPISODE = "1"; + +const withTimeout = (promise, ms) => + Promise.race([ + promise, + new Promise(resolve => setTimeout(() => resolve(null), ms)) + ]); + async function withRetry(fn, attempts = 2, delay = 300) { for (let i = 0; i < attempts; i++) { try { @@ -8,31 +17,62 @@ async function withRetry(fn, attempts = 2, delay = 300) { if (result != null) return result; } catch (err) { if (i === attempts - 1) throw err; - await new Promise(r => setTimeout(r, delay + Math.random() * delay * 0.5)); + await new Promise(resolve => + setTimeout(resolve, delay + Math.random() * delay * 0.5) + ); } } return null; } +function hasStream(result) { + const urls = [ + result?.url, + ...(result?.allUrls || []).map(item => item?.url) + ]; + + return urls.some(url => + typeof url === "string" && url.startsWith("http") + ); +} + export async function probeSource(sdk, cfg, mod) { - const audio = /dub$/.test(cfg.key) ? 'dub' : 'sub'; - const streamArgs = { - id: HEALTH_PROBE_ID, - s: null, - e: null, - clientIP: null, - absoluteBase: null, - audio, - sdk, - config: cfg, + const audio = /dub$/.test(cfg.key) ? "dub" : "sub"; + const start = Date.now(); + + const test = async (id, s = null, e = null) => { + const streamArgs = { + id, + s, + e, + clientIP: null, + absoluteBase: null, + audio, + sdk, + config: cfg, + }; + + const result = await withTimeout( + withRetry( + () => mod.getStream(streamArgs), + cfg.retries, + 300 + ), + cfg.timeout + ); + + return hasStream(result); }; - const t = Date.now(); - const result = await withTimeout( - withRetry(() => mod.getStream(streamArgs), cfg.retries, 300), - cfg.timeout - ); + const [movie, tv] = await Promise.all([ + test(MOVIE_PROBE_ID), + test(TV_PROBE_ID, TV_PROBE_SEASON, TV_PROBE_EPISODE) + ]); - const firstUrl = result?.url ?? result?.allUrls?.[0]?.url; - return { ok: !!(typeof firstUrl === 'string' && firstUrl.startsWith('http')), ms: Date.now() - t }; + return { + ok: movie || tv, + movie, + tv, + ms: Date.now() - start + }; } \ No newline at end of file diff --git a/src/sources/4movies.js b/src/sources/4movies.js new file mode 100644 index 0000000..fdd7396 --- /dev/null +++ b/src/sources/4movies.js @@ -0,0 +1,77 @@ +const BASE_URL = 'https://4movie.in'; +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + +const HEADERS = { + 'User-Agent': UA, + Referer: `${BASE_URL}/`, + Origin: BASE_URL, + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', +}; + +const PLAYBACK_HEADERS = { + 'User-Agent': UA, + Referer: `${BASE_URL}/`, + Origin: BASE_URL, +}; + +export async function getStream(args) { + const { id, s, e } = args || {}; + const isTv = s != null && e != null; + + let query = isTv + ? `tmdb=${id}&type=tv&season=${s}&episode=${e}` + : `tmdb=${id}&type=movie`; + + if (args?.title) query += `&title=${encodeURIComponent(args.title)}`; + if (args?.year) query += `&year=${encodeURIComponent(args.year)}`; + + const targetUrl = `${BASE_URL}/api/hubcloud?${query}`; + + const res = await fetch(targetUrl, { headers: HEADERS }).catch(() => null); + if (!res || !res.ok) return null; + + const data = await res.json().catch(() => null); + if (!data?.ok || !Array.isArray(data.streams) || !data.streams.length) return null; + + const allUrls = []; + const seen = new Set(); + + for (const stream of data.streams) { + if (stream.rawUrl && !seen.has(stream.rawUrl)) { + seen.add(stream.rawUrl); + allUrls.push({ + url: stream.rawUrl, + server: `4Movies - ${stream.name || 'Direct'}`, + quality: stream.quality || 'Auto', + type: stream.type || (stream.rawUrl.includes('.m3u8') ? 'hls' : 'mp4'), + headers: PLAYBACK_HEADERS, + }); + } + + if (stream.url) { + const proxyUrl = stream.url.startsWith('http') + ? stream.url + : `${BASE_URL}${stream.url.startsWith('/') ? '' : '/'}${stream.url}`; + + if (!seen.has(proxyUrl)) { + seen.add(proxyUrl); + allUrls.push({ + url: proxyUrl, + server: `4Movies - ${stream.name ? `${stream.name} (Proxy)` : 'Proxy'}`, + quality: stream.quality || 'Auto', + type: stream.type || 'hls', + headers: PLAYBACK_HEADERS, + }); + } + } + } + + if (!allUrls.length) return null; + + return { + url: allUrls[0].url, + headers: allUrls[0].headers, + allUrls, + }; +} \ No newline at end of file diff --git a/src/sources/aether.js b/src/sources/aether.js new file mode 100644 index 0000000..726e6fb --- /dev/null +++ b/src/sources/aether.js @@ -0,0 +1,99 @@ +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + +const HEADERS = { + 'User-Agent': UA, + Origin: 'https://aether.st', + Referer: 'https://aether.st/', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', +}; + +const PLAYBACK_HEADERS = { + 'User-Agent': UA, + Referer: 'https://aether.st/', + Origin: 'https://aether.st', +}; + +function extractStreamUrls(data, target) { + const urls = []; + if (!data) return urls; + + if (Array.isArray(data.streams)) { + for (const s of data.streams) { + if (s?.url) { + urls.push({ + url: s.url, + name: `${target.name} (${s.name || s.language || 'Default'})`, + quality: s.quality || 'Auto', + type: s.type || (s.url.includes('.m3u8') ? 'hls' : 'mp4'), + }); + } + } + } + + const singleUrl = data.directUrl || data.url || data.stream; + if (typeof singleUrl === 'string' && /^https?:\/\//.test(singleUrl)) { + urls.push({ + url: singleUrl, + name: `${target.name}${data.server ? ` - ${data.server}` : ''}`, + quality: data.quality || 'Auto', + type: data.streamType || data.container || (singleUrl.includes('.m3u8') ? 'hls' : 'mp4'), + }); + } + + return urls; +} + +export async function getStream(args) { + const { id, s, e } = args || {}; + const isTv = s != null && e != null; + + const endpoints = [ + { name: 'Meridian', url: isTv ? `https://meridian.raccoonunbutteredbagel.zip/show/${id}/${s}/${e}` : `https://meridian.raccoonunbutteredbagel.zip/movie/${id}` }, + { name: 'Rock', url: isTv ? `https://rock.raccoonunbutteredbagel.zip/tv/${id}/${s}/${e}` : `https://rock.raccoonunbutteredbagel.zip/movie/${id}` }, + { name: 'Shore', url: isTv ? `https://shore.raccoonunbutteredbagel.zip/show/${id}?s=${s}&e=${e}` : `https://shore.raccoonunbutteredbagel.zip/movie/${id}` }, + { name: 'Filler', url: isTv ? `https://filler.raccoonunbutteredbagel.zip/tv/${id}/${s}/${e}` : `https://filler.raccoonunbutteredbagel.zip/movie/${id}` }, + { name: 'Tiki', url: isTv ? `https://tiki.raccoonunbutteredbagel.zip/tv/${id}/${s}/${e}` : `https://tiki.raccoonunbutteredbagel.zip/movie/${id}` }, + { name: 'Link', url: isTv ? `https://link.raccoonunbutteredbagel.zip/tv/${id}/${s}/${e}` : `https://link.raccoonunbutteredbagel.zip/movie/${id}` }, + { name: 'VFast Rapid', url: isTv ? `https://vfast.raccoonunbutteredbagel.zip/tv/${id}/${s}/${e}?server=vRapid` : `https://vfast.raccoonunbutteredbagel.zip/movie/${id}?server=vRapid` }, + { name: 'VFast Blaze', url: isTv ? `https://vfast.raccoonunbutteredbagel.zip/tv/${id}/${s}/${e}?server=vBlaze` : `https://vfast.raccoonunbutteredbagel.zip/movie/${id}?server=vBlaze` }, + ]; + + const responses = await Promise.allSettled( + endpoints.map(async target => { + const res = await fetch(target.url, { headers: HEADERS }); + if (!res.ok) return null; + const data = await res.json(); + return { target, data }; + }) + ); + + const allUrls = []; + const seen = new Set(); + + for (const res of responses) { + if (res.status !== 'fulfilled' || !res.value?.data) continue; + const { target, data } = res.value; + const found = extractStreamUrls(data, target); + + for (const item of found) { + if (!item?.url || seen.has(item.url)) continue; + seen.add(item.url); + allUrls.push({ + url: item.url, + server: `Aether - ${item.name}`, + quality: item.quality || 'Auto', + type: item.type || (item.url.includes('.m3u8') ? 'hls' : 'mp4'), + headers: PLAYBACK_HEADERS, + }); + } + } + + if (!allUrls.length) return null; + + return { + url: allUrls[0].url, + headers: allUrls[0].headers, + allUrls, + }; +} \ No newline at end of file diff --git a/src/sources/castle.js b/src/sources/castle.js new file mode 100644 index 0000000..f3cfb28 --- /dev/null +++ b/src/sources/castle.js @@ -0,0 +1,415 @@ +import crypto from 'node:crypto'; + +const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; +const CASTLE_BASE = 'https://api.hlowb.com'; +const PKG = 'com.external.castle'; +const CHANNEL = 'IndiaA'; +const CLIENT = '1'; +const LANG = 'en-US'; +const SUFFIX = 'T!BgJB'; +const APK_SIGN_KEY = 'ED0955EB04E67A1D9F3305B95454FED485261475'; + +const WORKING_HEADERS = { + 'User-Agent': 'okhttp/4.9.3', + Accept: 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + Connection: 'Keep-Alive', + Referer: CASTLE_BASE, +}; + +export const CASTLE_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', +}; + +const QUALITY_MAP = { 1: '480p', 2: '720p', 3: '1080p' }; + +function safeJsonParse(text) { + const safe = text.replace(/:(\s*)(-?\d{16,})(\s*[,}\]])/g, ':$1"$2"$3'); + return JSON.parse(safe); +} + +class CastleScraper { + constructor(apiKey) { + this.tmdbKey = + apiKey || + process.env.CASTLE_TMDB_API_KEY || + process.env.TMDB_API_KEY || + '439c478a771f35c05022f9feabcca01c'; + this.securityKey = null; + } + + deriveKey(apiKeyB64) { + const apiKeyBytes = Buffer.from(apiKeyB64, 'base64'); + let keyMaterial = Buffer.concat([apiKeyBytes, Buffer.from(SUFFIX, 'ascii')]); + if (keyMaterial.length < 16) { + keyMaterial = Buffer.concat([ + keyMaterial, + Buffer.alloc(16 - keyMaterial.length), + ]); + } else if (keyMaterial.length > 16) { + keyMaterial = keyMaterial.subarray(0, 16); + } + return keyMaterial; + } + + decryptCastle(encryptedB64, securityKeyB64) { + const aesKey = this.deriveKey(securityKeyB64); + const iv = aesKey; + const encrypted = Buffer.from(encryptedB64, 'base64'); + const decipher = crypto.createDecipheriv('aes-128-cbc', aesKey, iv); + decipher.setAutoPadding(true); + return Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]).toString('utf-8'); + } + + async request(url, init) { + const r = await fetch(url, { + ...init, + headers: { ...WORKING_HEADERS, ...(init?.headers || {}) }, + }); + if (!r.ok) throw new Error(`Castle request failed ${r.status} for ${url}`); + return r; + } + + async extractCipher(response) { + const text = (await response.text()).trim(); + if (!text) throw new Error('Empty response'); + try { + const j = JSON.parse(text); + if (j && typeof j.data === 'string') return j.data.trim(); + } catch { } + return text; + } + + async decryptedJson(response, securityKey) { + const cipher = await this.extractCipher(response); + return safeJsonParse(this.decryptCastle(cipher, securityKey)); + } + + async getSecurityKey() { + if (this.securityKey) return this.securityKey; + const url = `${CASTLE_BASE}/v0.1/system/getSecurityKey/1?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}`; + const r = await this.request(url); + const data = await r.json(); + if (data.code !== 200 || !data.data) { + throw new Error(`Security key API error: ${JSON.stringify(data)}`); + } + this.securityKey = data.data; + return this.securityKey; + } + + async searchCastle(securityKey, keyword, page = 1, size = 30) { + const params = new URLSearchParams({ + channel: CHANNEL, + clientType: CLIENT, + keyword, + lang: LANG, + mode: '1', + packageName: PKG, + page: String(page), + size: String(size), + }); + const url = `${CASTLE_BASE}/film-api/v1.1.0/movie/searchByKeyword?${params}`; + return this.decryptedJson(await this.request(url), securityKey); + } + + async getDetails(securityKey, movieId) { + const url = `${CASTLE_BASE}/film-api/v1.1/movie?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}&movieId=${movieId}&packageName=${PKG}`; + return this.decryptedJson(await this.request(url), securityKey); + } + + async getVideo2(securityKey, movieId, episodeId, resolution = 2) { + const url = `${CASTLE_BASE}/film-api/v2.0.1/movie/getVideo2?clientType=${CLIENT}&packageName=${PKG}&channel=${CHANNEL}&lang=${LANG}`; + const body = { + mode: '1', + appMarket: 'GuanWang', + clientType: '1', + woolUser: 'false', + apkSignKey: APK_SIGN_KEY, + androidVersion: '13', + movieId: String(movieId), + episodeId: String(episodeId), + isNewUser: 'true', + resolution: String(resolution), + packageName: PKG, + }; + const r = await this.request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return this.decryptedJson(r, securityKey); + } + + async getVideoV1(securityKey, movieId, episodeId, languageId, resolution = 2) { + const params = new URLSearchParams({ + apkSignKey: APK_SIGN_KEY, + channel: CHANNEL, + clientType: CLIENT, + episodeId: String(episodeId), + lang: LANG, + languageId: String(languageId), + mode: '1', + movieId: String(movieId), + packageName: PKG, + resolution: String(resolution), + }); + const url = `${CASTLE_BASE}/film-api/v1.9.1/movie/getVideo?${params}`; + return this.decryptedJson(await this.request(url), securityKey); + } + + extractDataBlock(obj) { + if (obj && typeof obj.data === 'object' && obj.data) return obj.data; + return obj && typeof obj === 'object' ? obj : {}; + } + + qualityValue(quality) { + if (!quality) return 0; + let q = String(quality).toLowerCase(); + q = q.replace(/^(sd|hd|fhd|uhd|4k)\s*/, '').replace(/p$/, '').trim(); + if (q === '4k' || q === '2160') return 2160; + const known = [1440, 1080, 720, 480, 360, 240]; + const n = parseInt(q, 10); + if (known.includes(n)) return n; + return n > 0 ? n : 0; + } + + formatSize(size) { + if (typeof size === 'number' && size > 0) { + return size > 1_000_000_000 + ? `${(size / 1_000_000_000).toFixed(2)} GB` + : `${Math.round(size / 1_000_000)} MB`; + } + return 'Unknown'; + } + + async getTmdbDetails(tmdbId, mediaType) { + const endpoint = mediaType === 'tv' ? 'tv' : 'movie'; + const url = `${TMDB_BASE_URL}/${endpoint}/${tmdbId}?api_key=${this.tmdbKey}&append_to_response=external_ids`; + const r = await this.request(url); + const data = await r.json(); + const title = mediaType === 'tv' ? data.name : data.title; + const releaseDate = + mediaType === 'tv' ? data.first_air_date : data.release_date; + const year = releaseDate ? parseInt(String(releaseDate).split('-')[0], 10) : null; + return { title, year, tmdbId }; + } + + findMovieId(searchResult, tmdbInfo) { + const data = this.extractDataBlock(searchResult); + const rows = data.rows || []; + if (!rows.length) throw new Error('No search results found'); + const searchTitle = (tmdbInfo.title || '').toLowerCase(); + + for (const item of rows) { + const itemTitle = String(item.title || item.name || '').toLowerCase(); + if ( + itemTitle && + (itemTitle.includes(searchTitle) || searchTitle.includes(itemTitle)) + ) { + const id = item.id || item.redirectId || item.redirectIdStr; + if (id) return String(id); + } + } + const first = rows[0]; + const id = first.id || first.redirectId || first.redirectIdStr; + if (id) return String(id); + throw new Error('Could not extract movie ID from search results'); + } + + processVideoResponse(videoData, media, season, episode, resolution, languageInfo) { + const streams = []; + const data = this.extractDataBlock(videoData); + const videoUrl = data.videoUrl; + if (!videoUrl) return streams; + + let mediaTitle = media.title || 'Unknown'; + if (media.year) mediaTitle += ` (${media.year})`; + if (season && episode) { + mediaTitle = `${media.title} S${String(season).padStart(2, '0')}E${String( + episode + ).padStart(2, '0')}`; + } + + const quality = QUALITY_MAP[resolution] || `${resolution}p`; + + if (Array.isArray(data.videos)) { + for (const video of data.videos) { + let q = String( + video.resolutionDescription || video.resolution || quality + ).replace(/^(SD|HD|FHD)\s+/i, ''); + const name = languageInfo + ? `Castle ${languageInfo} - ${q}` + : `Castle - ${q}`; + streams.push({ + name, + title: mediaTitle, + url: video.url || videoUrl, + quality: q, + size: this.formatSize(video.size), + provider: 'castle', + }); + } + } else { + const name = languageInfo + ? `Castle ${languageInfo} - ${quality}` + : `Castle - ${quality}`; + streams.push({ + name, + title: mediaTitle, + url: videoUrl, + quality, + size: this.formatSize(data.size), + provider: 'castle', + }); + } + return streams; + } + + async fetchSources(tmdbId, season = 0, episode = 0) { + const mediaType = season && episode ? 'tv' : 'movie'; + try { + const tmdbInfo = await this.getTmdbDetails(tmdbId, mediaType); + const securityKey = await this.getSecurityKey(); + + const searchTerm = tmdbInfo.year + ? `${tmdbInfo.title} ${tmdbInfo.year}` + : tmdbInfo.title; + const searchResult = await this.searchCastle(securityKey, searchTerm); + let movieId = this.findMovieId(searchResult, tmdbInfo); + + let details = await this.getDetails(securityKey, movieId); + + if (mediaType === 'tv') { + const data = this.extractDataBlock(details); + const seasons = data.seasons || []; + const s = seasons.find((x) => x.number === season); + if (s && s.movieId && String(s.movieId) !== movieId) { + movieId = String(s.movieId); + details = await this.getDetails(securityKey, movieId); + } + } + + const data = this.extractDataBlock(details); + const episodes = data.episodes || []; + + let episodeId = null; + if (mediaType === 'tv') { + const ep = episodes.find((e) => e.number === episode); + if (ep && ep.id) episodeId = String(ep.id); + } else if (episodes.length) { + episodeId = String(episodes[0].id); + } + if (!episodeId) throw new Error('Could not find episode ID'); + + const epObj = episodes.find((e) => String(e.id) === episodeId); + const tracks = epObj?.tracks || []; + + const resolution = 2; + let allStreams = []; + + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]; + const langName = + track.languageName || track.abbreviate || `Lang${i + 1}`; + if (track.existIndividualVideo && track.languageId) { + try { + const videoData = await this.getVideoV1( + securityKey, + movieId, + episodeId, + String(track.languageId), + resolution + ); + const langStreams = this.processVideoResponse( + videoData, + tmdbInfo, + season, + episode, + resolution, + `[${langName}]` + ); + allStreams.push(...langStreams); + } catch (e) { + console.error(`[Castle] ${langName}: v1 failed - ${e?.message || e}`); + } + } + } + + if (!allStreams.length) { + const videoData = await this.getVideo2( + securityKey, + movieId, + episodeId, + resolution + ); + allStreams = this.processVideoResponse( + videoData, + tmdbInfo, + season, + episode, + resolution, + '[Shared]' + ); + } + + allStreams.sort( + (a, b) => this.qualityValue(b.quality) - this.qualityValue(a.quality) + ); + return allStreams; + } catch (e) { + console.error(`[Castle] Error: ${e?.message || e}`); + return []; + } + } +} + +export async function getStream(args) { + const { id, s, e, sdk } = args || {}; + const scraper = new CastleScraper(sdk?.tmdbApiKey); + const tmdbId = parseInt(String(id), 10); + const season = s != null ? parseInt(String(s), 10) : 0; + const episode = e != null ? parseInt(String(e), 10) : 0; + + const streams = await scraper.fetchSources(tmdbId, season, episode).catch(() => []); + if (!streams || !streams.length) return null; + + const seen = new Set(); + const allUrls = []; + + for (const item of streams) { + if (!item?.url || seen.has(item.url)) continue; + seen.add(item.url); + allUrls.push({ + url: item.url, + server: item.name || 'Castle', + quality: item.quality || 'Auto', + type: item.url.includes('.m3u8') + ? 'hls' + : item.url.includes('.mpd') + ? 'dash' + : 'mp4', + headers: CASTLE_PLAYBACK_HEADERS, + }); + } + + if (!allUrls.length) return null; + + return { + url: allUrls[0].url, + headers: allUrls[0].headers, + allUrls, + }; +} \ No newline at end of file diff --git a/src/sources/framextv.js b/src/sources/framextv.js new file mode 100644 index 0000000..d9bc16d --- /dev/null +++ b/src/sources/framextv.js @@ -0,0 +1,126 @@ +const FRAMEX_BASE = 'https://api.framextv.cc'; +const STREAMOS_BASE = 'https://api.streamos.top'; +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + +const FRAMEX_HEADERS = { + 'User-Agent': UA, + Referer: 'https://framextv.cc/', + Origin: 'https://framextv.cc', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', +}; + +const STREAMOS_HEADERS = { + 'User-Agent': UA, + Referer: 'https://streamos.top/', + Origin: 'https://streamos.top', +}; + +const STREAMOS_SERVERS = [ + 'auto', + 'miami', + 'boise', + 'vegas', + 'reno', + 'oakland', + 'seattle', + 'phoenix', + 'atlanta', + 'portland', + 'austin', + 'dallas', + 'tampa', + 'orlando', + 'munich', + 'berlin', + 'france', + 'india', + 'mexico', + 'delhi', +]; + +export async function getStream(args) { + const { id, s, e } = args || {}; + const isTv = s != null && e != null; + + const framexQuery = isTv + ? `type=tv&id=${id}&season=${s}&episode=${e}&codecs=hevc` + : `type=movie&id=${id}&codecs=hevc`; + + const framexUrls = [ + `${FRAMEX_BASE}/api/stream?${framexQuery}`, + `${FRAMEX_BASE}/api/stream?${framexQuery}&provider=sparky`, + `${FRAMEX_BASE}/api/stream?${framexQuery}&provider=titan`, + ]; + + const streamOsBaseQuery = isTv + ? `id=${id}&season=${s}&episode=${e}&kind=tv` + : `id=${id}&kind=movie`; + + const allUrls = []; + const seen = new Set(); + + const framexPromises = Promise.all( + framexUrls.map(url => + fetch(url, { headers: FRAMEX_HEADERS }) + .then(r => (r.ok ? r.json() : null)) + .catch(() => null) + ) + ); + + const streamOsChecks = Promise.all( + STREAMOS_SERVERS.slice(0, 8).map(async server => { + const serverParam = server === 'auto' ? '' : `&server=${server}`; + const url = `${STREAMOS_BASE}/api/relay/hls?${streamOsBaseQuery}${serverParam}`; + try { + const res = await fetch(url, { method: 'HEAD', headers: STREAMOS_HEADERS }); + if (res.ok) { + return { + url, + server: `StreamOS - ${server.toUpperCase()}`, + quality: '1080p', + type: 'hls', + headers: STREAMOS_HEADERS, + }; + } + } catch { } + return null; + }) + ); + + const [framexResults, streamOsResults] = await Promise.all([framexPromises, streamOsChecks]); + + for (const data of framexResults) { + if (!data?.success || !Array.isArray(data.sources)) continue; + for (const src of data.sources) { + if (!src?.url || seen.has(src.url)) continue; + seen.add(src.url); + allUrls.push({ + url: src.url, + server: `FrameX - ${src.server || data.provider || 'Direct'}`, + quality: src.quality || 'Auto', + type: src.type || (src.url.includes('.m3u8') ? 'hls' : 'mp4'), + headers: { + 'User-Agent': UA, + Referer: 'https://framextv.cc/', + Origin: 'https://framextv.cc', + }, + }); + } + } + + for (const stream of streamOsResults) { + if (stream && !seen.has(stream.url)) { + seen.add(stream.url); + allUrls.push(stream); + } + } + + if (!allUrls.length) return null; + + return { + url: allUrls[0].url, + headers: allUrls[0].headers, + allUrls, + }; +} \ No newline at end of file diff --git a/src/sources/meowtv.js b/src/sources/meowtv.js new file mode 100644 index 0000000..64b4812 --- /dev/null +++ b/src/sources/meowtv.js @@ -0,0 +1,140 @@ +const BASE_URL = 'https://api.meowtv.ru'; +const DEC_API = 'https://enc-dec.app/api'; +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + +const HEADERS = { + 'User-Agent': UA, + Origin: 'https://meowtv.ru', + Referer: 'https://meowtv.ru/', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', +}; + +const SERVERS = [ + { id: 'acme', label: 'Acme' }, + { id: 'dcloud', label: 'Dcloud' }, + { id: 'tik', label: 'TCloud' }, + { id: 'ipcloud', label: 'IPcloud' }, + { id: 'turkce', label: 'Türkçe', movieOnly: true }, + { id: 'hindi', label: 'Hindi' }, + { id: 'english', label: 'English' }, +]; + +async function decryptMeow(data) { + try { + const res = await fetch(`${DEC_API}/dec-meowtv`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': UA, + }, + body: JSON.stringify({ data }), + }); + if (!res.ok) return null; + const json = await res.json(); + return json?.status === 200 ? json.result : null; + } catch { + return null; + } +} + +function extractUrls(result) { + const urls = []; + if (!result) return urls; + if (typeof result === 'string' && /^https?:\/\//.test(result)) { + urls.push({ url: result, quality: 'Auto', type: result.includes('.m3u8') ? 'hls' : 'mp4' }); + } else if (result?.url && typeof result.url === 'string') { + urls.push({ + url: result.url, + quality: result.quality || 'Auto', + type: result.type || (result.url.includes('.m3u8') ? 'hls' : 'mp4'), + }); + } else if (Array.isArray(result?.sources)) { + for (const s of result.sources) { + if (s?.url) { + urls.push({ + url: s.url, + quality: s.quality || 'Auto', + type: s.type || (s.url.includes('.m3u8') ? 'hls' : 'mp4'), + }); + } + } + } else if (result?.stream) { + const sUrl = typeof result.stream === 'string' ? result.stream : result.stream?.url; + if (sUrl) { + urls.push({ + url: sUrl, + quality: result.stream?.quality || 'Auto', + type: sUrl.includes('.m3u8') ? 'hls' : 'mp4', + }); + } + } + return urls; +} + +export async function getStream(args) { + const { id, s, e } = args || {}; + const isTv = s != null && e != null; + + const ticketRes = await fetch(`${BASE_URL}/streams/ticket`, { + method: 'POST', + headers: HEADERS, + }).then(r => (r.ok ? r.json() : null)).catch(() => null); + + const ticket = ticketRes?.ticket; + if (!ticket) return null; + + const requestHeaders = { + ...HEADERS, + 'X-Stream-Ticket': ticket, + }; + + const targetServers = isTv ? SERVERS.filter(srv => !srv.movieOnly) : SERVERS; + + const responses = await Promise.allSettled( + targetServers.map(async server => { + const url = isTv + ? `${BASE_URL}/streams/tv/${id}/${s}/${e}?s=${encodeURIComponent(server.id)}` + : `${BASE_URL}/streams/movie/${id}?s=${encodeURIComponent(server.id)}`; + + const res = await fetch(url, { headers: requestHeaders }); + if (!res.ok) return null; + const data = await res.json(); + const decrypted = await decryptMeow(data); + return { server, decrypted }; + }) + ); + + const allUrls = []; + const seen = new Set(); + + for (const res of responses) { + if (res.status !== 'fulfilled' || !res.value?.decrypted) continue; + const { server, decrypted } = res.value; + const found = extractUrls(decrypted); + + for (const item of found) { + if (!item?.url || seen.has(item.url)) continue; + seen.add(item.url); + allUrls.push({ + url: item.url, + server: `MeowTV - ${server.label}`, + quality: item.quality || 'Auto', + type: item.type || (item.url.includes('.m3u8') ? 'hls' : 'mp4'), + headers: { + Referer: 'https://meowtv.ru/', + Origin: 'https://meowtv.ru', + 'User-Agent': UA, + }, + }); + } + } + + if (!allUrls.length) return null; + + return { + url: allUrls[0].url, + headers: allUrls[0].headers, + allUrls, + }; +} \ No newline at end of file diff --git a/src/sources/vidora.js b/src/sources/vidora.js new file mode 100644 index 0000000..87fada3 --- /dev/null +++ b/src/sources/vidora.js @@ -0,0 +1,94 @@ +const BASE_URL = 'https://vidoraplayer.duckdns.org'; +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'; + +const HEADERS = { + 'User-Agent': UA, + Referer: `${BASE_URL}/`, + Origin: BASE_URL, + Accept: 'application/x-ndjson, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', +}; + +function parseNdjson(text) { + if (!text) return []; + const normalized = text.replace(/\}\s*\{/g, '}\n{'); + const lines = normalized.split('\n'); + const items = []; + for (const raw of lines) { + const line = raw.trim(); + if (!line) continue; + try { + items.push(JSON.parse(line)); + } catch { } + } + return items; +} + +export async function getStream(args) { + const { id, s, e } = args || {}; + const isTv = s != null && e != null; + + const query = isTv + ? `type=tv&id=${id}&season=${s}&episode=${e}` + : `type=movie&id=${id}`; + + const urls = [ + `${BASE_URL}/api/sources?${query}`, + `${BASE_URL}/api/sources?${query}&gen=2`, + ]; + + const responses = await Promise.all( + urls.map(url => + fetch(url, { headers: HEADERS }) + .then(r => (r.ok ? r.text() : '')) + .catch(() => '') + ) + ); + + const allUrls = []; + const seen = new Set(); + + for (const text of responses) { + const events = parseNdjson(text); + for (const item of events) { + const list = []; + if (item?.event === 'source' && item?.source) { + list.push(item.source); + } else if (item?.event === 'done' && Array.isArray(item?.sources)) { + list.push(...item.sources); + } else if (item?.url) { + list.push(item); + } + + for (const src of list) { + if (!src?.url) continue; + const fullUrl = src.url.startsWith('http') + ? src.url + : `${BASE_URL}${src.url.startsWith('/') ? '' : '/'}${src.url}`; + + if (!seen.has(fullUrl)) { + seen.add(fullUrl); + allUrls.push({ + url: fullUrl, + server: `Vidora - ${src.label || src.server || src.providerGroup || 'Default'}`, + quality: src.quality || 'Auto', + type: src.type || (fullUrl.includes('.m3u8') ? 'hls' : 'mp4'), + headers: { + Referer: `${BASE_URL}/`, + Origin: BASE_URL, + 'User-Agent': UA, + }, + }); + } + } + } + } + + if (!allUrls.length) return null; + + return { + url: allUrls[0].url, + headers: allUrls[0].headers, + allUrls, + }; +} \ No newline at end of file