From 13f10fb7d8ceafdc200e6571e1ed4a0eb5875abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=81nis=20L?= Date: Thu, 23 Jul 2026 13:40:48 +0300 Subject: [PATCH] add cineby --- manifest.json | 26 ++- providers/cineby.js | 349 ++++++++++++++++++++++++++++++++++++++++ src/cineby/extractor.js | 334 ++++++++++++++++++++++++++++++++++++++ src/cineby/http.js | 48 ++++++ src/cineby/index.js | 28 ++++ 5 files changed, 784 insertions(+), 1 deletion(-) create mode 100644 providers/cineby.js create mode 100644 src/cineby/extractor.js create mode 100644 src/cineby/http.js create mode 100644 src/cineby/index.js diff --git a/manifest.json b/manifest.json index da3dc97..5a76ffd 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,31 @@ { "name": "Nuvio Providers", - "version": "1.0.11", + "version": "1.0.12", "scrapers": [ + { + "id": "cineby", + "name": "Cineby", + "description": "Movies and TV streams from Cineby.", + "version": "1.0.0", + "author": "Jānis L", + "supportedTypes": [ + "movie", + "tv" + ], + "filename": "providers/cineby.js", + "enabled": true, + "hasSettings": false, + "formats": [ + "m3u8" + ], + "logo": "https://www.cineby.at/favicon.ico", + "contentLanguage": [ + "en" + ], + "limited": false, + "disabledPlatforms": [], + "supportsExternalPlayer": true + }, { "id": "miruro", "name": "Miruro", diff --git a/providers/cineby.js b/providers/cineby.js new file mode 100644 index 0000000..1f8de57 --- /dev/null +++ b/providers/cineby.js @@ -0,0 +1,349 @@ +// cineby - bundled from src/cineby/ +var __defProp = Object.defineProperty; +var __defProps = Object.defineProperties; +var __getOwnPropDescs = Object.getOwnPropertyDescriptors; +var __getOwnPropSymbols = Object.getOwnPropertySymbols; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __propIsEnum = Object.prototype.propertyIsEnumerable; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + } + return a; +}; +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); + +// src/cineby/http.js +var SITE_URL = "https://www.cineby.at"; +var METADATA_API_URL = "https://db.speedracelight.com/3"; +var SOURCE_API_URL = "https://api.speedracelight.com"; +var HEADERS = { + "Accept": "*/*", + "Origin": SITE_URL, + "Referer": `${SITE_URL}/`, + "User-Agent": "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/140.0.0.0 Mobile Safari/537.36" +}; +function mergeHeaders(headers) { + return __spreadValues(__spreadValues({}, HEADERS), headers || {}); +} +async function fetchText(url, options = {}) { + const response = await fetch(url, __spreadProps(__spreadValues({}, options), { + headers: mergeHeaders(options.headers) + })); + if (!response.ok) { + const error = new Error(`HTTP ${response.status} for ${url}`); + error.status = response.status; + throw error; + } + return await response.text(); +} +async function fetchJson(url, options = {}) { + return JSON.parse(await fetchText(url, options)); +} +function buildQuery(params) { + return Object.keys(params).filter((key) => params[key] != null && params[key] !== "").map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`).join("&"); +} + +// src/cineby/extractor.js +var STREAM_MAGIC = [109, 118, 109, 49]; +var MIX_CONSTANTS = [ + 1116352408, + 1899447441, + 3049323471, + 3921009573, + 961987163, + 1508970993, + 2453635748, + 2870763221, + 3624381080, + 310598401, + 607225278, + 1426881987, + 1925078388, + 2162078206, + 2614888103, + 3248222580 +]; +var CINEMETA_URL = "https://v3-cinemeta.strem.io/meta"; +var TMDB_ID_CACHE = {}; +function mix(value) { + value >>>= 0; + value ^= value >>> 16; + value = Math.imul(value, 2246822507) >>> 0; + value ^= value >>> 13; + value = Math.imul(value, 3266489909) >>> 0; + return (value ^= value >>> 16) >>> 0; +} +function rotateLeft(value, amount) { + value >>>= 0; + amount &= 31; + return amount === 0 ? value : (value << amount | value >>> 32 - amount) >>> 0; +} +function isEven(value) { + return (value * (value + 1) & 1) === 0; +} +function isOdd(value) { + return (value * (value + 1) & 1) === 1; +} +function decodeBase64Url(value) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/").replace(/\s/g, ""); + const bytes = []; + let buffer = 0; + let bits = 0; + for (let index = 0; index < normalized.length; index++) { + const code = alphabet.indexOf(normalized[index]); + if (code < 0) { + continue; + } + buffer = buffer << 6 | code; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes.push(buffer >>> bits & 255); + } + } + return new Uint8Array(bytes); +} +function decodeUtf8(bytes) { + if (typeof TextDecoder !== "undefined") { + return new TextDecoder("utf-8").decode(bytes); + } + let encoded = ""; + for (let index = 0; index < bytes.length; index++) { + encoded += `%${`0${bytes[index].toString(16)}`.slice(-2)}`; + } + return decodeURIComponent(encoded); +} +function createKeyStream(seed, mediaId, length) { + const state = function createState(seedValue, mediaIdentifier) { + if (isOdd(seedValue.length)) { + const values2 = Array(256); + for (let index = 0; index < 256; index++) + values2[index] = index; + let cursor = 0; + for (let index = 0; index < 256; index++) { + cursor = cursor + values2[index] + seedValue.charCodeAt(index % seedValue.length) & 255; + const current = values2[index]; + values2[index] = values2[cursor]; + values2[cursor] = current; + } + let accumulator = 1732584193; + for (let index = 0; index < seedValue.length; index++) { + accumulator = rotateLeft( + (accumulator ^ Math.imul(seedValue.charCodeAt(index), MIX_CONSTANTS[15 & index])) >>> 0, + 5 + ); + } + return { S: values2, acc: mix(accumulator) }; + } + const values = Array(61); + let seedHash = 2166136261; + for (let index = 0; index < seedValue.length; index++) { + seedHash = Math.imul(seedHash ^ seedValue.charCodeAt(index), 16777619) >>> 0; + } + let value = mix(mix(seedHash) ^ mix(mediaIdentifier >>> 0 ^ 2654435769)) >>> 0; + for (let index = 0; index < 8; index++) { + if (isEven(index)) { + const position = value % 61; + value = rotateLeft(value + 2654435769 >>> 0, 7 + (7 & index)); + values[position] = (value ^ mix(value)) >>> 0; + value = mix(value + position >>> 0); + } else { + values[index] = MIX_CONSTANTS[15 & index]; + } + } + return { S: values, acc: mix(2779096485 ^ value) >>> 0 }; + }(seed, mediaId); + const stream = new Uint8Array(length); + let streamIndex = 0; + let wordCounter = 0; + while (streamIndex < length) { + const word = function nextWord(currentState, counter) { + let previous; + let mixed; + const values = currentState.S; + let accumulator = currentState.acc; + const position = accumulator % 61; + const present = 0 - Number(position in values); + const stored = values[position] >>> 0; + let selected = (((previous = accumulator) ^ (mixed = (stored ^ Math.imul(2654435769, counter + 1) >>> 0) >>> 0)) >>> 0 | (previous & mixed & present) >>> 0) >>> 0; + accumulator = mix((selected = (rotateLeft(selected + accumulator >>> 0, 31 & position) ^ rotateLeft(accumulator, 31 & Math.imul(position, 7))) >>> 0) + 2654435769 >>> 0); + values[position] = accumulator >>> 0; + currentState.acc = accumulator; + return accumulator >>> 0; + }(state, wordCounter++); + stream[streamIndex++] = word & 255; + if (streamIndex < length) + stream[streamIndex++] = word >>> 8 & 255; + if (streamIndex < length) + stream[streamIndex++] = word >>> 16 & 255; + if (streamIndex < length) + stream[streamIndex++] = word >>> 24 & 255; + } + return stream; +} +function decryptPayload(raw, seed, mediaId) { + const encrypted = decodeBase64Url(raw); + const keyStream = createKeyStream(seed, mediaId, encrypted.length); + for (let index = 0; index < encrypted.length; index++) { + encrypted[index] ^= keyStream[index]; + } + for (let index = 0; index < STREAM_MAGIC.length; index++) { + if (encrypted[index] !== STREAM_MAGIC[index]) { + throw new Error("Cineby source payload could not be decrypted"); + } + } + return decodeUtf8(encrypted.slice(STREAM_MAGIC.length)); +} +function isPositiveInteger(value) { + return Number.isInteger(Number(value)) && Number(value) > 0; +} +async function resolveTmdbId(value, mediaType) { + const id = String(value || "").trim(); + if (/^\d+$/.test(id)) + return id; + if (!/^tt\d+$/i.test(id)) + return null; + const cacheKey = `${mediaType}:${id.toLowerCase()}`; + if (TMDB_ID_CACHE[cacheKey]) + return TMDB_ID_CACHE[cacheKey]; + const catalogType = mediaType === "movie" ? "movie" : "series"; + const metadata = await fetchJson( + `${CINEMETA_URL}/${catalogType}/${encodeURIComponent(id)}.json`, + { headers: { "Accept": "application/json" } } + ); + const resolved = metadata && metadata.meta && metadata.meta.moviedb_id; + if (!resolved || !/^\d+$/.test(String(resolved))) { + return null; + } + TMDB_ID_CACHE[cacheKey] = String(resolved); + return TMDB_ID_CACHE[cacheKey]; +} +async function getMetadata(tmdbId, mediaType) { + const endpoint = mediaType === "movie" ? "movie" : "tv"; + const data = await fetchJson( + `${METADATA_API_URL}/${endpoint}/${encodeURIComponent(tmdbId)}?append_to_response=external_ids` + ); + const title = mediaType === "movie" ? data.title || data.original_title : data.name || data.original_name; + const date = mediaType === "movie" ? data.release_date : data.first_air_date; + const imdbId = data.imdb_id || data.external_ids && data.external_ids.imdb_id || ""; + if (!title || !date) { + throw new Error("Cineby metadata response is incomplete"); + } + return { + title, + year: String(date).slice(0, 4), + imdbId + }; +} +async function getSeed(mediaId) { + const response = await fetchJson( + `${SOURCE_API_URL}/seed?${buildQuery({ mediaId })}` + ); + if (!response || !response.seed) { + throw new Error("Cineby did not return a source seed"); + } + return response.seed; +} +async function getSources(tmdbId, mediaType, season, episode, metadata) { + const request = { + // Cineby's player pre-encodes the title, then its HTTP client serializes + // it again. Preserve that double encoding to match the live API. + title: encodeURIComponent(metadata.title), + mediaType, + year: metadata.year, + episodeId: mediaType === "movie" ? 1 : episode, + seasonId: mediaType === "movie" ? 1 : season, + tmdbId, + imdbId: metadata.imdbId, + enc: 2 + }; + for (let attempt = 0; attempt < 2; attempt++) { + const seed = await getSeed(tmdbId); + const url = `${SOURCE_API_URL}/cdn/sources-with-title?${buildQuery(__spreadProps(__spreadValues({}, request), { seed }))}`; + try { + const raw = (await fetchText(url)).trim(); + if (raw.startsWith("{")) { + return JSON.parse(raw); + } + return JSON.parse(decryptPayload(raw, seed, Number(tmdbId))); + } catch (error) { + if (attempt === 0 && error && error.status === 401) { + continue; + } + throw error; + } + } + return { sources: [] }; +} +function getQuality(source) { + const value = String(source && source.quality || ""); + if (/^4k$/i.test(value)) + return "4K"; + const match = value.match(/(2160|1440|1080|720|480|360)p?/i); + return match ? `${match[1]}p` : "Auto"; +} +function normalizeStreams(payload) { + const streams = []; + const seenUrls = {}; + const sources = payload && Array.isArray(payload.sources) ? payload.sources : []; + for (const source of sources) { + const url = source && typeof source.url === "string" ? source.url.trim() : ""; + if (!url || seenUrls[url]) + continue; + seenUrls[url] = true; + const quality = getQuality(source); + streams.push({ + name: "Cineby", + title: `Cineby \xB7 ${quality}`, + url, + quality, + headers: { + "Referer": `${SITE_URL}/`, + "Origin": SITE_URL, + "User-Agent": HEADERS["User-Agent"] + } + }); + } + return streams; +} +async function extractStreams(tmdbId, mediaType, season, episode) { + if (mediaType !== "movie" && mediaType !== "tv") + return []; + if (mediaType === "tv" && (!isPositiveInteger(season) || !isPositiveInteger(episode))) + return []; + const resolvedTmdbId = await resolveTmdbId(tmdbId, mediaType); + if (!resolvedTmdbId) + return []; + const metadata = await getMetadata(resolvedTmdbId, mediaType); + const payload = await getSources( + resolvedTmdbId, + mediaType, + Number(season), + Number(episode), + metadata + ); + return normalizeStreams(payload); +} + +// src/cineby/index.js +async function getStreams(tmdbId, mediaType, season, episode) { + try { + const episodeLabel = mediaType === "tv" ? ` S${season}E${episode}` : ""; + console.log(`[Cineby] Request: ${mediaType} ${tmdbId}${episodeLabel}`); + const streams = await extractStreams(tmdbId, mediaType, season, episode); + console.log(`[Cineby] Found ${streams.length} stream(s)`); + return streams; + } catch (error) { + console.error(`[Cineby] Error: ${error.message}`); + return []; + } +} +module.exports = { getStreams }; diff --git a/src/cineby/extractor.js b/src/cineby/extractor.js new file mode 100644 index 0000000..c0e8d72 --- /dev/null +++ b/src/cineby/extractor.js @@ -0,0 +1,334 @@ +/** Resolve TMDB media through Cineby's encrypted source API. */ + +import { + buildQuery, + fetchJson, + fetchText, + HEADERS, + METADATA_API_URL, + SITE_URL, + SOURCE_API_URL +} from './http.js'; + +const STREAM_MAGIC = [109, 118, 109, 49]; // "mvm1" +const MIX_CONSTANTS = [ + 1116352408, 1899447441, 3049323471, 3921009573, + 961987163, 1508970993, 2453635748, 2870763221, + 3624381080, + 310598401, 607225278, 1426881987, 1925078388, + 2162078206, 2614888103, 3248222580 +]; +const CINEMETA_URL = 'https://v3-cinemeta.strem.io/meta'; +const TMDB_ID_CACHE = {}; + +function mix(value) { + value >>>= 0; + value ^= value >>> 16; + value = Math.imul(value, 2246822507) >>> 0; + value ^= value >>> 13; + value = Math.imul(value, 3266489909) >>> 0; + return (value ^= value >>> 16) >>> 0; +} + +function rotateLeft(value, amount) { + value >>>= 0; + amount &= 31; + return amount === 0 ? value : ((value << amount) | (value >>> (32 - amount))) >>> 0; +} + +function isEven(value) { + return (value * (value + 1) & 1) === 0; +} + +function isOdd(value) { + return (value * (value + 1) & 1) === 1; +} + +function decodeBase64Url(value) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, ''); + const bytes = []; + let buffer = 0; + let bits = 0; + + for (let index = 0; index < normalized.length; index++) { + const code = alphabet.indexOf(normalized[index]); + + if (code < 0) { + continue; + } + + buffer = (buffer << 6) | code; + bits += 6; + + if (bits >= 8) { + bits -= 8; + bytes.push((buffer >>> bits) & 255); + } + } + + return new Uint8Array(bytes); +} + +function decodeUtf8(bytes) { + if (typeof TextDecoder !== 'undefined') { + return new TextDecoder('utf-8').decode(bytes); + } + + let encoded = ''; + for (let index = 0; index < bytes.length; index++) { + encoded += `%${(`0${bytes[index].toString(16)}`).slice(-2)}`; + } + return decodeURIComponent(encoded); +} + +// This is Cineby's current seed-derived stream cipher, kept dependency-free for +// the React Native/Hermes runtime. It is ported directly from its player bundle. +function createKeyStream(seed, mediaId, length) { + const state = (function createState(seedValue, mediaIdentifier) { + if (isOdd(seedValue.length)) { + const values = Array(256); + for (let index = 0; index < 256; index++) values[index] = index; + + let cursor = 0; + for (let index = 0; index < 256; index++) { + cursor = (cursor + values[index] + seedValue.charCodeAt(index % seedValue.length)) & 255; + const current = values[index]; + values[index] = values[cursor]; + values[cursor] = current; + } + + let accumulator = 1732584193; + for (let index = 0; index < seedValue.length; index++) { + accumulator = rotateLeft( + (accumulator ^ Math.imul(seedValue.charCodeAt(index), MIX_CONSTANTS[15 & index])) >>> 0, + 5 + ); + } + + return { S: values, acc: mix(accumulator) }; + } + + const values = Array(61); + let seedHash = 2166136261; + for (let index = 0; index < seedValue.length; index++) { + seedHash = Math.imul(seedHash ^ seedValue.charCodeAt(index), 16777619) >>> 0; + } + let value = mix(mix(seedHash) ^ mix((mediaIdentifier >>> 0) ^ 2654435769)) >>> 0; + + for (let index = 0; index < 8; index++) { + if (isEven(index)) { + const position = value % 61; + value = rotateLeft((value + 2654435769) >>> 0, 7 + (7 & index)); + values[position] = (value ^ mix(value)) >>> 0; + value = mix((value + position) >>> 0); + } else { + values[index] = MIX_CONSTANTS[15 & index]; + } + } + + return { S: values, acc: mix(2779096485 ^ value) >>> 0 }; + })(seed, mediaId); + + const stream = new Uint8Array(length); + let streamIndex = 0; + let wordCounter = 0; + + while (streamIndex < length) { + const word = (function nextWord(currentState, counter) { + let previous; + let mixed; + const values = currentState.S; + let accumulator = currentState.acc; + const position = accumulator % 61; + const present = 0 - Number(position in values); + const stored = values[position] >>> 0; + let selected = (((previous = accumulator) ^ + (mixed = (stored ^ Math.imul(2654435769, counter + 1) >>> 0) >>> 0)) >>> 0 | + (previous & mixed & present) >>> 0) >>> 0; + + accumulator = mix((selected = ( + rotateLeft((selected + accumulator) >>> 0, 31 & position) ^ + rotateLeft(accumulator, 31 & Math.imul(position, 7)) + ) >>> 0) + 2654435769 >>> 0); + values[position] = accumulator >>> 0; + currentState.acc = accumulator; + return accumulator >>> 0; + })(state, wordCounter++); + + stream[streamIndex++] = word & 255; + if (streamIndex < length) stream[streamIndex++] = word >>> 8 & 255; + if (streamIndex < length) stream[streamIndex++] = word >>> 16 & 255; + if (streamIndex < length) stream[streamIndex++] = word >>> 24 & 255; + } + + return stream; +} + +function decryptPayload(raw, seed, mediaId) { + const encrypted = decodeBase64Url(raw); + const keyStream = createKeyStream(seed, mediaId, encrypted.length); + + for (let index = 0; index < encrypted.length; index++) { + encrypted[index] ^= keyStream[index]; + } + + for (let index = 0; index < STREAM_MAGIC.length; index++) { + if (encrypted[index] !== STREAM_MAGIC[index]) { + throw new Error('Cineby source payload could not be decrypted'); + } + } + + return decodeUtf8(encrypted.slice(STREAM_MAGIC.length)); +} + +function isPositiveInteger(value) { + return Number.isInteger(Number(value)) && Number(value) > 0; +} + +async function resolveTmdbId(value, mediaType) { + const id = String(value || '').trim(); + if (/^\d+$/.test(id)) return id; + if (!/^tt\d+$/i.test(id)) return null; + + const cacheKey = `${mediaType}:${id.toLowerCase()}`; + if (TMDB_ID_CACHE[cacheKey]) return TMDB_ID_CACHE[cacheKey]; + + const catalogType = mediaType === 'movie' ? 'movie' : 'series'; + const metadata = await fetchJson( + `${CINEMETA_URL}/${catalogType}/${encodeURIComponent(id)}.json`, + { headers: { 'Accept': 'application/json' } } + ); + const resolved = metadata && metadata.meta && metadata.meta.moviedb_id; + + if (!resolved || !/^\d+$/.test(String(resolved))) { + return null; + } + + TMDB_ID_CACHE[cacheKey] = String(resolved); + return TMDB_ID_CACHE[cacheKey]; +} + +async function getMetadata(tmdbId, mediaType) { + const endpoint = mediaType === 'movie' ? 'movie' : 'tv'; + const data = await fetchJson( + `${METADATA_API_URL}/${endpoint}/${encodeURIComponent(tmdbId)}?append_to_response=external_ids` + ); + const title = mediaType === 'movie' + ? data.title || data.original_title + : data.name || data.original_name; + const date = mediaType === 'movie' ? data.release_date : data.first_air_date; + const imdbId = data.imdb_id || (data.external_ids && data.external_ids.imdb_id) || ''; + + if (!title || !date) { + throw new Error('Cineby metadata response is incomplete'); + } + + return { + title, + year: String(date).slice(0, 4), + imdbId + }; +} + +async function getSeed(mediaId) { + const response = await fetchJson( + `${SOURCE_API_URL}/seed?${buildQuery({ mediaId })}` + ); + + if (!response || !response.seed) { + throw new Error('Cineby did not return a source seed'); + } + + return response.seed; +} + +async function getSources(tmdbId, mediaType, season, episode, metadata) { + const request = { + // Cineby's player pre-encodes the title, then its HTTP client serializes + // it again. Preserve that double encoding to match the live API. + title: encodeURIComponent(metadata.title), + mediaType, + year: metadata.year, + episodeId: mediaType === 'movie' ? 1 : episode, + seasonId: mediaType === 'movie' ? 1 : season, + tmdbId, + imdbId: metadata.imdbId, + enc: 2 + }; + + for (let attempt = 0; attempt < 2; attempt++) { + const seed = await getSeed(tmdbId); + const url = `${SOURCE_API_URL}/cdn/sources-with-title?${buildQuery({ ...request, seed })}`; + + try { + const raw = (await fetchText(url)).trim(); + if (raw.startsWith('{')) { + return JSON.parse(raw); + } + + return JSON.parse(decryptPayload(raw, seed, Number(tmdbId))); + } catch (error) { + if (attempt === 0 && error && error.status === 401) { + continue; + } + throw error; + } + } + + return { sources: [] }; +} + +function getQuality(source) { + const value = String(source && source.quality || ''); + if (/^4k$/i.test(value)) return '4K'; + + const match = value.match(/(2160|1440|1080|720|480|360)p?/i); + return match ? `${match[1]}p` : 'Auto'; +} + +function normalizeStreams(payload) { + const streams = []; + const seenUrls = {}; + const sources = payload && Array.isArray(payload.sources) ? payload.sources : []; + + for (const source of sources) { + const url = source && typeof source.url === 'string' ? source.url.trim() : ''; + if (!url || seenUrls[url]) continue; + + seenUrls[url] = true; + const quality = getQuality(source); + streams.push({ + name: 'Cineby', + title: `Cineby · ${quality}`, + url, + quality, + headers: { + 'Referer': `${SITE_URL}/`, + 'Origin': SITE_URL, + 'User-Agent': HEADERS['User-Agent'] + } + }); + } + + return streams; +} + +export async function extractStreams(tmdbId, mediaType, season, episode) { + if (mediaType !== 'movie' && mediaType !== 'tv') return []; + if (mediaType === 'tv' && (!isPositiveInteger(season) || !isPositiveInteger(episode))) return []; + + const resolvedTmdbId = await resolveTmdbId(tmdbId, mediaType); + if (!resolvedTmdbId) return []; + + const metadata = await getMetadata(resolvedTmdbId, mediaType); + const payload = await getSources( + resolvedTmdbId, + mediaType, + Number(season), + Number(episode), + metadata + ); + + return normalizeStreams(payload); +} diff --git a/src/cineby/http.js b/src/cineby/http.js new file mode 100644 index 0000000..f4086e0 --- /dev/null +++ b/src/cineby/http.js @@ -0,0 +1,48 @@ +/** HTTP helpers for Cineby's metadata and source APIs. */ + +export const SITE_URL = 'https://www.cineby.at'; +export const METADATA_API_URL = 'https://db.speedracelight.com/3'; +export const SOURCE_API_URL = 'https://api.speedracelight.com'; + +export const HEADERS = { + 'Accept': '*/*', + 'Origin': SITE_URL, + 'Referer': `${SITE_URL}/`, + 'User-Agent': 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/140.0.0.0 Mobile Safari/537.36' +}; + +function mergeHeaders(headers) { + return { + ...HEADERS, + ...(headers || {}) + }; +} + +/** Fetch text and retain the status code on errors for seed retries. */ +export async function fetchText(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: mergeHeaders(options.headers) + }); + + if (!response.ok) { + const error = new Error(`HTTP ${response.status} for ${url}`); + error.status = response.status; + throw error; + } + + return await response.text(); +} + +/** Fetch and parse a JSON response. */ +export async function fetchJson(url, options = {}) { + return JSON.parse(await fetchText(url, options)); +} + +/** Encode a query object without relying on URLSearchParams in Hermes. */ +export function buildQuery(params) { + return Object.keys(params) + .filter((key) => params[key] != null && params[key] !== '') + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`) + .join('&'); +} diff --git a/src/cineby/index.js b/src/cineby/index.js new file mode 100644 index 0000000..041e8e1 --- /dev/null +++ b/src/cineby/index.js @@ -0,0 +1,28 @@ +/** Cineby provider entry point. */ + +import { extractStreams } from './extractor.js'; + +/** + * Resolve a Nuvio media request to Cineby's current source API. + * + * @param {string} tmdbId TMDB numeric ID (IMDb tt IDs are also accepted) + * @param {string} mediaType 'movie' or 'tv' + * @param {number} season TV season number + * @param {number} episode TV episode number + * @returns {Promise} + */ +async function getStreams(tmdbId, mediaType, season, episode) { + try { + const episodeLabel = mediaType === 'tv' ? ` S${season}E${episode}` : ''; + console.log(`[Cineby] Request: ${mediaType} ${tmdbId}${episodeLabel}`); + + const streams = await extractStreams(tmdbId, mediaType, season, episode); + console.log(`[Cineby] Found ${streams.length} stream(s)`); + return streams; + } catch (error) { + console.error(`[Cineby] Error: ${error.message}`); + return []; + } +} + +module.exports = { getStreams };