add cineby
This commit is contained in:
1 parent
f502283d6c
commit
13f10fb7d8
5 files changed
+784
-1
No files matched your search
+25
-1
@@ -1,7 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "Nuvio Providers",
|
"name": "Nuvio Providers",
|
||||||
"version": "1.0.11",
|
"version": "1.0.12",
|
||||||
"scrapers": [
|
"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",
|
"id": "miruro",
|
||||||
"name": "Miruro",
|
"name": "Miruro",
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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('&');
|
||||||
|
}
|
||||||
@@ -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<Array>}
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
Reference in new issue
Block a user