├── providers ├── hianime │ └── .hianime_cache │ │ ├── hianime_slug_attack_on_titan_s1.json │ │ └── hianime_episodes_attack-on-titan-112.json ├── VidZee.js ├── soapertv.js ├── vixsrc.js ├── 4khdhub.js ├── hdrezkas.js ├── vidsrcextractor.js └── Showbox.js ├── Dockerfile ├── docker-compose.yml ├── vercel.json ├── manifest.json ├── LICENSE ├── package.json ├── .gitignore ├── .env.example ├── cookies.txt ├── utils ├── requestContext.js ├── redisCache.js └── linkResolver.js ├── README.md ├── scrapersdirect ├── animeflix_scraper.js ├── myflixer-extractor.js └── animepahe-scraper.js └── DOCUMENTATION.md /providers/hianime/.hianime_cache/hianime_slug_attack_on_titan_s1.json: -------------------------------------------------------------------------------- 1 | {"value":{"slug":"attack-on-titan-112","useRelativeEpisodeNumber":true},"expiry":1751443035481} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | RUN npm install --production 7 | 8 | COPY . . 9 | 10 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nuvio-addon: 3 | build: . 4 | image: nuvio-streams-addon:latest 5 | container_name: nuvio-streams-addon 6 | ports: 7 | - "7000:7000" 8 | env_file: 9 | - .env 10 | restart: unless-stopped 11 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "server.js", 6 | "use": "@vercel/node", 7 | "config": { "includeFiles": ["providers/**", "views/**"] } 8 | } 9 | ], 10 | "routes": [ 11 | { 12 | "src": "/(.*)", 13 | "dest": "server.js" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "org.nuvio.streams", 3 | "version": "0.5.10", 4 | "name": "Nuvio Streams | Elfhosted", 5 | "description": "Stremio addon for high-quality streaming links.", 6 | "logo": "https://raw.githubusercontent.com/tapframe/NuvioStreaming/refs/heads/appstore/assets/titlelogo.png", 7 | "resources": ["stream"], 8 | "types": ["movie", "series"], 9 | "idPrefixes": ["tt", "tmdb"], 10 | "catalogs": [], 11 | "behaviorHints": { 12 | "adult": false, 13 | "p2pNotSupported": true, 14 | "configurable": true 15 | }, 16 | "stremioAddonsConfig": { 17 | "issuer": "https://stremio-addons.net", 18 | "signature": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0..2YiTzY5hiYNywrpyMgv9Vg.vtorJ0blJ2dja5h7HSdL2eg0Hho7g8nJJnX2HbjJXSqzOU64niitT3FQItz9Q0rtF2sIswFcm842XgAnih0vPCTksxPwlkfDqolZcCjirQMl5hhcSzxJo88Mvonq2dxG.OYTTCCQcFthlmdn6c117CQ"} 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 tapframe and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuvio-streaming-addon", 3 | "version": "0.5.10", 4 | "description": "Stremio addon for HTTP streaming links", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "dependencies": { 10 | "aromanize": "^0.1.5", 11 | "axios": "^1.9.0", 12 | "axios-cookiejar-support": "^6.0.2", 13 | "body-parser": "^2.2.0", 14 | "bytes": "^3.1.2", 15 | "cheerio": "^1.0.0", 16 | "cors": "^2.8.5", 17 | "crypto-js": "^4.2.0", 18 | "dotenv": "^16.5.0", 19 | "express": "^4.21.2", 20 | "fast-levenshtein": "^3.0.0", 21 | "form-data": "^4.0.3", 22 | "hls-parser": "^0.13.5", 23 | "ioredis": "^5.6.1", 24 | "jsdom": "^26.1.0", 25 | "node-fetch": "^2.7.0", 26 | "p-limit": "^6.2.0", 27 | "puppeteer": "^24.11.0", 28 | "redis": "^5.5.5", 29 | "rot13-cipher": "^1.0.0", 30 | "stremio-addon-sdk": "^1.6.10", 31 | "string-similarity": "^4.0.4", 32 | "tough-cookie": "^5.1.2", 33 | "ttl-cache": "^1.0.2", 34 | "vm2": "^3.9.19", 35 | "yargs": "^17.7.2" 36 | }, 37 | "engines": { 38 | "node": ">=14.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | package-lock.json 4 | .vercel 5 | 6 | reddit_post.md 7 | reddit_post_title.md 8 | .env 9 | .cache 10 | misscalleneous/reddit_post_title.md 11 | misscalleneous/logs.txt 12 | misscalleneous/reddit_post.md 13 | misscalleneous 14 | 15 | # Environment variables 16 | .env 17 | 18 | # Cookies 19 | cookies.txt 20 | 21 | # IDE / OS specific 22 | .vscode/ 23 | .idea/ 24 | *.DS_Store 25 | *.project 26 | *.classpath 27 | *.cproject 28 | *.buildpath 29 | *.settings/ 30 | *.suo 31 | *.user 32 | *.sln.docstates 33 | 34 | # Python specific 35 | __pycache__/ 36 | *.pyc 37 | *.pyo 38 | *.pyd 39 | *.egg-info/ 40 | dist/ 41 | build/ 42 | *.egg 43 | venv/ 44 | env/ 45 | pip-wheel-metadata/ 46 | .Python 47 | *.tar.gz 48 | collaboration_message.md 49 | .streams_cache 50 | important_announcement.md 51 | envs.zip 52 | qa.md 53 | discord_announcement.md 54 | uhdmovies_scraper.js 55 | 56 | announcement_v2.md 57 | providers/hianime_back.js 58 | hianime_extractor.js 59 | cloudstream-extensions-phisher 60 | hianime-extractor.js 61 | /provider-service 62 | /web-gui 63 | providers/hdhub4u.js 64 | dahmer-movies-fetcher.js 65 | 66 | Extractor.kt 67 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Cache Settings 2 | DISABLE_CACHE=false 3 | DISABLE_STREAM_CACHE=true 4 | USE_REDIS_CACHE=false 5 | REDIS_URL= 6 | ENABLE_PSTREAM_API=false 7 | 8 | # URL Validation Settings 9 | # Set to true to disable URL validation for streaming links (faster but may return broken links) 10 | DISABLE_URL_VALIDATION=false 11 | # Set to true to disable URL validation specifically for 4KHDHub provider 12 | DISABLE_4KHDHUB_URL_VALIDATION=true 13 | 14 | # Global Proxy Configuration 15 | SHOWBOX_PROXY_URL_VALUE= 16 | SHOWBOX_PROXY_URL_ALTERNATE= 17 | SHOWBOX_USE_ROTATING_PROXY=false 18 | 19 | # Provider-specific Proxy URLs 20 | # If empty, will make direct requests without proxy 21 | VIDSRC_PROXY_URL= 22 | VIDZEE_PROXY_URL= 23 | SOAPERTV_PROXY_URL= 24 | HOLLYMOVIEHD_PROXY_URL= 25 | UHDMOVIES_PROXY_URL= 26 | MOVIESMOD_PROXY_URL= 27 | DRAMADRIP_PROXY_URL= 28 | HDHUB4U_PROXY_URL= 29 | TOPMOVIES_PROXY_URL= 30 | 31 | 32 | # Provider Enablement 33 | ENABLE_VIDZEE_PROVIDER=true 34 | ENABLE_HOLLYMOVIEHD_PROVIDER=false 35 | ENABLE_VIXSRC_PROVIDER=true 36 | 37 | # Anime configuration (placeholders kept minimal) 38 | 39 | # API Keys and External Services 40 | TMDB_API_KEY= 41 | 42 | 43 | # External Provider Services 44 | # Set to true to enable external provider microservices, false to use local providers only 45 | USE_EXTERNAL_PROVIDERS=false 46 | # External Provider Service URLs 47 | # Leave empty to use local providers, set URLs to use external microservices 48 | EXTERNAL_UHDMOVIES_URL= 49 | EXTERNAL_DRAMADRIP_URL= 50 | EXTERNAL_TOPMOVIES_URL= 51 | EXTERNAL_MOVIESMOD_URL= 52 | 53 | # Showbox proxy configuration 54 | # Comma-separated list for rotation; falls back to SHOWBOX_PROXY_URL_VALUE if empty 55 | SHOWBOX_PROXY_URLS= 56 | # SCRAPER_MODE=proxy 57 | # SCRAPER_API_KEY_VALUE= 58 | 59 | # Port configuration 60 | PORT=7777 61 | 62 | # Febbox proxy rotation list 63 | FEBBOX_PROXY_URLS= -------------------------------------------------------------------------------- /providers/hianime/.hianime_cache/hianime_episodes_attack-on-titan-112.json: -------------------------------------------------------------------------------- 1 | {"value":[{"title":"To You Two Thousand Years Later","episodeId":"attack-on-titan-112?ep=3303","number":1,"isFiller":false},{"title":"That Day","episodeId":"attack-on-titan-112?ep=3304","number":2,"isFiller":false},{"title":"Shining Dimly in the Midst of Despair","episodeId":"attack-on-titan-112?ep=3305","number":3,"isFiller":false},{"title":"Night of the Disbanding","episodeId":"attack-on-titan-112?ep=3306","number":4,"isFiller":false},{"title":"First Battle","episodeId":"attack-on-titan-112?ep=3307","number":5,"isFiller":false},{"title":"The World She Saw","episodeId":"attack-on-titan-112?ep=3308","number":6,"isFiller":false},{"title":"The Small Blade","episodeId":"attack-on-titan-112?ep=3309","number":7,"isFiller":false},{"title":"Hearing the Heartbeat","episodeId":"attack-on-titan-112?ep=3310","number":8,"isFiller":false},{"title":"The Left Arm's Trace","episodeId":"attack-on-titan-112?ep=3311","number":9,"isFiller":false},{"title":"Answer","episodeId":"attack-on-titan-112?ep=3312","number":10,"isFiller":false},{"title":"Idol","episodeId":"attack-on-titan-112?ep=3313","number":11,"isFiller":false},{"title":"Wound","episodeId":"attack-on-titan-112?ep=3314","number":12,"isFiller":false},{"title":"Primordial Desire","episodeId":"attack-on-titan-112?ep=3315","number":13,"isFiller":false},{"title":"Can't Look Into His Eyes","episodeId":"attack-on-titan-112?ep=3316","number":14,"isFiller":false},{"title":"Special Operations Squad","episodeId":"attack-on-titan-112?ep=3317","number":15,"isFiller":false},{"title":"What To Do Now","episodeId":"attack-on-titan-112?ep=3318","number":16,"isFiller":false},{"title":"The Female Titan","episodeId":"attack-on-titan-112?ep=3319","number":17,"isFiller":false},{"title":"The Forest of Giant Trees","episodeId":"attack-on-titan-112?ep=3320","number":18,"isFiller":false},{"title":"Bite","episodeId":"attack-on-titan-112?ep=3321","number":19,"isFiller":false},{"title":"Erwin Smith","episodeId":"attack-on-titan-112?ep=3322","number":20,"isFiller":false},{"title":"Crushing Blow","episodeId":"attack-on-titan-112?ep=3323","number":21,"isFiller":false},{"title":"The Defeated","episodeId":"attack-on-titan-112?ep=3324","number":22,"isFiller":false},{"title":"Smile","episodeId":"attack-on-titan-112?ep=3325","number":23,"isFiller":false},{"title":"Mercy","episodeId":"attack-on-titan-112?ep=3326","number":24,"isFiller":false},{"title":"Wall","episodeId":"attack-on-titan-112?ep=3327","number":25,"isFiller":false}],"expiry":1750924635892} -------------------------------------------------------------------------------- /cookies.txt: -------------------------------------------------------------------------------- 1 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg0Mzk5NDYsIm5iZiI6MTc0ODQzOTk0NiwiZXhwIjoxNzc5NTQzOTY2LCJkYXRhIjp7InVpZCI6NzkxNzY4LCJ0b2tlbiI6ImZlNTg4MjBjOGYxMmJkZWYzZDk4MmZmY2RjMTA2ZmFmIn19.xPu6Vw565rIuIsuYYZci-yL1ugC9-PdDcNRk40QVVsc 2 | 3 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg0NDAwMTQsIm5iZiI6MTc0ODQ0MDAxNCwiZXhwIjoxNzc5NTQ0MDM0LCJkYXRhIjp7InVpZCI6NzkxNzcwLCJ0b2tlbiI6ImM0NDc3OTJhNTFiN2U4YzI0OWFlOTNhNjRlZGIwZTU2In19.oxBR1Nr4AD8sAAZm1dT0XhvTZ8Q6iiG8e3J3FgiFkQg 4 | 5 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg0NDAwNTgsIm5iZiI6MTc0ODQ0MDA1OCwiZXhwIjoxNzc5NTQ0MDc4LCJkYXRhIjp7InVpZCI6NzkxNzcxLCJ0b2tlbiI6IjdmNGEwNmViMjMyY2Y4NDE0MTc3Yzg3N2U1OTE5NjgzIn19.ONPgKWnQyjdMIMBExTHWVLK5zT72tIjq92VYmx9M90M 6 | 7 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg0NDAxMjUsIm5iZiI6MTc0ODQ0MDEyNSwiZXhwIjoxNzc5NTQ0MTQ1LCJkYXRhIjp7InVpZCI6NzkxNzczLCJ0b2tlbiI6Ijg3OTFmODQ1YjYxZmI2MTBiMjU5YzdkYTg5OWU5ZmI1In19.EWtWz_g1TCvyjeSI4ZPuQIlrxD5HkJQo4r7KD-77ZUQ 8 | 9 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg0NDAxODMsIm5iZiI6MTc0ODQ0MDE4MywiZXhwIjoxNzc5NTQ0MjAzLCJkYXRhIjp7InVpZCI6NzkxNzc0LCJ0b2tlbiI6ImIxZmZhMmViYThjZTQ0ZTQ5Njg0ZWZlNjdjODlmYmY2In19.gpmYPsOXw-9fiCvyvhHX1IVfwB2pzR0tXdUFl1e4Y-w 10 | 11 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg0NDAyMTgsIm5iZiI6MTc0ODQ0MDIxOCwiZXhwIjoxNzc5NTQ0MjM4LCJkYXRhIjp7InVpZCI6NzkxNzc1LCJ0b2tlbiI6ImM2MTIyNmNhNDc1YTE3MzVjZDc3MTgwYjA0ZGM5NzVmIn19.eXYMkm-LLEVNPS8k06zywNHyh78g_pJG0qq58IX_QRo 12 | 13 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg0NDAyNTMsIm5iZiI6MTc0ODQ0MDI1MywiZXhwIjoxNzc5NTQ0MjczLCJkYXRhIjp7InVpZCI6NzkxNzc3LCJ0b2tlbiI6IjFmMWUwNWRhMjVlZDhlZTgxMjJjZDE3MDVjYzBmYTBkIn19.mvUeUXPMfQHKkjnSOX6ESiflhxP2c9LZY22CShyWwdM 14 | 15 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDgwNjYzMjgsIm5iZiI6MTc0ODA2NjMyOCwiZXhwIjoxNzc5MTcwMzQ4LCJkYXRhIjp7InVpZCI6NzgyNDcwLCJ0b2tlbiI6ImUwMTAyNjIyOWMyOTVlOTFlOTY0MWJjZWZiZGE4MGUxIn19.Za7tx60gu8rq9pLw1LVuIjROaBJzgF_MV049B8NO3L8 16 | 17 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg1NDg5MzksIm5iZiI6MTc0ODU0ODkzOSwiZXhwIjoxNzc5NjUyOTU5LCJkYXRhIjp7InVpZCI6NzkzMzk5LCJ0b2tlbiI6IjlhNzBmYzM0OGE5NTI5MmM0MmUxMWZhNzEwY2Y2MzFjIn19.KjyDpQh_sEdEXYSrB7TojsThb_R935zTVd3WoeTFiRE 18 | 19 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg1NDkwNDEsIm5iZiI6MTc0ODU0OTA0MSwiZXhwIjoxNzc5NjUzMDYxLCJkYXRhIjp7InVpZCI6Nzk0NDE0LCJ0b2tlbiI6IjRkNjY2NjhiY2U1NWJkM2NhNzkxYTkyNzlkOTU3M2I0In19.nDXwZeGZPWLC5IZRyNMekpPp88ANMNLueCQZnJcBAB8 20 | 21 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NDg1NDkxNjgsIm5iZiI6MTc0ODU0OTE2OCwiZXhwIjoxNzc5NjUzMTg4LCJkYXRhIjp7InVpZCI6Nzk0NDIxLCJ0b2tlbiI6IjY1MWJhNDhhM2IzODQyM2M5NWE1NTE1YmZmMDUzMjkxIn19.G99ckRadTLbb0DFr4YomBDIWLYxa3mQ59-KjO3SOhmA -------------------------------------------------------------------------------- /utils/requestContext.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Request Context using AsyncLocalStorage for per-request isolation 3 | * This ensures cookies and other request-specific data don't leak between concurrent requests 4 | */ 5 | 6 | const { AsyncLocalStorage } = require('async_hooks'); 7 | 8 | // Create the AsyncLocalStorage instance 9 | const requestContext = new AsyncLocalStorage(); 10 | 11 | /** 12 | * Get the current request's config 13 | * @returns {Object} The current request config or empty object if not in a request context 14 | */ 15 | function getRequestConfig() { 16 | const store = requestContext.getStore(); 17 | return store?.config || {}; 18 | } 19 | 20 | /** 21 | * Set a value in the current request's config 22 | * @param {string} key - The key to set 23 | * @param {any} value - The value to set 24 | */ 25 | function setRequestConfigValue(key, value) { 26 | const store = requestContext.getStore(); 27 | if (store && store.config) { 28 | store.config[key] = value; 29 | } 30 | } 31 | 32 | /** 33 | * Get a specific value from the current request's config 34 | * @param {string} key - The key to get 35 | * @param {any} defaultValue - Default value if key doesn't exist 36 | * @returns {any} The value or defaultValue 37 | */ 38 | function getRequestConfigValue(key, defaultValue = null) { 39 | const config = getRequestConfig(); 40 | return config[key] !== undefined ? config[key] : defaultValue; 41 | } 42 | 43 | /** 44 | * Run a function within a request context 45 | * @param {Object} config - The request config to use 46 | * @param {Function} fn - The function to run 47 | * @returns {any} The result of the function 48 | */ 49 | function runWithRequestContext(config, fn) { 50 | return requestContext.run({ config }, fn); 51 | } 52 | 53 | /** 54 | * Express middleware to set up request context 55 | * @param {Object} config - The config object to use for this request 56 | * @returns {Function} Express middleware function 57 | */ 58 | function createRequestContextMiddleware() { 59 | return (req, res, next) => { 60 | // Initialize empty config - will be populated by other middleware 61 | const config = {}; 62 | 63 | // Run the rest of the request within this context 64 | requestContext.run({ config }, () => { 65 | // Store reference on req for easy access in middleware 66 | req.nuvioConfig = config; 67 | next(); 68 | }); 69 | }; 70 | } 71 | 72 | module.exports = { 73 | requestContext, 74 | getRequestConfig, 75 | setRequestConfigValue, 76 | getRequestConfigValue, 77 | runWithRequestContext, 78 | createRequestContextMiddleware 79 | }; 80 | -------------------------------------------------------------------------------- /providers/VidZee.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | // Function to parse command line arguments 4 | const parseArgs = () => { 5 | const args = process.argv.slice(2); 6 | const options = {}; 7 | let i = 0; 8 | while (i < args.length) { 9 | const arg = args[i]; 10 | if (arg.startsWith('--')) { 11 | const key = arg.substring(2); 12 | if (i + 1 < args.length && !args[i + 1].startsWith('--')) { 13 | options[key] = args[i + 1]; 14 | i++; 15 | } else { 16 | options[key] = true; // Flag argument 17 | } 18 | } else { 19 | // Positional arguments, if any (none expected for this script based on current design) 20 | } 21 | i++; 22 | } 23 | return options; 24 | }; 25 | 26 | const getVidZeeStreams = async (tmdbId, mediaType, seasonNum, episodeNum) => { 27 | if (!tmdbId) { 28 | console.error('[VidZee] Error: TMDB ID (tmdbId) is required.'); 29 | return []; 30 | } 31 | 32 | if (!mediaType || (mediaType !== 'movie' && mediaType !== 'tv')) { 33 | console.error('[VidZee] Error: mediaType is required and must be either "movie" or "tv".'); 34 | return []; 35 | } 36 | 37 | if (mediaType === 'tv') { 38 | if (!seasonNum) { 39 | console.error('[VidZee] Error: Season (seasonNum) is required for TV shows.'); 40 | return []; 41 | } 42 | if (!episodeNum) { 43 | console.error('[VidZee] Error: Episode (episodeNum) is required for TV shows.'); 44 | return []; 45 | } 46 | } 47 | 48 | const servers = [3, 4, 5]; 49 | 50 | const streamPromises = servers.map(async (sr) => { 51 | let targetApiUrl = `https://player.vidzee.wtf/api/server?id=${tmdbId}&sr=${sr}`; 52 | 53 | if (mediaType === 'tv') { 54 | targetApiUrl += `&ss=${seasonNum}&ep=${episodeNum}`; 55 | } 56 | 57 | let finalApiUrl; 58 | let headers = { 59 | 'Referer': 'https://core.vidzee.wtf/' // Default for proxy method 60 | }; 61 | let timeout = 7000; // Reduced timeout 62 | 63 | const proxyBaseUrl = process.env.VIDZEE_PROXY_URL || process.env.SHOWBOX_PROXY_URL_VALUE; 64 | if (proxyBaseUrl) { 65 | finalApiUrl = proxyBaseUrl + encodeURIComponent(targetApiUrl); 66 | } else { 67 | finalApiUrl = targetApiUrl; 68 | } 69 | 70 | console.log(`[VidZee] Fetching from server ${sr}: ${targetApiUrl}`); 71 | 72 | try { 73 | const response = await axios.get(finalApiUrl, { 74 | headers: headers, 75 | timeout: timeout 76 | }); 77 | 78 | const responseData = response.data; 79 | 80 | if (!responseData || typeof responseData !== 'object') { 81 | console.error(`[VidZee S${sr}] Error: Invalid response data from API.`); 82 | return []; 83 | } 84 | 85 | if (responseData.tracks) { 86 | delete responseData.tracks; 87 | } 88 | 89 | let apiSources = []; 90 | if (responseData.url && Array.isArray(responseData.url)) { 91 | apiSources = responseData.url; 92 | } else if (responseData.link && typeof responseData.link === 'string') { 93 | apiSources = [responseData]; 94 | } 95 | 96 | if (!apiSources || apiSources.length === 0) { 97 | console.log(`[VidZee S${sr}] No stream sources found in API response.`); 98 | return []; 99 | } 100 | 101 | const streams = apiSources.map(sourceItem => { 102 | // Prefer sourceItem.name as label, fallback to sourceItem.type, then 'VidZee Stream' 103 | const label = sourceItem.name || sourceItem.type || 'VidZee'; 104 | // Ensure quality has 'p' if it's a resolution, or keep it as is 105 | const quality = String(label).match(/^\d+$/) ? `${label}p` : label; 106 | const language = sourceItem.language || sourceItem.lang; 107 | 108 | return { 109 | title: `VidZee S${sr} - ${quality}`, 110 | url: sourceItem.link, // Use sourceItem.link for the URL 111 | quality: quality, 112 | language: language, 113 | provider: "VidZee", 114 | size: "Unknown size", 115 | behaviorHints: { 116 | notWebReady: true, 117 | headers: { 118 | 'Referer': 'https://core.vidzee.wtf/' 119 | } 120 | } 121 | }; 122 | }).filter(stream => stream.url); 123 | 124 | console.log(`[VidZee S${sr}] Successfully extracted ${streams.length} streams.`); 125 | return streams; 126 | 127 | } catch (error) { 128 | if (error.response) { 129 | console.error(`[VidZee S${sr}] Error fetching: ${error.response.status} ${error.response.statusText}`); 130 | } else if (error.request) { 131 | console.error(`[VidZee S${sr}] Error fetching: No response received.`); 132 | } else { 133 | console.error(`[VidZee S${sr}] Error fetching:`, error.message); 134 | } 135 | return []; 136 | } 137 | }); 138 | 139 | const allStreamsNested = await Promise.all(streamPromises); 140 | const allStreams = allStreamsNested.flat(); 141 | 142 | console.log(`[VidZee] Found a total of ${allStreams.length} streams from servers ${servers.join(', ')}.`); 143 | return allStreams; 144 | }; 145 | 146 | module.exports = { getVidZeeStreams }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | [![Contributors][contributors-shield]][contributors-url] 6 | [![Forks][forks-shield]][forks-url] 7 | [![Stargazers][stars-shield]][stars-url] 8 | [![Issues][issues-shield]][issues-url] 9 | [![MIT License][license-shield]][license-url] 10 | 11 | 12 |
13 |
14 |

🎬 Nuvio Streams

15 |

16 | Direct HTTP streaming addon for Stremio 17 |
18 | Multiple providers • No P2P • Customizable quality settings 19 |
20 |
21 | Try Public Instance » 22 |
23 |
24 | View Demo 25 | · 26 | Report Bug 27 | · 28 | Request Feature 29 |

30 |
31 | 32 | 33 |
34 | Table of Contents 35 |
    36 |
  1. 37 | About The Project 38 | 42 |
  2. 43 |
  3. Public Instance
  4. 44 |
  5. 45 | Getting Started 46 | 51 |
  6. 52 |
  7. Usage Notes
  8. 53 |
  9. Contributing
  10. 54 |
  11. Support
  12. 55 |
  13. License
  14. 56 |
  15. Contact
  16. 57 |
  17. Acknowledgments
  18. 58 |
59 |
60 | 61 | 62 | ## About The Project 63 | 64 | Nuvio Streams is a powerful Stremio addon that provides direct HTTP streaming links for movies and TV shows from multiple online providers. Unlike torrent-based solutions, this addon focuses on delivering reliable, direct streams without P2P requirements. 65 | 66 | **Perfect for users who:** 67 | * Prefer direct HTTP streaming over debrid services 68 | * Want customizable provider and quality settings 69 | * Need reliable streaming without torrents/P2P 70 | * Are willing to configure personal cookies for optimal performance 71 | 72 |

(back to top)

73 | 74 | ### Key Features 75 | 76 | * **🌐 Multiple Sources** - Aggregates direct HTTP streams without P2P 77 | * **Personal Cookie Support** - Get your own quota and access to 4K/HDR content 78 | * **🎯 Quality Filtering** - Set minimum quality requirements 79 | * **🔒 No P2P/Torrents** - Only direct HTTP streams 80 | * **🎬 Full Compatibility** - Supports TMDB & IMDb IDs 81 | * **Easy Configuration** - Web-based settings management 82 | 83 |

(back to top)

84 | 85 | ### Built With 86 | 87 | * [![Node.js][Node.js]][Node-url] 88 | * [![Express.js][Express.js]][Express-url] 89 | * [![JavaScript][JavaScript]][JavaScript-url] 90 | 91 |

(back to top)

92 | 93 | 94 | ## Public Instance 95 | 96 | **🌍 Current Public Instance:** [https://nuviostreams.hayd.uk](https://nuviostreams.hayd.uk) 97 | 98 | * 💡 For the most reliable experience, consider self-hosting your own instance 99 | 100 |

(back to top)

101 | 102 | 103 | ## Getting Started 104 | 105 | Self-hosting provides the best experience with full access and personalized performance. For detailed setup and configuration instructions, please refer to our documentation. 106 | 107 | **[View the Self-Hosting Guide](https://github.com/tapframe/NuvioStreamsAddon/blob/master/DOCUMENTATION.md)** 108 | 109 |

(back to top)

110 | 111 | 112 | ## Usage Notes 113 | 114 | ### Troubleshooting 115 | 116 | If some links are missing, try a refresh or a second attempt (caching may help). If a site is blocked in your region, consider using a proxy and configure proxy URLs in your `.env` file. 117 | 118 |

(back to top)

119 | 120 | 121 | 122 |

(back to top)

123 | 124 | 125 | ## Contributing 126 | 127 | Contributions make the open source community amazing! Any contributions are **greatly appreciated**. 128 | 129 | 1. Fork the Project 130 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 131 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 132 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 133 | 5. Open a Pull Request 134 | 135 | ### Ways to Contribute 136 | 137 | * 🔧 **Code Contributions** - Improve core features or add new ones 138 | * 🐛 **Bug Reports** - Help identify and fix issues 139 | * 💡 **Feature Requests** - Suggest improvements 140 | * **Documentation** - Improve or translate docs 141 | * 🧪 **Testing** - Test on different platforms 142 | 143 |

(back to top)

144 | 145 | 146 | ## Support 147 | 148 | If you find Nuvio Streams useful, consider supporting development: 149 | 150 | * **[Ko-Fi](https://ko-fi.com/tapframe)** - Help with server costs 151 | * **GitHub Star** - Show your support 152 | * **Share** - Tell others about the project 153 | 154 |

(back to top)

155 | 156 | 157 | ## License 158 | 159 | Distributed under the MIT License. See `LICENSE` for more information. 160 | 161 |

(back to top)

162 | 163 | 164 | ## Contact 165 | 166 | **Project Links:** 167 | * GitHub: [https://github.com/tapframe](https://github.com/tapframe) 168 | * Issues: [https://github.com/tapframe/NuvioStreamsAddon/issues](https://github.com/tapframe/NuvioStreamsAddon/issues) 169 | * Public Instance: [https://nuviostreams.hayd.uk](https://nuviostreams.hayd.uk) 170 | 171 |

(back to top)

172 | 173 | 174 | ## Acknowledgments 175 | 176 | * [TMDB](https://www.themoviedb.org/) - Movie/TV metadata 177 | * [Stremio](https://www.stremio.com/) - Streaming platform 178 | * [ScraperAPI](https://www.scraperapi.com/) - Anti-scraping solution 179 | * [Netlify](https://www.netlify.com/) - Proxy hosting 180 | * Community contributors and testers 181 | 182 | **Disclaimer:** This addon scrapes third-party websites. Users are responsible for compliance with terms of service and local laws. For educational and personal use only. 183 | 184 |

(back to top)

185 | 186 | 187 | [contributors-shield]: https://img.shields.io/github/contributors/tapframe/NuvioStreamsAddon.svg?style=for-the-badge 188 | [contributors-url]: https://github.com/tapframe/NuvioStreamsAddon/graphs/contributors 189 | [forks-shield]: https://img.shields.io/github/forks/tapframe/NuvioStreamsAddon.svg?style=for-the-badge 190 | [forks-url]: https://github.com/tapframe/NuvioStreamsAddon/network/members 191 | [stars-shield]: https://img.shields.io/github/stars/tapframe/NuvioStreamsAddon.svg?style=for-the-badge 192 | [stars-url]: https://github.com/tapframe/NuvioStreamsAddon/stargazers 193 | [issues-shield]: https://img.shields.io/github/issues/tapframe/NuvioStreamsAddon.svg?style=for-the-badge 194 | [issues-url]: https://github.com/tapframe/NuvioStreamsAddon/issues 195 | [license-shield]: https://img.shields.io/github/license/tapframe/NuvioStreamsAddon.svg?style=for-the-badge 196 | [license-url]: https://github.com/tapframe/NuvioStreamsAddon/blob/master/LICENSE 197 | 198 | [Node.js]: https://img.shields.io/badge/Node.js-43853D?style=for-the-badge&logo=node.js&logoColor=white 199 | [Node-url]: https://nodejs.org/ 200 | [Express.js]: https://img.shields.io/badge/Express.js-404D59?style=for-the-badge&logo=express&logoColor=white 201 | [Express-url]: https://expressjs.com/ 202 | [JavaScript]: https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black 203 | [JavaScript-url]: https://developer.mozilla.org/en-US/docs/Web/JavaScript 204 | -------------------------------------------------------------------------------- /utils/redisCache.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const Redis = require('ioredis'); 3 | const fs = require('fs').promises; 4 | const path = require('path'); 5 | 6 | // Redis Cache Utility for NuvioStreams Providers 7 | class RedisCache { 8 | constructor(providerName = 'Generic') { 9 | this.providerName = providerName; 10 | this.redisClient = null; 11 | this.redisKeepAliveInterval = null; 12 | this.initializeRedis(); 13 | } 14 | 15 | initializeRedis() { 16 | if (process.env.USE_REDIS_CACHE === 'true') { 17 | try { 18 | console.log(`[${this.providerName} Cache] Initializing Redis. REDIS_URL from env: ${process.env.REDIS_URL ? 'exists and has value' : 'MISSING or empty'}`); 19 | if (!process.env.REDIS_URL) { 20 | throw new Error(`REDIS_URL environment variable is not set or is empty for ${this.providerName} Redis.`); 21 | } 22 | 23 | // Check if this is a local Redis instance or remote 24 | const isLocal = process.env.REDIS_URL.includes('localhost') || process.env.REDIS_URL.includes('127.0.0.1'); 25 | 26 | this.redisClient = new Redis(process.env.REDIS_URL, { 27 | maxRetriesPerRequest: 5, 28 | retryStrategy(times) { 29 | const delay = Math.min(times * 500, 5000); 30 | return delay; 31 | }, 32 | reconnectOnError: function(err) { 33 | const targetError = 'READONLY'; 34 | if (err.message.includes(targetError)) { 35 | return true; 36 | } 37 | return false; 38 | }, 39 | // TLS is optional - only use if explicitly specified with rediss:// protocol 40 | tls: process.env.REDIS_URL.startsWith('rediss://') ? {} : undefined, 41 | enableOfflineQueue: true, 42 | enableReadyCheck: true, 43 | autoResubscribe: true, 44 | autoResendUnfulfilledCommands: true, 45 | lazyConnect: false 46 | }); 47 | 48 | this.redisClient.on('connect', () => { 49 | console.log(`[${this.providerName} Cache] Successfully connected to Redis server.`); 50 | 51 | // Redis Keep-Alive for managed services like Upstash 52 | if (this.redisKeepAliveInterval) { 53 | clearInterval(this.redisKeepAliveInterval); 54 | } 55 | this.redisKeepAliveInterval = setInterval(async () => { 56 | try { 57 | await this.redisClient.ping(); 58 | } catch (pingError) { 59 | console.warn(`[${this.providerName} Cache] Redis keep-alive ping failed: ${pingError.message}`); 60 | } 61 | }, 4 * 60 * 1000); // 4 minutes 62 | }); 63 | 64 | this.redisClient.on('error', (err) => { 65 | console.error(`[${this.providerName} Cache] Redis connection error: ${err.message}`); 66 | }); 67 | 68 | this.redisClient.on('close', () => { 69 | console.log(`[${this.providerName} Cache] Redis connection closed.`); 70 | if (this.redisKeepAliveInterval) { 71 | clearInterval(this.redisKeepAliveInterval); 72 | this.redisKeepAliveInterval = null; 73 | } 74 | }); 75 | 76 | } catch (initError) { 77 | console.error(`[${this.providerName} Cache] Failed to initialize Redis client: ${initError.message}`); 78 | this.redisClient = null; 79 | } 80 | } else { 81 | console.log(`[${this.providerName} Cache] Redis cache is disabled (USE_REDIS_CACHE is not 'true'). Using file system cache only.`); 82 | } 83 | } 84 | 85 | async getFromCache(cacheKey, subDir = '', cacheDir = null) { 86 | if (process.env.DISABLE_CACHE === 'true') { 87 | console.log(`[${this.providerName} Cache] CACHE DISABLED: Skipping read for ${path.join(subDir, cacheKey)}`); 88 | return null; 89 | } 90 | 91 | const fullCacheKey = subDir ? `${this.providerName.toLowerCase()}:${subDir}:${cacheKey}` : `${this.providerName.toLowerCase()}:${cacheKey}`; 92 | 93 | // Try to get from Redis first 94 | if (this.redisClient && this.redisClient.status === 'ready') { 95 | try { 96 | const redisData = await this.redisClient.get(fullCacheKey); 97 | if (redisData !== null) { 98 | console.log(`[${this.providerName} Cache] REDIS CACHE HIT for: ${fullCacheKey}`); 99 | try { 100 | return JSON.parse(redisData); 101 | } catch (e) { 102 | return redisData; 103 | } 104 | } 105 | console.log(`[${this.providerName} Cache] REDIS CACHE MISS for: ${fullCacheKey}`); 106 | } catch (redisError) { 107 | console.warn(`[${this.providerName} Cache] REDIS CACHE READ ERROR for ${fullCacheKey}: ${redisError.message}. Falling back to file system cache.`); 108 | } 109 | } else if (this.redisClient) { 110 | console.log(`[${this.providerName} Cache] Redis client not ready (status: ${this.redisClient.status}). Skipping Redis read for ${fullCacheKey}, trying file system.`); 111 | } 112 | 113 | // Fallback to file system cache 114 | if (cacheDir) { 115 | const cachePath = path.join(cacheDir, subDir, `${cacheKey}.json`); 116 | try { 117 | const fileData = await fs.readFile(cachePath, 'utf-8'); 118 | console.log(`[${this.providerName} Cache] FILE SYSTEM CACHE HIT for: ${path.join(subDir, cacheKey)}`); 119 | 120 | // If Redis is available, populate Redis for next time (permanent cache) 121 | if (this.redisClient && this.redisClient.status === 'ready') { 122 | try { 123 | await this.redisClient.set(fullCacheKey, fileData); 124 | console.log(`[${this.providerName} Cache] Populated REDIS CACHE from FILE SYSTEM for: ${fullCacheKey} (PERMANENT - no expiration)`); 125 | } catch (redisSetError) { 126 | console.warn(`[${this.providerName} Cache] REDIS CACHE SET ERROR (after file read) for ${fullCacheKey}: ${redisSetError.message}`); 127 | } 128 | } 129 | 130 | try { 131 | return JSON.parse(fileData); 132 | } catch (e) { 133 | return fileData; 134 | } 135 | } catch (error) { 136 | if (error.code !== 'ENOENT') { 137 | console.warn(`[${this.providerName} Cache] FILE SYSTEM CACHE READ ERROR for ${cacheKey}: ${error.message}`); 138 | } else { 139 | console.log(`[${this.providerName} Cache] FILE SYSTEM CACHE MISS for: ${path.join(subDir, cacheKey)}`); 140 | } 141 | return null; 142 | } 143 | } 144 | 145 | return null; 146 | } 147 | 148 | async saveToCache(cacheKey, content, subDir = '', cacheDir = null, ttlSeconds = null) { 149 | if (process.env.DISABLE_CACHE === 'true') { 150 | console.log(`[${this.providerName} Cache] CACHE DISABLED: Skipping write for ${path.join(subDir, cacheKey)}`); 151 | return; 152 | } 153 | 154 | const dataToSave = typeof content === 'string' ? content : JSON.stringify(content, null, 2); 155 | const fullCacheKey = subDir ? `${this.providerName.toLowerCase()}:${subDir}:${cacheKey}` : `${this.providerName.toLowerCase()}:${cacheKey}`; 156 | 157 | // Attempt to save to Redis first with optional TTL 158 | if (this.redisClient && this.redisClient.status === 'ready') { 159 | try { 160 | if (ttlSeconds) { 161 | await this.redisClient.setex(fullCacheKey, ttlSeconds, dataToSave); 162 | console.log(`[${this.providerName} Cache] SAVED TO REDIS CACHE: ${fullCacheKey} (TTL: ${ttlSeconds}s)`); 163 | } else { 164 | await this.redisClient.set(fullCacheKey, dataToSave); 165 | console.log(`[${this.providerName} Cache] SAVED TO REDIS CACHE: ${fullCacheKey} (PERMANENT - no expiration)`); 166 | } 167 | } catch (redisError) { 168 | console.warn(`[${this.providerName} Cache] REDIS CACHE WRITE ERROR for ${fullCacheKey}: ${redisError.message}. Proceeding with file system cache.`); 169 | } 170 | } else if (this.redisClient) { 171 | console.log(`[${this.providerName} Cache] Redis client not ready (status: ${this.redisClient.status}). Skipping Redis write for ${fullCacheKey}.`); 172 | } 173 | 174 | // Always save to file system cache as a fallback 175 | if (cacheDir) { 176 | try { 177 | const fullSubDir = path.join(cacheDir, subDir); 178 | await this.ensureCacheDir(fullSubDir); 179 | const cachePath = path.join(fullSubDir, `${cacheKey}.json`); 180 | await fs.writeFile(cachePath, dataToSave, 'utf-8'); 181 | console.log(`[${this.providerName} Cache] SAVED TO FILE SYSTEM CACHE: ${path.join(subDir, cacheKey)}`); 182 | } catch (error) { 183 | console.warn(`[${this.providerName} Cache] FILE SYSTEM CACHE WRITE ERROR for ${cacheKey}: ${error.message}`); 184 | } 185 | } 186 | } 187 | 188 | // TTL method removed - all cache entries are now permanent 189 | // getTTLForSubDir(subDir) { 190 | // // This method is no longer used as all cache entries are permanent 191 | // // Previously configured TTL based on data type but now all data persists indefinitely 192 | // } 193 | 194 | async ensureCacheDir(dirPath) { 195 | try { 196 | await fs.mkdir(dirPath, { recursive: true }); 197 | } catch (error) { 198 | if (error.code !== 'EEXIST') { 199 | console.warn(`[${this.providerName} Cache] Warning: Could not create cache directory ${dirPath}: ${error.message}`); 200 | } 201 | } 202 | } 203 | 204 | // Cleanup method 205 | cleanup() { 206 | if (this.redisKeepAliveInterval) { 207 | clearInterval(this.redisKeepAliveInterval); 208 | this.redisKeepAliveInterval = null; 209 | } 210 | if (this.redisClient) { 211 | this.redisClient.disconnect(); 212 | this.redisClient = null; 213 | } 214 | } 215 | } 216 | 217 | module.exports = RedisCache; -------------------------------------------------------------------------------- /providers/soapertv.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); // As per original script: npm install node-fetch@2 2 | const cheerio = require('cheerio'); // As per original script: npm install cheerio 3 | const { URLSearchParams } = require('url'); // For form data 4 | 5 | // Constants 6 | const PROXY_URL = process.env.SOAPERTV_PROXY_URL || process.env.SHOWBOX_PROXY_URL_VALUE; 7 | const BASE_URL = 'https://soaper.cc'; 8 | const TMDB_API_KEY_SOAPERTV = "439c478a771f35c05022f9feabcca01c"; // Public TMDB API key used by this provider 9 | 10 | // Simple In-Memory Cache 11 | const soaperCache = { 12 | search: {}, 13 | episodes: {} 14 | }; 15 | const CACHE_TTL = 30 * 60 * 1000; // 30 minutes TTL for cache entries 16 | 17 | // Function to get from cache 18 | function getFromCache(type, key) { 19 | if (soaperCache[type] && soaperCache[type][key]) { 20 | const entry = soaperCache[type][key]; 21 | if (Date.now() - entry.timestamp < CACHE_TTL) { 22 | console.log(`[Soaper TV Cache] HIT for ${type} - ${key}`); 23 | return entry.data; 24 | } 25 | console.log(`[Soaper TV Cache] STALE for ${type} - ${key}`); 26 | delete soaperCache[type][key]; // Remove stale entry 27 | } 28 | console.log(`[Soaper TV Cache] MISS for ${type} - ${key}`); 29 | return null; 30 | } 31 | 32 | // Function to save to cache 33 | function saveToCache(type, key, data) { 34 | if (!soaperCache[type]) soaperCache[type] = {}; 35 | soaperCache[type][key] = { 36 | data: data, 37 | timestamp: Date.now() 38 | }; 39 | console.log(`[Soaper TV Cache] SAVED for ${type} - ${key}`); 40 | } 41 | 42 | // Proxy wrapper for fetch 43 | async function proxiedFetchSoaper(url, options = {}, isFullUrlOverride = false) { 44 | const isHttpUrl = url.startsWith('http://') || url.startsWith('https://'); 45 | const fullUrl = isHttpUrl || isFullUrlOverride ? url : `${BASE_URL}${url}`; 46 | 47 | let fetchUrl; 48 | if (PROXY_URL) { 49 | fetchUrl = `${PROXY_URL}${encodeURIComponent(fullUrl)}`; 50 | console.log(`[Soaper TV] Fetching: ${url} (via proxy: ${fetchUrl.substring(0,100)}...)`); 51 | } else { 52 | fetchUrl = fullUrl; 53 | console.log(`[Soaper TV] Fetching: ${url} (direct request)`); 54 | } 55 | 56 | try { 57 | const response = await fetch(fetchUrl, options); 58 | 59 | if (!response.ok) { 60 | let errorBody = ''; 61 | try { 62 | errorBody = await response.text(); 63 | } catch (e) { /* ignore */ } 64 | throw new Error(`Response not OK: ${response.status} ${response.statusText}. Body: ${errorBody.substring(0,200)}`); 65 | } 66 | 67 | const contentType = response.headers.get('content-type'); 68 | if (contentType && contentType.includes('application/json')) { 69 | return response.json(); 70 | } 71 | return response.text(); 72 | } catch (error) { 73 | console.error(`[Soaper TV] Fetch error for ${url}:`, error.message); 74 | throw error; 75 | } 76 | } 77 | 78 | // Compare media to find matching result 79 | function compareMediaSoaper(media, title, year) { 80 | const normalizeString = (str) => String(str || '').toLowerCase().replace(/[^a-zA-Z0-9]/g, ''); 81 | const normalizedMediaTitle = normalizeString(media.title); 82 | const normalizedResultTitle = normalizeString(title); 83 | 84 | if (normalizedMediaTitle !== normalizedResultTitle) { 85 | return false; 86 | } 87 | 88 | if (year && media.year && media.year !== year) { 89 | return false; 90 | } 91 | 92 | return true; 93 | } 94 | 95 | async function getSoaperTvStreams(tmdbId, mediaType = 'movie', season = '', episode = '') { 96 | console.log(`[Soaper TV] Attempting to fetch streams for TMDB ID: ${tmdbId}, Type: ${mediaType}${mediaType === 'tv' ? `, S:${season}E:${episode}` : ''}`); 97 | try { 98 | const tmdbUrl = mediaType === 'movie' 99 | ? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${TMDB_API_KEY_SOAPERTV}` 100 | : `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=${TMDB_API_KEY_SOAPERTV}`; 101 | 102 | console.log(`[Soaper TV] Fetching TMDB info from: ${tmdbUrl}`); 103 | const tmdbResponse = await fetch(tmdbUrl); // Direct fetch for TMDB API 104 | if (!tmdbResponse.ok) { 105 | const errorBody = await tmdbResponse.text(); 106 | throw new Error(`TMDB API request failed: ${tmdbResponse.status} ${tmdbResponse.statusText}. Body: ${errorBody.substring(0,200)}`); 107 | } 108 | const tmdbData = await tmdbResponse.json(); 109 | 110 | if (tmdbData.success === false) { 111 | throw new Error(`TMDB API error: ${tmdbData.status_message || 'Unknown TMDB error'}`); 112 | } 113 | 114 | const mediaInfo = { 115 | title: mediaType === 'movie' ? tmdbData.title : tmdbData.name, 116 | year: parseInt(mediaType === 'movie' 117 | ? (tmdbData.release_date || '').split('-')[0] 118 | : (tmdbData.first_air_date || '').split('-')[0], 10) 119 | }; 120 | 121 | if (!mediaInfo.title) { 122 | console.error('[Soaper TV] Failed to get title from TMDB data:', tmdbData); 123 | throw new Error('Could not extract title from TMDB response.'); 124 | } 125 | console.log(`[Soaper TV] TMDB Info: "${mediaInfo.title}" (${mediaInfo.year || 'N/A'})`); 126 | 127 | const searchCacheKey = mediaInfo.title.toLowerCase(); 128 | let searchResults = getFromCache('search', searchCacheKey); 129 | 130 | if (!searchResults) { 131 | const searchUrl = `/search.html?keyword=${encodeURIComponent(mediaInfo.title)}`; 132 | const searchResultHtml = await proxiedFetchSoaper(searchUrl); 133 | const search$ = cheerio.load(searchResultHtml); 134 | 135 | searchResults = []; // Initialize to empty array before pushing 136 | search$('.thumbnail').each((_, element) => { 137 | const title = search$(element).find('h5 a').first().text().trim(); 138 | const yearText = search$(element).find('.img-tip').first().text().trim(); 139 | const url = search$(element).find('h5 a').first().attr('href'); 140 | 141 | if (title && url) { 142 | searchResults.push({ 143 | title, 144 | year: yearText ? parseInt(yearText, 10) : undefined, 145 | url 146 | }); 147 | } 148 | }); 149 | saveToCache('search', searchCacheKey, searchResults); 150 | } else { 151 | console.log(`[Soaper TV] Using cached search results for "${mediaInfo.title}".`); 152 | } 153 | 154 | console.log(`[Soaper TV] Found ${searchResults.length} search results for "${mediaInfo.title}".`); 155 | 156 | const matchingResult = searchResults.find(x => compareMediaSoaper(mediaInfo, x.title, x.year)); 157 | 158 | if (!matchingResult) { 159 | console.log(`[Soaper TV] No matching content found on SoaperTV for "${mediaInfo.title}" (${mediaInfo.year || 'N/A'}).`); 160 | return []; 161 | } 162 | 163 | console.log(`[Soaper TV] Found matching SoaperTV content: "${matchingResult.title}" (${matchingResult.year || 'N/A'}) at ${matchingResult.url}`); 164 | let contentUrl = matchingResult.url; 165 | 166 | if (mediaType === 'tv') { 167 | console.log(`[Soaper TV] Finding Season ${season}, Episode ${episode} for TV show.`); 168 | 169 | const episodeCacheKey = `${contentUrl}-s${season}`.toLowerCase(); 170 | let episodeLinks = getFromCache('episodes', episodeCacheKey); 171 | 172 | if (!episodeLinks) { 173 | const showPageHtml = await proxiedFetchSoaper(contentUrl); 174 | const showPage$ = cheerio.load(showPageHtml); 175 | 176 | const seasonBlock = showPage$('h4') 177 | .filter((_, el) => showPage$(el).text().trim().split(':')[0].trim().toLowerCase() === `season${season}`) 178 | .parent(); 179 | 180 | if (seasonBlock.length === 0) { 181 | console.log(`[Soaper TV] Season ${season} not found on page.`); 182 | return []; 183 | } 184 | 185 | episodeLinks = []; // Initialize before pushing 186 | seasonBlock.find('a').each((_, el) => { 187 | const episodeNumText = showPage$(el).text().split('.')[0]; 188 | const episodeUrl = showPage$(el).attr('href'); 189 | if (episodeNumText && episodeUrl) { 190 | episodeLinks.push({ 191 | num: parseInt(episodeNumText, 10), 192 | url: episodeUrl 193 | }); 194 | } 195 | }); 196 | saveToCache('episodes', episodeCacheKey, episodeLinks); 197 | } else { 198 | console.log(`[Soaper TV] Using cached episode links for Season ${season} of ${contentUrl}.`); 199 | } 200 | 201 | const targetEpisode = episodeLinks.find(ep => ep.num === parseInt(episode, 10)); 202 | 203 | if (!targetEpisode) { 204 | console.log(`[Soaper TV] Episode ${episode} not found in Season ${season} (using ${episodeLinks.length} cached/parsed links).`); 205 | return []; 206 | } 207 | 208 | contentUrl = targetEpisode.url; 209 | console.log(`[Soaper TV] Found episode page (from cache/parse): ${contentUrl}`); 210 | } 211 | 212 | const contentPageHtml = await proxiedFetchSoaper(contentUrl); 213 | const contentPage$ = cheerio.load(contentPageHtml); 214 | const pass = contentPage$('#hId').attr('value'); 215 | 216 | if (!pass) { 217 | console.error('[Soaper TV] Could not find pass value on content page.'); 218 | return []; 219 | } 220 | console.log(`[Soaper TV] Found pass value: ${pass}`); 221 | 222 | const infoEndpoint = mediaType === 'tv' ? '/home/index/getEInfoAjax' : '/home/index/getMInfoAjax'; 223 | const formData = new URLSearchParams(); 224 | formData.append('pass', pass); 225 | formData.append('e2', '0'); // Default value from original script 226 | formData.append('server', '0'); // Default value from original script 227 | 228 | const headers = { 229 | 'referer': `${BASE_URL}${contentUrl}`, // Critical for the API call 230 | 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', 231 | 'Viewport-Width': '375', 232 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', // Specify charset 233 | 'X-Requested-With': 'XMLHttpRequest' // Often needed for AJAX endpoints 234 | }; 235 | 236 | console.log(`[Soaper TV] Requesting stream info from ${infoEndpoint} with pass ${pass}.`); 237 | const streamInfoResponse = await proxiedFetchSoaper(infoEndpoint, { 238 | method: 'POST', 239 | body: formData.toString(), 240 | headers: headers 241 | }); 242 | 243 | let streamInfo; 244 | if (typeof streamInfoResponse === 'string') { 245 | try { 246 | streamInfo = JSON.parse(streamInfoResponse); 247 | } catch (e) { 248 | console.error('[Soaper TV] Failed to parse stream info JSON:', streamInfoResponse.substring(0, 500)); 249 | return []; 250 | } 251 | } else { 252 | streamInfo = streamInfoResponse; // Assuming it's already JSON 253 | } 254 | 255 | if (!streamInfo || !streamInfo.val || typeof streamInfo.val !== 'string') { 256 | console.error('[Soaper TV] No valid stream URL (val) found in response:', streamInfo); 257 | return []; 258 | } 259 | 260 | const streamPath = streamInfo.val; 261 | // Ensure streamPath doesn't already start with BASE_URL or http 262 | const finalStreamUrl = streamPath.startsWith('http') ? streamPath : (streamPath.startsWith('/') ? `${BASE_URL}${streamPath}` : `${BASE_URL}/${streamPath}`); 263 | 264 | console.log(`[Soaper TV] Found stream source: ${finalStreamUrl}`); 265 | 266 | const proxiedStreamUrl = `${PROXY_URL}${encodeURIComponent(finalStreamUrl)}`; 267 | 268 | return [{ 269 | url: proxiedStreamUrl, 270 | quality: 'Auto Quality', 271 | provider: 'Soaper TV', 272 | title: `${mediaInfo.title}${mediaType === 'tv' ? ` S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` : ''} - Soaper TV`, 273 | name: `Soaper TV - Auto`, // Shorter name for Stremio UI 274 | behaviorHints: { 275 | notWebReady: true 276 | }, 277 | 源: 'SoaperTV', // Using Chinese character for "source" as seen in other provider 278 | codecs: [], // SoaperTV does not provide detailed codec info easily 279 | size: 'N/A' 280 | }]; 281 | 282 | } catch (error) { 283 | console.error(`[Soaper TV] Error in getSoaperTvStreams for TMDB ID ${tmdbId}:`, error.message, error.stack); 284 | return []; 285 | } 286 | } 287 | 288 | module.exports = { getSoaperTvStreams }; -------------------------------------------------------------------------------- /providers/vixsrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Vixsrc streaming provider integration for Stremio 3 | * React Native compatible version - Standalone (no external dependencies) 4 | * Converted to Promise-based syntax for sandbox compatibility 5 | */ 6 | 7 | // Constants 8 | const TMDB_API_KEY = "68e094699525b18a70bab2f86b1fa706"; 9 | const BASE_URL = 'https://vixsrc.to'; 10 | 11 | // Helper function to make HTTP requests with default headers 12 | function makeRequest(url, options = {}) { 13 | const defaultHeaders = { 14 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 15 | 'Accept': 'application/json,*/*', 16 | 'Accept-Language': 'en-US,en;q=0.5', 17 | 'Accept-Encoding': 'gzip, deflate', 18 | 'Connection': 'keep-alive', 19 | ...options.headers 20 | }; 21 | 22 | return fetch(url, { 23 | method: options.method || 'GET', 24 | headers: defaultHeaders, 25 | ...options 26 | }) 27 | .then(response => { 28 | if (!response.ok) { 29 | throw new Error(`HTTP ${response.status}: ${response.statusText}`); 30 | } 31 | return response; 32 | }) 33 | .catch(error => { 34 | console.error(`[Vixsrc] Request failed for ${url}: ${error.message}`); 35 | throw error; 36 | }); 37 | } 38 | 39 | // Helper function to get TMDB info 40 | function getTmdbInfo(tmdbId, mediaType) { 41 | const url = `https://api.themoviedb.org/3/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}?api_key=${TMDB_API_KEY}`; 42 | 43 | return makeRequest(url) 44 | .then(response => response.json()) 45 | .then(data => { 46 | const title = mediaType === 'tv' ? data.name : data.title; 47 | const year = mediaType === 'tv' ? data.first_air_date?.substring(0, 4) : data.release_date?.substring(0, 4); 48 | 49 | if (!title) { 50 | throw new Error('Could not extract title from TMDB response'); 51 | } 52 | 53 | console.log(`[Vixsrc] TMDB Info: "${title}" (${year})`); 54 | return { title, year, data }; 55 | }); 56 | } 57 | 58 | // Helper function to parse M3U8 playlist 59 | function parseM3U8Playlist(content, baseUrl) { 60 | const streams = []; 61 | const audioTracks = []; 62 | const lines = content.split('\n'); 63 | 64 | let currentStream = null; 65 | 66 | for (let i = 0; i < lines.length; i++) { 67 | const line = lines[i].trim(); 68 | 69 | // Parse video streams 70 | if (line.startsWith('#EXT-X-STREAM-INF:')) { 71 | // Parse stream info 72 | const bandwidthMatch = line.match(/BANDWIDTH=(\d+)/); 73 | const resolutionMatch = line.match(/RESOLUTION=(\d+x\d+)/); 74 | const nameMatch = line.match(/NAME="([^"]+)"/) || line.match(/NAME=([^,]+)/); 75 | 76 | if (bandwidthMatch) { 77 | currentStream = { 78 | bandwidth: parseInt(bandwidthMatch[1]), 79 | resolution: resolutionMatch ? resolutionMatch[1] : 'Unknown', 80 | quality: nameMatch ? nameMatch[1] : getQualityFromResolution(resolutionMatch ? resolutionMatch[1] : 'Unknown'), 81 | url: '' 82 | }; 83 | } 84 | } 85 | // Parse audio tracks 86 | else if (line.startsWith('#EXT-X-MEDIA:')) { 87 | const typeMatch = line.match(/TYPE=([^,]+)/); 88 | const nameMatch = line.match(/NAME="([^"]+)"/); 89 | const groupIdMatch = line.match(/GROUP-ID="([^"]+)"/); 90 | const languageMatch = line.match(/LANGUAGE="([^"]+)"/); 91 | const uriMatch = line.match(/URI="([^"]+)"/); 92 | 93 | if (typeMatch && typeMatch[1] === 'AUDIO') { 94 | const audioTrack = { 95 | type: 'audio', 96 | name: nameMatch ? nameMatch[1] : 'Unknown Audio', 97 | groupId: groupIdMatch ? groupIdMatch[1] : 'unknown', 98 | language: languageMatch ? languageMatch[1] : 'unknown', 99 | url: uriMatch ? resolveUrl(uriMatch[1], baseUrl) : null 100 | }; 101 | audioTracks.push(audioTrack); 102 | } 103 | } 104 | // Handle URLs for video streams 105 | else if (line.startsWith('http') && currentStream) { 106 | // This is the URL for the current video stream 107 | currentStream.url = line.startsWith('http') ? line : resolveUrl(line, baseUrl); 108 | streams.push(currentStream); 109 | currentStream = null; 110 | } 111 | } 112 | 113 | console.log(`[Vixsrc] Found ${audioTracks.length} audio tracks:`); 114 | audioTracks.forEach((track, index) => { 115 | console.log(` ${index + 1}. ${track.name} (${track.language}) - ${track.url ? 'Available' : 'No URL'}`); 116 | }); 117 | 118 | return { streams, audioTracks }; 119 | } 120 | 121 | // Helper function to get quality from resolution 122 | function getQualityFromResolution(resolution) { 123 | if (resolution.includes('1920x1080') || resolution.includes('1080')) { 124 | return '1080p'; 125 | } else if (resolution.includes('1280x720') || resolution.includes('720')) { 126 | return '720p'; 127 | } else if (resolution.includes('854x480') || resolution.includes('640x480') || resolution.includes('480')) { 128 | return '480p'; 129 | } else if (resolution.includes('640x360') || resolution.includes('360')) { 130 | return '360p'; 131 | } else { 132 | return resolution; 133 | } 134 | } 135 | 136 | // Helper function to resolve URLs 137 | function resolveUrl(url, baseUrl) { 138 | if (url.startsWith('http')) { 139 | return url; 140 | } 141 | 142 | // Handle relative URLs 143 | const baseUrlObj = new URL(baseUrl); 144 | if (url.startsWith('/')) { 145 | return `${baseUrlObj.protocol}//${baseUrlObj.host}${url}`; 146 | } else { 147 | const basePath = baseUrlObj.pathname.substring(0, baseUrlObj.pathname.lastIndexOf('/') + 1); 148 | return `${baseUrlObj.protocol}//${baseUrlObj.host}${basePath}${url}`; 149 | } 150 | } 151 | 152 | // Helper function to extract stream URL from Vixsrc page 153 | function extractStreamFromPage(url, contentType, contentId, seasonNum, episodeNum) { 154 | let vixsrcUrl; 155 | let subtitleApiUrl; 156 | 157 | if (contentType === 'movie') { 158 | vixsrcUrl = `${BASE_URL}/movie/${contentId}`; 159 | subtitleApiUrl = `https://sub.wyzie.ru/search?id=${contentId}`; 160 | } else { 161 | vixsrcUrl = `${BASE_URL}/tv/${contentId}/${seasonNum}/${episodeNum}`; 162 | subtitleApiUrl = `https://sub.wyzie.ru/search?id=${contentId}&season=${seasonNum}&episode=${episodeNum}`; 163 | } 164 | 165 | console.log(`[Vixsrc] Fetching: ${vixsrcUrl}`); 166 | 167 | return makeRequest(vixsrcUrl, { 168 | headers: { 169 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' 170 | } 171 | }) 172 | .then(response => response.text()) 173 | .then(html => { 174 | console.log(`[Vixsrc] HTML length: ${html.length} characters`); 175 | 176 | let masterPlaylistUrl = null; 177 | 178 | // Method 1: Look for window.masterPlaylist (primary method) 179 | if (html.includes('window.masterPlaylist')) { 180 | console.log('[Vixsrc] Found window.masterPlaylist'); 181 | 182 | const urlMatch = html.match(/url:\s*['"]([^'"]+)['"]/); 183 | const tokenMatch = html.match(/['"]?token['"]?\s*:\s*['"]([^'"]+)['"]/); 184 | const expiresMatch = html.match(/['"]?expires['"]?\s*:\s*['"]([^'"]+)['"]/); 185 | 186 | if (urlMatch && tokenMatch && expiresMatch) { 187 | const baseUrl = urlMatch[1]; 188 | const token = tokenMatch[1]; 189 | const expires = expiresMatch[1]; 190 | 191 | console.log('[Vixsrc] Extracted tokens:'); 192 | console.log(` - Base URL: ${baseUrl}`); 193 | console.log(` - Token: ${token.substring(0, 20)}...`); 194 | console.log(` - Expires: ${expires}`); 195 | 196 | // Construct the master playlist URL 197 | if (baseUrl.includes('?b=1')) { 198 | masterPlaylistUrl = `${baseUrl}&token=${token}&expires=${expires}&h=1&lang=en`; 199 | } else { 200 | masterPlaylistUrl = `${baseUrl}?token=${token}&expires=${expires}&h=1&lang=en`; 201 | } 202 | 203 | console.log(`[Vixsrc] Constructed master playlist URL: ${masterPlaylistUrl}`); 204 | } 205 | } 206 | 207 | // Method 2: Look for direct .m3u8 URLs 208 | if (!masterPlaylistUrl) { 209 | const m3u8Match = html.match(/(https?:\/\/[^'"\s]+\.m3u8[^'"\s]*)/); 210 | if (m3u8Match) { 211 | masterPlaylistUrl = m3u8Match[1]; 212 | console.log('[Vixsrc] Found direct .m3u8 URL:', masterPlaylistUrl); 213 | } 214 | } 215 | 216 | // Method 3: Look for stream URLs in script tags 217 | if (!masterPlaylistUrl) { 218 | const scriptMatches = html.match(/]*>(.*?)<\/script>/gs); 219 | if (scriptMatches) { 220 | for (const script of scriptMatches) { 221 | const streamMatch = script.match(/['"]?(https?:\/\/[^'"\s]+(?:\.m3u8|playlist)[^'"\s]*)/); 222 | if (streamMatch) { 223 | masterPlaylistUrl = streamMatch[1]; 224 | console.log('[Vixsrc] Found stream in script:', masterPlaylistUrl); 225 | break; 226 | } 227 | } 228 | } 229 | } 230 | 231 | if (!masterPlaylistUrl) { 232 | console.log('[Vixsrc] No master playlist URL found'); 233 | return null; 234 | } 235 | 236 | return { masterPlaylistUrl, subtitleApiUrl }; 237 | }); 238 | } 239 | 240 | // Helper function to get subtitles 241 | function getSubtitles(subtitleApiUrl) { 242 | return makeRequest(subtitleApiUrl) 243 | .then(response => response.json()) 244 | .then(subtitleData => { 245 | // Find English subtitle track (same logic as original) 246 | let subtitleTrack = subtitleData.find(track => 247 | track.display.includes('English') && (track.encoding === 'ASCII' || track.encoding === 'UTF-8') 248 | ); 249 | 250 | if (!subtitleTrack) { 251 | subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP1252'); 252 | } 253 | 254 | if (!subtitleTrack) { 255 | subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP1250'); 256 | } 257 | 258 | if (!subtitleTrack) { 259 | subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP850'); 260 | } 261 | 262 | const subtitles = subtitleTrack ? subtitleTrack.url : ''; 263 | console.log(subtitles ? `[Vixsrc] Found subtitles: ${subtitles}` : '[Vixsrc] No English subtitles found'); 264 | return subtitles; 265 | }) 266 | .catch(error => { 267 | console.log('[Vixsrc] Subtitle fetch failed:', error.message); 268 | return ''; 269 | }); 270 | } 271 | 272 | // Main function to get streams - adapted for Nuvio provider format 273 | function getVixsrcStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) { 274 | console.log(`[Vixsrc] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`); 275 | 276 | return getTmdbInfo(tmdbId, mediaType) 277 | .then(tmdbInfo => { 278 | const { title, year } = tmdbInfo; 279 | 280 | // Extract stream from Vixsrc page 281 | return extractStreamFromPage(null, mediaType, tmdbId, seasonNum, episodeNum); 282 | }) 283 | .then(streamData => { 284 | if (!streamData) { 285 | console.log('[Vixsrc] No stream data found'); 286 | return []; 287 | } 288 | 289 | const { masterPlaylistUrl, subtitleApiUrl } = streamData; 290 | 291 | // Return single master playlist with Auto quality 292 | console.log('[Vixsrc] Returning master playlist with Auto quality...'); 293 | 294 | // Get subtitles 295 | return getSubtitles(subtitleApiUrl) 296 | .then(subtitles => { 297 | // Return single stream with master playlist 298 | const nuvioStreams = [{ 299 | name: "Vixsrc", 300 | title: "Auto Quality Stream", 301 | url: masterPlaylistUrl, 302 | quality: 'Auto', 303 | type: 'direct', 304 | headers: { 305 | 'Referer': BASE_URL, 306 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' 307 | } 308 | }]; 309 | 310 | console.log('[Vixsrc] Successfully processed 1 stream with Auto quality'); 311 | return nuvioStreams; 312 | }); 313 | }) 314 | .catch(error => { 315 | console.error(`[Vixsrc] Error in getVixsrcStreams: ${error.message}`); 316 | return []; 317 | }); 318 | } 319 | 320 | module.exports = { getVixsrcStreams }; 321 | -------------------------------------------------------------------------------- /utils/linkResolver.js: -------------------------------------------------------------------------------- 1 | const cheerio = require('cheerio'); 2 | const { URL, URLSearchParams } = require('url'); 3 | const FormData = require('form-data'); 4 | 5 | // Shared helpers for resolving driveseed/driveleech style redirects and extracting final download URLs. 6 | // This util is proxy-agnostic: providers must inject their own network functions and validators. 7 | // All functions accept injected dependencies so proxy, cookies, and caching stay in provider code. 8 | 9 | // --- Default extractors (can be used directly or replaced by providers) --- 10 | 11 | async function defaultTryInstantDownload($, { post, origin, log = console }) { 12 | const allInstant = $('a:contains("Instant Download"), a:contains("Instant")'); 13 | log.log(`[LinkResolver] defaultTryInstantDownload: found ${allInstant.length} matching anchor(s).`); 14 | const instantLink = allInstant.attr('href'); 15 | if (!instantLink) { 16 | log.log('[LinkResolver] defaultTryInstantDownload: no href on element.'); 17 | return null; 18 | } 19 | 20 | try { 21 | const urlObj = new URL(instantLink, origin); 22 | const keys = new URLSearchParams(urlObj.search).get('url'); 23 | if (!keys) return null; 24 | 25 | const apiUrl = `${urlObj.origin}/api`; 26 | const formData = new FormData(); 27 | formData.append('keys', keys); 28 | 29 | const resp = await post(apiUrl, formData, { 30 | headers: { ...formData.getHeaders(), 'x-token': urlObj.hostname } 31 | }); 32 | 33 | if (resp && resp.data && resp.data.url) { 34 | let finalUrl = resp.data.url; 35 | if (typeof finalUrl === 'string' && finalUrl.includes('workers.dev')) { 36 | const parts = finalUrl.split('/'); 37 | const fn = parts[parts.length - 1]; 38 | parts[parts.length - 1] = fn.replace(/ /g, '%20'); 39 | finalUrl = parts.join('/'); 40 | } 41 | log.log('[LinkResolver] defaultTryInstantDownload: extracted API url'); 42 | return finalUrl; 43 | } 44 | return null; 45 | } catch (e) { 46 | log.log(`[LinkResolver] defaultTryInstantDownload error: ${e.message}`); 47 | return null; 48 | } 49 | } 50 | 51 | async function defaultTryResumeCloud($, { origin, get, validate, log = console }) { 52 | let resumeAnchor = $('a:contains("Resume Cloud"), a:contains("Cloud Resume Download"), a:contains("Resume Worker Bot"), a:contains("Worker")'); 53 | log.log(`[LinkResolver] defaultTryResumeCloud: found ${resumeAnchor.length} candidate button(s).`); 54 | 55 | if (resumeAnchor.length === 0) { 56 | // Try direct links on page 57 | const direct = $('a[href*="workers.dev"], a[href*="workerseed"], a[href*="worker"], a[href*="driveleech.net/d/"], a[href*="driveseed.org/d/"]').attr('href'); 58 | if (direct) { 59 | const ok = validate ? await validate(direct) : true; 60 | if (ok) return direct; 61 | } 62 | return null; 63 | } 64 | 65 | const href = resumeAnchor.attr('href'); 66 | if (!href) return null; 67 | 68 | if (href.startsWith('http') || href.includes('workers.dev')) { 69 | const ok = validate ? await validate(href) : true; 70 | return ok ? href : null; 71 | } 72 | 73 | try { 74 | const resumeUrl = new URL(href, origin).href; 75 | const res = await get(resumeUrl, { maxRedirects: 10 }); 76 | const $$ = cheerio.load(res.data); 77 | let finalDownloadLink = $$('a.btn-success[href*="workers.dev"], a[href*="workerseed"], a[href*="worker"], a[href*="driveleech.net/d/"], a[href*="driveseed.org/d/"]').attr('href'); 78 | if (!finalDownloadLink) { 79 | finalDownloadLink = $$('a[href*="workers.dev"], a[href*="workerseed"], a[href*="worker"], a[href*="driveleech.net/d/"], a[href*="driveseed.org/d/"]').first().attr('href'); 80 | } 81 | if (!finalDownloadLink) return null; 82 | const ok = validate ? await validate(finalDownloadLink) : true; 83 | return ok ? finalDownloadLink : null; 84 | } catch (e) { 85 | log.log(`[LinkResolver] defaultTryResumeCloud error: ${e.message}`); 86 | return null; 87 | } 88 | } 89 | 90 | // --- Core steps --- 91 | 92 | async function followRedirectToFilePage({ redirectUrl, get, log = console }) { 93 | const res = await get(redirectUrl, { maxRedirects: 10 }); 94 | let $ = cheerio.load(res.data); 95 | const scriptContent = $('script').html(); 96 | const match = scriptContent && scriptContent.match(/window\.location\.replace\("([^"]+)"\)/); 97 | let finalFilePageUrl = redirectUrl; 98 | if (match && match[1]) { 99 | const base = new URL(redirectUrl).origin; 100 | finalFilePageUrl = new URL(match[1], base).href; 101 | log.log(`[LinkResolver] Redirect resolved to final file page: ${finalFilePageUrl}`); 102 | const finalRes = await get(finalFilePageUrl, { maxRedirects: 10 }); 103 | $ = cheerio.load(finalRes.data); 104 | } 105 | return { $, finalFilePageUrl }; 106 | } 107 | 108 | async function extractFinalDownloadFromFilePage($, { 109 | origin, 110 | get, 111 | post, 112 | validate, 113 | log = console, 114 | tryResumeCloud = defaultTryResumeCloud, 115 | tryInstantDownload = defaultTryInstantDownload 116 | }) { 117 | // Driveseed/Driveleech-specific: mirror Extractor.kt button logic 118 | const tryDriveseedButtons = async () => { 119 | try { 120 | const anchors = $('div.text-center > a'); 121 | if (!anchors || anchors.length === 0) return null; 122 | 123 | const getFirstValid = async (candidates) => { 124 | for (const url of candidates) { 125 | if (!url) continue; 126 | const ok = validate ? await validate(url) : true; 127 | if (ok) return url; 128 | } 129 | return null; 130 | }; 131 | 132 | // Instant Download 133 | const instant = anchors.filter((i, el) => /Instant Download/i.test($(el).text())); 134 | if (instant.length > 0) { 135 | const href = $(instant[0]).attr('href'); 136 | if (href) { 137 | // Use same logic as Kotlin: POST to /api with x-token = host 138 | try { 139 | const urlObj = new URL(href, origin); 140 | const keys = new URLSearchParams(urlObj.search).get('url'); 141 | if (keys) { 142 | const apiUrl = `${urlObj.origin}/api`; 143 | const formData = new FormData(); 144 | formData.append('keys', keys); 145 | const resp = await post(apiUrl, formData, { 146 | headers: { ...formData.getHeaders(), 'x-token': urlObj.hostname }, 147 | }); 148 | if (resp && resp.data && resp.data.url) { 149 | return await getFirstValid([resp.data.url]); 150 | } 151 | } 152 | } catch (e) { 153 | log.log(`[LinkResolver] Instant Download error: ${e.message}`); 154 | } 155 | } 156 | } 157 | 158 | // Resume Worker Bot 159 | const worker = anchors.filter((i, el) => /Resume Worker Bot/i.test($(el).text())); 160 | if (worker.length > 0) { 161 | const href = $(worker[0]).attr('href'); 162 | if (href) { 163 | try { 164 | const workerUrl = new URL(href, origin).href; 165 | const res = await get(workerUrl); 166 | const html = res.data || ''; 167 | const scripts = (html.match(//gi) || []); 168 | const target = scripts.find(s => s.includes("formData.append('token'")); 169 | const tokenMatch = target && target.match(/formData\.append\('token', '([^']+)'\)/); 170 | const idMatch = target && target.match(/fetch\('\/download\?id=([^']+)',/); 171 | if (tokenMatch && tokenMatch[1] && idMatch && idMatch[1]) { 172 | const token = tokenMatch[1]; 173 | const id = idMatch[1]; 174 | const apiUrl = `${new URL(workerUrl).origin}/download?id=${id}`; 175 | const formData = new FormData(); 176 | formData.append('token', token); 177 | const resp = await post(apiUrl, formData, { 178 | headers: { 179 | ...formData.getHeaders(), 180 | 'x-requested-with': 'XMLHttpRequest', 181 | 'Referer': workerUrl 182 | } 183 | }); 184 | if (resp && resp.data && resp.data.url) { 185 | return await getFirstValid([resp.data.url]); 186 | } 187 | } 188 | } catch (e) { 189 | log.log(`[LinkResolver] Resume Worker Bot error: ${e.message}`); 190 | } 191 | } 192 | } 193 | 194 | // Direct Links (CF Type 1) 195 | const directLinks = anchors.filter((i, el) => /Direct Links/i.test($(el).text())); 196 | if (directLinks.length > 0) { 197 | const href = $(directLinks[0]).attr('href'); 198 | if (href) { 199 | try { 200 | const cfUrl = new URL(href, origin); 201 | // Kotlin hits ?type=1 202 | const urlWithType = `${cfUrl.href}${cfUrl.search ? '&' : '?'}type=1`; 203 | const res = await get(urlWithType); 204 | const $$ = cheerio.load(res.data || ''); 205 | const btns = $$('.btn-success'); 206 | if (btns && btns.length > 0) { 207 | const candidates = []; 208 | btns.each((i, el) => { 209 | const u = $$(el).attr('href'); 210 | if (u && /^https?:/i.test(u)) candidates.push(u); 211 | }); 212 | const found = await getFirstValid(candidates); 213 | if (found) return found; 214 | } 215 | } catch (e) { 216 | log.log(`[LinkResolver] Direct Links error: ${e.message}`); 217 | } 218 | } 219 | } 220 | 221 | // Resume Cloud 222 | const resumeCloud = anchors.filter((i, el) => /Resume Cloud|Cloud Resume Download/i.test($(el).text())); 223 | if (resumeCloud.length > 0) { 224 | const href = $(resumeCloud[0]).attr('href'); 225 | if (href) { 226 | try { 227 | const resumeUrl = new URL(href, origin).href; 228 | const res = await get(resumeUrl); 229 | const $$ = cheerio.load(res.data || ''); 230 | const link = $$('.btn-success').attr('href'); 231 | if (link && /^https?:/i.test(link)) { 232 | return await getFirstValid([link]); 233 | } 234 | } catch (e) { 235 | log.log(`[LinkResolver] Resume Cloud error: ${e.message}`); 236 | } 237 | } 238 | } 239 | 240 | return null; 241 | } catch (e) { 242 | log.log(`[LinkResolver] tryDriveseedButtons error: ${e.message}`); 243 | return null; 244 | } 245 | }; 246 | 247 | // First attempt: Driveseed/Driveleech button flow 248 | const dsUrl = await tryDriveseedButtons(); 249 | if (dsUrl) { 250 | const ok = validate ? await validate(dsUrl) : true; 251 | if (ok) return dsUrl; 252 | } 253 | 254 | // Fallback to known generic methods 255 | const methods = [ 256 | async () => await tryResumeCloud($, { origin, get, validate, log }), 257 | async () => await tryInstantDownload($, { post, origin, log }) 258 | ]; 259 | 260 | for (const fn of methods) { 261 | try { 262 | const url = await fn(); 263 | if (url) { 264 | const ok = validate ? await validate(url) : true; 265 | if (ok) return url; 266 | } 267 | } catch (e) { 268 | log.log(`[LinkResolver] method error: ${e.message}`); 269 | } 270 | } 271 | 272 | // Last resort: scan for plausible direct links 273 | let direct = $('a[href*="workers.dev"], a[href*="workerseed"], a[href*="worker"], a[href*="driveleech.net/d/"], a[href*="driveseed.org/d/"]').attr('href'); 274 | if (direct) { 275 | const ok = validate ? await validate(direct) : true; 276 | if (ok) return direct; 277 | } 278 | return null; 279 | } 280 | 281 | // Resolve SID (tech.unblockedgames.world etc.) to intermediate redirect (driveleech/driveseed) 282 | // createSession(jar) must return an axios-like instance with get/post that respects proxy and cookie jar 283 | async function resolveSidToRedirect({ sidUrl, createSession, jar, log = console }) { 284 | const session = await createSession(jar); 285 | // Step 0 286 | const step0 = await session.get(sidUrl); 287 | let $ = cheerio.load(step0.data); 288 | const form0 = $('#landing'); 289 | const wp_http = form0.find('input[name="_wp_http"]').val(); 290 | const action0 = form0.attr('action'); 291 | if (!wp_http || !action0) return null; 292 | // Step 1 293 | const step1 = await session.post(action0, new URLSearchParams({ '_wp_http': wp_http }), { 294 | headers: { 'Referer': sidUrl, 'Content-Type': 'application/x-www-form-urlencoded' } 295 | }); 296 | // Step 2 297 | $ = cheerio.load(step1.data); 298 | const form1 = $('#landing'); 299 | const action1 = form1.attr('action'); 300 | const wp_http2 = form1.find('input[name="_wp_http2"]').val(); 301 | const token = form1.find('input[name="token"]').val(); 302 | if (!action1) return null; 303 | const step2 = await session.post(action1, new URLSearchParams({ '_wp_http2': wp_http2, token }), { 304 | headers: { 'Referer': step1.request?.res?.responseUrl || sidUrl, 'Content-Type': 'application/x-www-form-urlencoded' } 305 | }); 306 | // Step 3 - meta refresh 307 | $ = cheerio.load(step2.data); 308 | const meta = $('meta[http-equiv="refresh"]').attr('content') || ''; 309 | const m = meta.match(/url=(.*)/i); 310 | if (!m || !m[1]) return null; 311 | const origin = new URL(sidUrl).origin; 312 | const redirectUrl = new URL(m[1].replace(/"/g, '').replace(/'/g, ''), origin).href; 313 | log.log(`[LinkResolver] SID resolved to redirect: ${redirectUrl}`); 314 | return redirectUrl; 315 | } 316 | 317 | module.exports = { 318 | defaultTryInstantDownload, 319 | defaultTryResumeCloud, 320 | followRedirectToFilePage, 321 | extractFinalDownloadFromFilePage, 322 | resolveSidToRedirect 323 | }; 324 | 325 | 326 | 327 | 328 | 329 | 330 | -------------------------------------------------------------------------------- /scrapersdirect/animeflix_scraper.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const cheerio = require('cheerio'); 3 | const readline = require('readline'); 4 | const FormData = require('form-data'); 5 | const { CookieJar } = require('tough-cookie'); 6 | const { wrapper } = require('axios-cookiejar-support'); 7 | 8 | const BASE_URL = 'https://animeflix.pm'; 9 | 10 | // Search for anime on AnimeFlix 11 | async function searchAnime(query) { 12 | try { 13 | const searchUrl = `${BASE_URL}/?s=${encodeURIComponent(query)}`; 14 | const { data } = await axios.get(searchUrl); 15 | const $ = cheerio.load(data); 16 | 17 | const results = []; 18 | $('a[href*="https://animeflix.pm/"]').each((i, element) => { 19 | const linkElement = $(element); 20 | const title = linkElement.attr('title'); 21 | const url = linkElement.attr('href'); 22 | 23 | if (title && url && !results.some(r => r.url === url)) { // Ensure unique URLs 24 | results.push({ title, url }); 25 | } 26 | }); 27 | 28 | console.log(`Found ${results.length} results for "${query}" on AnimeFlix`); 29 | return results; 30 | } catch (error) { 31 | console.error(`Error searching on AnimeFlix: ${error.message}`); 32 | return []; 33 | } 34 | } 35 | 36 | // Extract the main download page link (e.g., Gdrive + Mirrors) 37 | async function extractMainDownloadLink(animePageUrl) { 38 | try { 39 | console.log(`\nExtracting initial links from: ${animePageUrl}`); 40 | const { data } = await axios.get(animePageUrl); 41 | const $ = cheerio.load(data); 42 | const links = []; 43 | 44 | // Find all h3 tags that seem to indicate a quality/download section 45 | $('h3:contains("720p"), h3:contains("1080p")').each((i, el) => { 46 | const header = $(el); 47 | const qualityText = header.text().trim(); 48 | 49 | // Find the "Gdrive + Mirrors" link in the next element 50 | const gdriveLink = header.next('p').find('a:contains("Gdrive + Mirrors")'); 51 | 52 | if (gdriveLink.length > 0) { 53 | const linkUrl = gdriveLink.attr('href'); 54 | console.log(`Found link for "${qualityText}": ${linkUrl}`); 55 | links.push({ 56 | source: qualityText, // Use the header text as the source/quality indicator 57 | url: linkUrl 58 | }); 59 | } 60 | }); 61 | 62 | if (links.length === 0) { 63 | console.log('Could not find any "Gdrive + Mirrors" links associated with quality headers.'); 64 | } 65 | 66 | return links; 67 | } catch (error) { 68 | console.error(`Error extracting main download link: ${error.message}`); 69 | return []; 70 | } 71 | } 72 | 73 | // Scrape the episodes.animeflix.pm page for all episode /getlink/ URLs 74 | async function resolveGdriveMirrorPage(episodePageUrl) { 75 | try { 76 | console.log(`\nScraping episode list from: ${episodePageUrl}`); 77 | const { data } = await axios.get(episodePageUrl); 78 | const $ = cheerio.load(data); 79 | const episodeLinks = []; 80 | 81 | $('a[href*="/getlink/"]').each((i, el) => { 82 | const link = $(el).attr('href'); 83 | const episodeText = $(el).text().trim().replace(/[⌈⌋_]/g, ''); // Clean up text like "⌈Episode 1⌋" 84 | 85 | if (link && episodeText) { 86 | episodeLinks.push({ 87 | title: episodeText, 88 | url: link 89 | }); 90 | } 91 | }); 92 | 93 | console.log(`Found ${episodeLinks.length} episode links.`); 94 | return episodeLinks; 95 | } catch (error) { 96 | console.error(`Error resolving Gdrive/Mirror page: ${error.message}`); 97 | return []; 98 | } 99 | } 100 | 101 | // Placeholder for resolving the final /getlink/ URL 102 | async function resolveGetLink(getLinkUrl) { 103 | try { 104 | console.log(`\nResolving final link from: ${getLinkUrl}`); 105 | // Use axios to follow the redirect. The final URL will be in the response object. 106 | const response = await axios.get(getLinkUrl, { 107 | maxRedirects: 5 // Follow up to 5 redirects 108 | }); 109 | 110 | // The final URL after all redirects is in `response.request.res.responseUrl` 111 | const finalUrl = response.request.res.responseUrl; 112 | 113 | if (finalUrl) { 114 | console.log(` Redirect resolved to: ${finalUrl}`); 115 | return finalUrl; 116 | } else { 117 | console.log(' Could not resolve the final URL after redirects.'); 118 | return null; 119 | } 120 | } catch (error) { 121 | console.error(`Error resolving getlink: ${error.message}`); 122 | if (error.response) { 123 | console.error(` Status: ${error.response.status}`); 124 | } 125 | return null; 126 | } 127 | } 128 | 129 | // Follows the anishort.xyz redirector to get the final link 130 | async function resolveAnishortLink(anishortUrl) { 131 | try { 132 | console.log(`\nResolving anishort.xyz link: ${anishortUrl}`); 133 | const { data } = await axios.get(anishortUrl); 134 | const $ = cheerio.load(data); 135 | 136 | // Find the "Download Now" button and extract its link 137 | const downloadLink = $('a.button-24').attr('href'); 138 | 139 | if (downloadLink) { 140 | console.log(` Anishort resolved to: ${downloadLink}`); 141 | return downloadLink; 142 | } else { 143 | console.log(' Could not find the download button on the anishort.xyz page.'); 144 | return null; 145 | } 146 | } catch (error) { 147 | console.error(`Error resolving anishort.xyz link: ${error.message}`); 148 | return null; 149 | } 150 | } 151 | 152 | // Follows the driveleech.net redirector to get the final video URL 153 | async function resolveDriveleechLink(driveleechUrl) { 154 | try { 155 | console.log(`\nResolving driveleech.net link: ${driveleechUrl}`); 156 | const { data } = await axios.get(driveleechUrl); 157 | 158 | // Check for the JavaScript redirect first 159 | const redirectMatch = data.match(/window\.location\.replace\("([^"]+)"\)/); 160 | if (redirectMatch && redirectMatch[1]) { 161 | const nextPath = redirectMatch[1]; 162 | const nextUrl = new URL(nextPath, driveleechUrl).href; 163 | console.log(` Found JavaScript redirect. Following to: ${nextUrl}`); 164 | // Recursively call to handle the next page in the chain 165 | return await resolveDriveleechLink(nextUrl); 166 | } 167 | 168 | // If no JS redirect, look for the final download button on the current page 169 | const $ = cheerio.load(data); 170 | const finalLink = $('a.btn-danger:contains("Instant Download")').attr('href'); 171 | 172 | if (finalLink) { 173 | console.log(` Found "Instant Download" link: ${finalLink}`); 174 | return finalLink; 175 | } 176 | 177 | console.log(' Could not find a JS redirect or an "Instant Download" button.'); 178 | console.log('--- BEGIN Driveleech HTML ---'); 179 | console.log(data); 180 | console.log('--- END Driveleech HTML ---'); 181 | return null; 182 | 183 | } catch (error) { 184 | console.error(`Error resolving driveleech.net link: ${error.message}`); 185 | return null; 186 | } 187 | } 188 | 189 | // Resolves the video-leech.pro API to get the final video URL (adapted from UHDMovies logic) 190 | async function resolveVideoLeechLink(videoLeechUrl) { 191 | try { 192 | console.log(`\nResolving Video-leech link: ${videoLeechUrl}`); 193 | const urlObject = new URL(videoLeechUrl); 194 | const keysParam = urlObject.searchParams.get('url'); 195 | 196 | if (!keysParam) { 197 | console.error('Could not find the "url" parameter in the video-leech.pro link.'); 198 | return null; 199 | } 200 | 201 | const apiUrl = `${urlObject.origin}/api`; 202 | console.log(` POSTing to API endpoint: ${apiUrl}`); 203 | 204 | const { data } = await axios.post(apiUrl, `keys=${keysParam}`, { 205 | headers: { 206 | 'Content-Type': 'application/x-www-form-urlencoded', 207 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 208 | 'x-token': urlObject.hostname // Use the hostname from the URL as the token 209 | } 210 | }); 211 | 212 | if (data && data.url) { 213 | console.log(` SUCCESS! Final video link from API: ${data.url}`); 214 | return data.url; 215 | } else if (data && data.message) { 216 | console.error(` API Error: ${data.message}`); 217 | return null; 218 | } else { 219 | console.log(' API request did not return a valid link object.'); 220 | console.log(' Received:', data); 221 | return null; 222 | } 223 | } catch (error) { 224 | console.error(`Error resolving Video-leech link: ${error.message}`); 225 | return null; 226 | } 227 | } 228 | 229 | // Function to prompt user for selection 230 | function promptUser(results, promptTitle) { 231 | return new Promise((resolve) => { 232 | if (!results || results.length === 0) { 233 | console.log(`No items to select for: ${promptTitle}`); 234 | return resolve(null); 235 | } 236 | const rl = readline.createInterface({ 237 | input: process.stdin, 238 | output: process.stdout 239 | }); 240 | 241 | console.log(`\nPlease select a ${promptTitle}:`); 242 | results.forEach((result, index) => { 243 | const displayText = result.title || result.source; 244 | console.log(`${index + 1}: ${displayText}`); 245 | }); 246 | console.log('0: Exit'); 247 | 248 | rl.question('\nEnter your choice: ', (choice) => { 249 | rl.close(); 250 | const index = parseInt(choice, 10) - 1; 251 | if (index >= 0 && index < results.length) { 252 | resolve(results[index]); 253 | } else { 254 | resolve(null); // Exit or invalid choice 255 | } 256 | }); 257 | }); 258 | } 259 | 260 | // Main function to run the scraper from the command line. 261 | async function main() { 262 | const queryArgs = process.argv.slice(2); 263 | if (queryArgs.length === 0) { 264 | console.log('Please provide a search query.'); 265 | console.log('Usage: node scrapersdirect/animeflix_scraper.js "Your Anime Title"'); 266 | return; 267 | } 268 | const query = queryArgs.join(' '); 269 | 270 | console.log(`Searching for "${query}"...`); 271 | const searchResults = await searchAnime(query); 272 | 273 | if (searchResults.length === 0) { 274 | return; 275 | } 276 | 277 | const selectedAnime = await promptUser(searchResults, 'anime to scrape'); 278 | if (!selectedAnime) { 279 | console.log('Exiting.'); 280 | return; 281 | } 282 | 283 | const mainDownloadLinks = await extractMainDownloadLink(selectedAnime.url); 284 | if (mainDownloadLinks.length === 0) { 285 | return; 286 | } 287 | 288 | const selectedSource = await promptUser(mainDownloadLinks, 'download source'); 289 | if (!selectedSource) { 290 | console.log('Exiting.'); 291 | return; 292 | } 293 | 294 | console.log(`Selected Source: ${selectedSource.source}`); 295 | 296 | // Get all the episode /getlink/ urls 297 | const episodeLinks = await resolveGdriveMirrorPage(selectedSource.url); 298 | 299 | if (episodeLinks.length === 0) { 300 | console.log('\nCould not find any episode links.'); 301 | return; 302 | } 303 | 304 | const selectedEpisode = await promptUser(episodeLinks, 'episode'); 305 | if (!selectedEpisode) { 306 | console.log('Exiting.'); 307 | return; 308 | } 309 | 310 | // Resolve the final link for the selected episode 311 | const anishortLink = await resolveGetLink(selectedEpisode.url); 312 | 313 | if (anishortLink) { 314 | const driveleechLink = await resolveAnishortLink(anishortLink); 315 | 316 | if (driveleechLink) { 317 | const videoLeechLink = await resolveDriveleechLink(driveleechLink); 318 | 319 | if (videoLeechLink) { 320 | const finalLink = await resolveVideoLeechLink(videoLeechLink); 321 | if (finalLink) { 322 | console.log('\n================================'); 323 | console.log('✅ Final Download Link Found:'); 324 | console.log(finalLink); 325 | console.log('================================'); 326 | } else { 327 | console.log('Failed to resolve the final video-leech.pro link.'); 328 | } 329 | } else { 330 | console.log('Failed to resolve the final driveleech.net link.'); 331 | } 332 | } else { 333 | console.log('Failed to resolve the anishort.xyz link.'); 334 | } 335 | } else { 336 | console.log('Failed to resolve the getlink URL.'); 337 | } 338 | } 339 | 340 | if (require.main === module) { 341 | main(); 342 | } 343 | 344 | module.exports = { 345 | searchAnime, 346 | extractMainDownloadLink, 347 | }; -------------------------------------------------------------------------------- /providers/4khdhub.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const cheerio = require('cheerio'); 3 | const bytes = require('bytes'); 4 | const levenshtein = require('fast-levenshtein'); 5 | const rot13Cipher = require('rot13-cipher'); 6 | const { URL } = require('url'); 7 | const path = require('path'); 8 | const fs = require('fs').promises; 9 | const RedisCache = require('../utils/redisCache'); 10 | 11 | // Cache configuration 12 | const CACHE_ENABLED = process.env.DISABLE_CACHE !== 'true'; 13 | const CACHE_DIR = process.env.VERCEL ? path.join('/tmp', '.4khdhub_cache') : path.join(__dirname, '.cache', '4khdhub'); 14 | const redisCache = new RedisCache('4KHDHub'); 15 | 16 | // Helper to ensure cache directory exists 17 | const ensureCacheDir = async () => { 18 | if (!CACHE_ENABLED) return; 19 | try { 20 | await fs.mkdir(CACHE_DIR, { recursive: true }); 21 | } catch (error) { 22 | console.error(`[4KHDHub] Error creating cache directory: ${error.message}`); 23 | } 24 | }; 25 | ensureCacheDir(); 26 | 27 | const BASE_URL = 'https://4khdhub.fans'; 28 | const TMDB_API_KEY = '439c478a771f35c05022f9feabcca01c'; 29 | 30 | // Polyfill for atob if not available globally 31 | const atob = (str) => Buffer.from(str, 'base64').toString('binary'); 32 | 33 | // Helper to fetch text content 34 | async function fetchText(url, options = {}) { 35 | try { 36 | const response = await axios.get(url, { 37 | headers: { 38 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 39 | ...options.headers 40 | }, 41 | timeout: 10000 42 | }); 43 | return response.data; 44 | } catch (error) { 45 | console.error(`[4KHDHub] Request failed for ${url}: ${error.message}`); 46 | return null; 47 | } 48 | } 49 | 50 | // Fetch TMDB Details 51 | async function getTmdbDetails(tmdbId, type) { 52 | try { 53 | const isSeries = type === 'series' || type === 'tv'; 54 | const url = `https://api.themoviedb.org/3/${isSeries ? 'tv' : 'movie'}/${tmdbId}?api_key=${TMDB_API_KEY}`; 55 | console.log(`[4KHDHub] Fetching TMDB details from: ${url}`); 56 | const response = await axios.get(url); 57 | const data = response.data; 58 | // ... 59 | 60 | if (isSeries) { 61 | return { 62 | title: data.name, 63 | year: data.first_air_date ? parseInt(data.first_air_date.split('-')[0]) : 0 64 | }; 65 | } else { 66 | return { 67 | title: data.title, 68 | year: data.release_date ? parseInt(data.release_date.split('-')[0]) : 0 69 | }; 70 | } 71 | } catch (error) { 72 | console.error(`[4KHDHub] TMDB request failed: ${error.message}`); 73 | return null; 74 | } 75 | } 76 | 77 | // FourKHDHub Logic 78 | async function fetchPageUrl(name, year, isSeries) { 79 | const cacheKey = `search_${name.replace(/[^a-z0-9]/gi, '_')}_${year}`; 80 | // [4KHDHub] Checking cache for key: ${cacheKey} (Enabled: ${CACHE_ENABLED}) 81 | 82 | if (CACHE_ENABLED) { 83 | const cached = await redisCache.getFromCache(cacheKey, '', CACHE_DIR); 84 | if (cached) { 85 | // [4KHDHub] Cache HIT for search: ${name} 86 | return cached.data || cached; 87 | } else { 88 | // [4KHDHub] Cache MISS for search: ${name} 89 | } 90 | } 91 | 92 | const searchUrl = `${BASE_URL}/?s=${encodeURIComponent(`${name} ${year}`)}`; 93 | const html = await fetchText(searchUrl); 94 | if (!html) return null; 95 | 96 | const $ = cheerio.load(html); 97 | const targetType = isSeries ? 'Series' : 'Movies'; 98 | 99 | // Find cards that contain the correct type 100 | const matchingCards = $('.movie-card') 101 | .filter((_i, el) => { 102 | const hasFormat = $(el).find(`.movie-card-format:contains("${targetType}")`).length > 0; 103 | return hasFormat; 104 | }) 105 | .filter((_i, el) => { 106 | const metaText = $(el).find('.movie-card-meta').text(); 107 | const movieCardYear = parseInt(metaText); 108 | return !isNaN(movieCardYear) && Math.abs(movieCardYear - year) <= 1; 109 | }) 110 | .filter((_i, el) => { 111 | const movieCardTitle = $(el).find('.movie-card-title') 112 | .text() 113 | .replace(/\[.*?]/g, '') 114 | .trim(); 115 | 116 | // Allow exact match or close Levenshtein distance 117 | // Also user's code used: useCollator: true, but fast-levenshtein is simpler 118 | return levenshtein.get(movieCardTitle.toLowerCase(), name.toLowerCase()) < 5; 119 | }) 120 | .map((_i, el) => { 121 | let href = $(el).attr('href'); 122 | if (href && !href.startsWith('http')) { 123 | href = BASE_URL + (href.startsWith('/') ? '' : '/') + href; 124 | } 125 | return href; 126 | }) 127 | .get(); 128 | 129 | const result = matchingCards.length > 0 ? matchingCards[0] : null; 130 | if (CACHE_ENABLED && result) { 131 | await redisCache.saveToCache(cacheKey, { data: result }, '', CACHE_DIR, 86400); // 1 day TTL 132 | } 133 | return result; 134 | } 135 | 136 | async function resolveRedirectUrl(redirectUrl) { 137 | const cacheKey = `redirect_${redirectUrl.replace(/[^a-z0-9]/gi, '')}`; 138 | if (CACHE_ENABLED) { 139 | const cached = await redisCache.getFromCache(cacheKey, '', CACHE_DIR); 140 | if (cached) return cached.data || cached; 141 | } 142 | 143 | const redirectHtml = await fetchText(redirectUrl); 144 | if (!redirectHtml) return null; 145 | 146 | try { 147 | const redirectDataMatch = redirectHtml.match(/'o','(.*?)'/); 148 | if (!redirectDataMatch) return null; 149 | 150 | // JSON.parse(atob(rot13Cipher(atob(atob(redirectDataMatch[1] as string))))) 151 | const step1 = atob(redirectDataMatch[1]); 152 | const step2 = atob(step1); 153 | const step3 = rot13Cipher(step2); 154 | const step4 = atob(step3); 155 | const redirectData = JSON.parse(step4); 156 | 157 | if (redirectData && redirectData.o) { 158 | const resolved = atob(redirectData.o); 159 | if (CACHE_ENABLED) { 160 | await redisCache.saveToCache(cacheKey, { data: resolved }, '', CACHE_DIR, 86400 * 3); // 3 days 161 | } 162 | return resolved; 163 | } 164 | } catch (e) { 165 | console.error(`[4KHDHub] Error resolving redirect: ${e.message}`); 166 | } 167 | return null; 168 | } 169 | 170 | async function extractSourceResults($, el) { 171 | const localHtml = $(el).html(); 172 | const sizeMatch = localHtml.match(/([\d.]+ ?[GM]B)/); 173 | let heightMatch = localHtml.match(/\d{3,}p/); 174 | 175 | const title = $(el).find('.file-title, .episode-file-title').text().trim(); 176 | 177 | // If quality detection failed from HTML, try the title 178 | if (!heightMatch) { 179 | heightMatch = title.match(/(\d{3,4})p/i); 180 | } 181 | 182 | // Fallback for "4K" 183 | let height = heightMatch ? parseInt(heightMatch[0]) : 0; 184 | if (height === 0 && (title.includes('4K') || title.includes('4k') || localHtml.includes('4K') || localHtml.includes('4k'))) { 185 | height = 2160; 186 | } 187 | 188 | const meta = { 189 | bytes: sizeMatch ? bytes.parse(sizeMatch[1]) : 0, 190 | height: height, 191 | title: title 192 | }; 193 | 194 | // Check for HubCloud link 195 | let hubCloudLink = $(el).find('a') 196 | .filter((_i, a) => $(a).text().includes('HubCloud')) 197 | .attr('href'); 198 | 199 | if (hubCloudLink) { 200 | const resolved = await resolveRedirectUrl(hubCloudLink); 201 | return { url: resolved, meta }; 202 | } 203 | 204 | // Check for HubDrive link 205 | let hubDriveLink = $(el).find('a') 206 | .filter((_i, a) => $(a).text().includes('HubDrive')) 207 | .attr('href'); 208 | 209 | if (hubDriveLink) { 210 | const resolvedDrive = await resolveRedirectUrl(hubDriveLink); 211 | if (resolvedDrive) { 212 | const hubDriveHtml = await fetchText(resolvedDrive); 213 | if (hubDriveHtml) { 214 | const $2 = cheerio.load(hubDriveHtml); 215 | const innerCloudLink = $2('a:contains("HubCloud")').attr('href'); 216 | if (innerCloudLink) { 217 | return { url: innerCloudLink, meta }; 218 | } 219 | } 220 | } 221 | } 222 | 223 | return null; 224 | } 225 | 226 | // HubCloud Extractor Logic 227 | async function extractHubCloud(hubCloudUrl, baseMeta) { 228 | if (!hubCloudUrl) return []; 229 | 230 | const cacheKey = `hubcloud_${hubCloudUrl.replace(/[^a-z0-9]/gi, '')}`; 231 | if (CACHE_ENABLED) { 232 | const cached = await redisCache.getFromCache(cacheKey, '', CACHE_DIR); 233 | if (cached) return cached.data || cached; 234 | } 235 | 236 | const headers = { Referer: hubCloudUrl }; // or should it be the previous page? User's code uses meta.referer ?? url.href. HubCloud.ts says Referer: meta.referer ?? url.href. 237 | // In extractInternal(ctx, url, meta): const headers = { Referer: meta.referer ?? url.href }; 238 | // Then fetches redirectHtml. 239 | 240 | // We'll trust the url itself as referer if we don't have the parent page readily passed down, or just no referer. 241 | // Let's use the HubCloud URL itself as referer for the first request, that's usually safe or standard. 242 | 243 | const redirectHtml = await fetchText(hubCloudUrl, { headers: { Referer: hubCloudUrl } }); 244 | if (!redirectHtml) return []; 245 | 246 | const redirectUrlMatch = redirectHtml.match(/var url ?= ?'(.*?)'/); 247 | if (!redirectUrlMatch) return []; 248 | 249 | const finalLinksUrl = redirectUrlMatch[1]; 250 | const linksHtml = await fetchText(finalLinksUrl, { headers: { Referer: hubCloudUrl } }); 251 | if (!linksHtml) return []; 252 | 253 | const $ = cheerio.load(linksHtml); 254 | const results = []; 255 | const sizeText = $('#size').text(); 256 | const titleText = $('title').text().trim(); 257 | 258 | // Combine meta from page with baseMeta (user's code does this) 259 | const currentMeta = { 260 | ...baseMeta, 261 | bytes: bytes.parse(sizeText) || baseMeta.bytes, 262 | title: titleText || baseMeta.title 263 | }; 264 | 265 | // FSL Links 266 | $('a').each((_i, el) => { 267 | const text = $(el).text(); 268 | const href = $(el).attr('href'); 269 | if (!href) return; 270 | 271 | if (text.includes('FSL') || text.includes('Download File')) { 272 | results.push({ 273 | source: 'FSL', 274 | url: href, 275 | meta: currentMeta 276 | }); 277 | } 278 | else if (text.includes('PixelServer')) { 279 | const pixelUrl = href.replace('/u/', '/api/file/'); 280 | results.push({ 281 | source: 'PixelServer', 282 | url: pixelUrl, 283 | meta: currentMeta 284 | }); 285 | } 286 | }); 287 | 288 | if (CACHE_ENABLED && results.length > 0) { 289 | await redisCache.saveToCache(cacheKey, { data: results }, '', CACHE_DIR, 3600); // 1 hour TTL 290 | } 291 | 292 | return results; 293 | } 294 | 295 | async function get4KHDHubStreams(tmdbId, type, season = null, episode = null) { 296 | const tmdbDetails = await getTmdbDetails(tmdbId, type); 297 | if (!tmdbDetails) return []; 298 | 299 | const { title, year } = tmdbDetails; 300 | console.log(`[4KHDHub] Search: ${title} (${year})`); 301 | 302 | const isSeries = type === 'series' || type === 'tv'; 303 | const pageUrl = await fetchPageUrl(title, year, isSeries); 304 | if (!pageUrl) { 305 | console.log(`[4KHDHub] Page not found`); 306 | return []; 307 | } 308 | console.log(`[4KHDHub] Found page: ${pageUrl}`); 309 | 310 | const html = await fetchText(pageUrl); 311 | if (!html) return []; 312 | const $ = cheerio.load(html); 313 | 314 | let itemsToProcess = []; 315 | 316 | if (isSeries && season && episode) { // Use isSeries here 317 | // Find specific season and episode 318 | const seasonStr = `S${String(season).padStart(2, '0')}`; 319 | const episodeStr = `Episode-${String(episode).padStart(2, '0')}`; 320 | 321 | $('.episode-item').each((_i, el) => { 322 | if ($('.episode-title', el).text().includes(seasonStr)) { 323 | const downloadItems = $('.episode-download-item', el) 324 | .filter((_j, item) => $(item).text().includes(episodeStr)); 325 | 326 | downloadItems.each((_k, item) => { 327 | itemsToProcess.push(item); 328 | }); 329 | } 330 | }); 331 | } else { 332 | // Movies 333 | $('.download-item').each((_i, el) => { 334 | itemsToProcess.push(el); 335 | }); 336 | } 337 | 338 | console.log(`[4KHDHub] Processing ${itemsToProcess.length} items`); 339 | 340 | const streams = []; 341 | 342 | for (const item of itemsToProcess) { 343 | try { 344 | const sourceResult = await extractSourceResults($, item); 345 | if (sourceResult && sourceResult.url) { 346 | console.log(`[4KHDHub] Extracting from HubCloud: ${sourceResult.url}`); 347 | const extractedLinks = await extractHubCloud(sourceResult.url, sourceResult.meta); 348 | 349 | for (const link of extractedLinks) { 350 | streams.push({ 351 | name: `4KHDHub - ${link.source} ${sourceResult.meta.height ? sourceResult.meta.height + 'p' : ''}`, 352 | title: `${link.meta.title}\n${bytes.format(link.meta.bytes || 0)}`, 353 | url: link.url, 354 | quality: sourceResult.meta.height ? `${sourceResult.meta.height}p` : undefined, 355 | behaviorHints: { 356 | bingeGroup: `4khdhub-${link.source}` 357 | } 358 | }); 359 | } 360 | } 361 | } catch (err) { 362 | console.error(`[4KHDHub] Item processing error: ${err.message}`); 363 | } 364 | } 365 | 366 | return streams; 367 | } 368 | 369 | module.exports = { get4KHDHubStreams }; -------------------------------------------------------------------------------- /providers/hdrezkas.js: -------------------------------------------------------------------------------- 1 | // Simplified standalone script to test hdrezka scraper flow 2 | import fetch from 'node-fetch'; 3 | import readline from 'readline'; 4 | 5 | // Constants 6 | const rezkaBase = 'https://hdrezka.ag/'; 7 | const baseHeaders = { 8 | 'X-Hdrezka-Android-App': '1', 9 | 'X-Hdrezka-Android-App-Version': '2.2.0', 10 | }; 11 | 12 | // Parse command line arguments 13 | const args = process.argv.slice(2); 14 | const argOptions = { 15 | title: null, 16 | type: null, 17 | year: null, 18 | season: null, 19 | episode: null 20 | }; 21 | 22 | // Process command line arguments 23 | for (let i = 0; i < args.length; i++) { 24 | if (args[i] === '--title' || args[i] === '-t') { 25 | argOptions.title = args[i + 1]; 26 | i++; 27 | } else if (args[i] === '--type' || args[i] === '-m') { 28 | argOptions.type = args[i + 1].toLowerCase(); 29 | i++; 30 | } else if (args[i] === '--year' || args[i] === '-y') { 31 | argOptions.year = parseInt(args[i + 1]); 32 | i++; 33 | } else if (args[i] === '--season' || args[i] === '-s') { 34 | argOptions.season = parseInt(args[i + 1]); 35 | i++; 36 | } else if (args[i] === '--episode' || args[i] === '-e') { 37 | argOptions.episode = parseInt(args[i + 1]); 38 | i++; 39 | } else if (args[i] === '--help' || args[i] === '-h') { 40 | console.log(` 41 | HDRezka Scraper Test Script 42 | 43 | Usage: 44 | node hdrezka-test.js [options] 45 | 46 | Options: 47 | --title, -t Title to search for 48 | --type, -m <type> Media type (movie or show) 49 | --year, -y <year> Release year 50 | --season, -s <number> Season number (for shows) 51 | --episode, -e <number> Episode number (for shows) 52 | --help, -h Show this help message 53 | 54 | Examples: 55 | node hdrezka-test.js --title "Breaking Bad" --type show --season 1 --episode 3 56 | node hdrezka-test.js --title "Inception" --type movie --year 2010 57 | node hdrezka-test.js (interactive mode) 58 | `); 59 | process.exit(0); 60 | } 61 | } 62 | 63 | // Create readline interface for user input 64 | const rl = readline.createInterface({ 65 | input: process.stdin, 66 | output: process.stdout 67 | }); 68 | 69 | // Function to prompt user for input 70 | function prompt(question) { 71 | return new Promise((resolve) => { 72 | rl.question(question, (answer) => { 73 | resolve(answer); 74 | }); 75 | }); 76 | } 77 | 78 | // Helper functions 79 | function generateRandomFavs() { 80 | const randomHex = () => Math.floor(Math.random() * 16).toString(16); 81 | const generateSegment = (length) => Array.from({ length }, randomHex).join(''); 82 | 83 | return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`; 84 | } 85 | 86 | function extractTitleAndYear(input) { 87 | const regex = /^(.*?),.*?(\d{4})/; 88 | const match = input.match(regex); 89 | 90 | if (match) { 91 | const title = match[1]; 92 | const year = match[2]; 93 | return { title: title.trim(), year: year ? parseInt(year, 10) : null }; 94 | } 95 | return null; 96 | } 97 | 98 | function parseVideoLinks(inputString) { 99 | if (!inputString) { 100 | throw new Error('No video links found'); 101 | } 102 | 103 | console.log(`[PARSE] Parsing video links from stream URL data`); 104 | const linksArray = inputString.split(','); 105 | const result = {}; 106 | 107 | linksArray.forEach((link) => { 108 | // Handle different quality formats: 109 | // 1. Simple format: [360p]https://example.com/video.mp4 110 | // 2. HTML format: [<span class="pjs-registered-quality">1080p<img...>]https://example.com/video.mp4 111 | 112 | // Try simple format first (non-HTML) 113 | let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/); 114 | 115 | // If not found, try HTML format with more flexible pattern 116 | if (!match) { 117 | // Extract quality text from HTML span 118 | const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/); 119 | // Extract URL separately 120 | const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/); 121 | 122 | if (qualityMatch && urlMatch) { 123 | match = [null, qualityMatch[1].trim(), urlMatch[1]]; 124 | } 125 | } 126 | 127 | if (match) { 128 | const qualityText = match[1].trim(); 129 | const mp4Url = match[2]; 130 | 131 | // Extract the quality value (e.g., "360p", "1080p Ultra") 132 | let quality = qualityText; 133 | 134 | // Skip null URLs (premium content that requires login) 135 | if (mp4Url !== 'null') { 136 | result[quality] = { type: 'mp4', url: mp4Url }; 137 | console.log(`[QUALITY] Found ${quality}: ${mp4Url}`); 138 | } else { 139 | console.log(`[QUALITY] Premium quality ${quality} requires login (null URL)`); 140 | } 141 | } else { 142 | console.log(`[WARNING] Could not parse quality from: ${link}`); 143 | } 144 | }); 145 | 146 | console.log(`[PARSE] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`); 147 | return result; 148 | } 149 | 150 | function parseSubtitles(inputString) { 151 | if (!inputString) { 152 | console.log('[SUBTITLES] No subtitles found'); 153 | return []; 154 | } 155 | 156 | console.log(`[PARSE] Parsing subtitles data`); 157 | const linksArray = inputString.split(','); 158 | const captions = []; 159 | 160 | linksArray.forEach((link) => { 161 | const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/); 162 | 163 | if (match) { 164 | const language = match[1]; 165 | const url = match[2]; 166 | 167 | captions.push({ 168 | id: url, 169 | language, 170 | hasCorsRestrictions: false, 171 | type: 'vtt', 172 | url: url, 173 | }); 174 | console.log(`[SUBTITLE] Found ${language}: ${url}`); 175 | } 176 | }); 177 | 178 | console.log(`[PARSE] Found ${captions.length} subtitles`); 179 | return captions; 180 | } 181 | 182 | // Main scraper functions 183 | async function searchAndFindMediaId(media) { 184 | console.log(`[STEP 1] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`); 185 | 186 | const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g; 187 | const idRegexPattern = /\/(\d+)-[^/]+\.html$/; 188 | 189 | const fullUrl = new URL('/engine/ajax/search.php', rezkaBase); 190 | fullUrl.searchParams.append('q', media.title); 191 | 192 | console.log(`[REQUEST] Making search request to: ${fullUrl.toString()}`); 193 | const response = await fetch(fullUrl.toString(), { 194 | headers: baseHeaders 195 | }); 196 | 197 | if (!response.ok) { 198 | throw new Error(`HTTP error! status: ${response.status}`); 199 | } 200 | 201 | const searchData = await response.text(); 202 | console.log(`[RESPONSE] Search response length: ${searchData.length}`); 203 | 204 | const movieData = []; 205 | let match; 206 | 207 | while ((match = itemRegexPattern.exec(searchData)) !== null) { 208 | const url = match[1]; 209 | const titleAndYear = match[3]; 210 | 211 | const result = extractTitleAndYear(titleAndYear); 212 | if (result !== null) { 213 | const id = url.match(idRegexPattern)?.[1] || null; 214 | const isMovie = url.includes('/films/'); 215 | const isShow = url.includes('/series/'); 216 | const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown'; 217 | 218 | movieData.push({ 219 | id: id ?? '', 220 | year: result.year ?? 0, 221 | type, 222 | url, 223 | title: match[2] 224 | }); 225 | console.log(`[MATCH] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`); 226 | } 227 | } 228 | 229 | // If year is provided, filter by year 230 | let filteredItems = movieData; 231 | if (media.releaseYear) { 232 | filteredItems = movieData.filter(item => item.year === media.releaseYear); 233 | console.log(`[FILTER] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`); 234 | } 235 | 236 | // If type is provided, filter by type 237 | if (media.type) { 238 | filteredItems = filteredItems.filter(item => item.type === media.type); 239 | console.log(`[FILTER] Items filtered by type ${media.type}: ${filteredItems.length}`); 240 | } 241 | 242 | if (filteredItems.length === 0 && movieData.length > 0) { 243 | console.log(`[WARNING] No items match the exact criteria. Showing all results:`); 244 | movieData.forEach((item, index) => { 245 | console.log(` ${index + 1}. ${item.title} (${item.year}) - ${item.type}`); 246 | }); 247 | 248 | // Let user select from results 249 | const selection = await prompt("Enter the number of the item you want to select (or press Enter to use the first result): "); 250 | const selectedIndex = parseInt(selection) - 1; 251 | 252 | if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex < movieData.length) { 253 | console.log(`[RESULT] Selected item: id=${movieData[selectedIndex].id}, title=${movieData[selectedIndex].title}`); 254 | return movieData[selectedIndex]; 255 | } else if (movieData.length > 0) { 256 | console.log(`[RESULT] Using first result: id=${movieData[0].id}, title=${movieData[0].title}`); 257 | return movieData[0]; 258 | } 259 | 260 | return null; 261 | } 262 | 263 | if (filteredItems.length > 0) { 264 | console.log(`[RESULT] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`); 265 | return filteredItems[0]; 266 | } else { 267 | console.log(`[ERROR] No matching items found`); 268 | return null; 269 | } 270 | } 271 | 272 | async function getTranslatorId(url, id, media) { 273 | console.log(`[STEP 2] Getting translator ID for url=${url}, id=${id}`); 274 | 275 | // Make sure the URL is absolute 276 | const fullUrl = url.startsWith('http') ? url : `${rezkaBase}${url.startsWith('/') ? url.substring(1) : url}`; 277 | console.log(`[REQUEST] Making request to: ${fullUrl}`); 278 | 279 | const response = await fetch(fullUrl, { 280 | headers: baseHeaders, 281 | }); 282 | 283 | if (!response.ok) { 284 | throw new Error(`HTTP error! status: ${response.status}`); 285 | } 286 | 287 | const responseText = await response.text(); 288 | console.log(`[RESPONSE] Translator page response length: ${responseText.length}`); 289 | 290 | // Translator ID 238 represents the Original + subtitles player. 291 | if (responseText.includes(`data-translator_id="238"`)) { 292 | console.log(`[RESULT] Found translator ID 238 (Original + subtitles)`); 293 | return '238'; 294 | } 295 | 296 | const functionName = media.type === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents'; 297 | const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i'); 298 | const match = responseText.match(regexPattern); 299 | const translatorId = match ? match[1] : null; 300 | 301 | console.log(`[RESULT] Extracted translator ID: ${translatorId}`); 302 | return translatorId; 303 | } 304 | 305 | async function getStream(id, translatorId, media) { 306 | console.log(`[STEP 3] Getting stream for id=${id}, translatorId=${translatorId}`); 307 | 308 | const searchParams = new URLSearchParams(); 309 | searchParams.append('id', id); 310 | searchParams.append('translator_id', translatorId); 311 | 312 | if (media.type === 'show') { 313 | searchParams.append('season', media.season.number.toString()); 314 | searchParams.append('episode', media.episode.number.toString()); 315 | console.log(`[PARAMS] Show params: season=${media.season.number}, episode=${media.episode.number}`); 316 | } 317 | 318 | const randomFavs = generateRandomFavs(); 319 | searchParams.append('favs', randomFavs); 320 | searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie'); 321 | 322 | const fullUrl = `${rezkaBase}ajax/get_cdn_series/`; 323 | console.log(`[REQUEST] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`); 324 | 325 | // Log the request details 326 | console.log('[HDRezka][FETCH DEBUG]', { 327 | url: fullUrl, 328 | method: 'POST', 329 | headers: baseHeaders, 330 | body: searchParams.toString() 331 | }); 332 | 333 | const response = await fetch(fullUrl, { 334 | method: 'POST', 335 | body: searchParams, 336 | headers: baseHeaders, 337 | }); 338 | 339 | // Log the response details 340 | let responseHeaders = {}; 341 | if (response.headers && typeof response.headers.forEach === 'function') { 342 | response.headers.forEach((value, key) => { 343 | responseHeaders[key] = value; 344 | }); 345 | } else if (response.headers && response.headers.entries) { 346 | for (const [key, value] of response.headers.entries()) { 347 | responseHeaders[key] = value; 348 | } 349 | } 350 | const responseText = await response.clone().text(); 351 | console.log('[HDRezka][FETCH RESPONSE]', { 352 | status: response.status, 353 | headers: responseHeaders, 354 | text: responseText 355 | }); 356 | 357 | if (!response.ok) { 358 | throw new Error(`HTTP error! status: ${response.status}`); 359 | } 360 | 361 | const rawText = await response.text(); 362 | console.log(`[RESPONSE] Stream response length: ${rawText.length}`); 363 | 364 | // Response content-type is text/html, but it's actually JSON 365 | try { 366 | const parsedResponse = JSON.parse(rawText); 367 | console.log(`[RESULT] Parsed response successfully`); 368 | 369 | // Process video qualities and subtitles 370 | const qualities = parseVideoLinks(parsedResponse.url); 371 | const captions = parseSubtitles(parsedResponse.subtitle); 372 | 373 | // Add the parsed data to the response 374 | parsedResponse.formattedQualities = qualities; 375 | parsedResponse.formattedCaptions = captions; 376 | 377 | return parsedResponse; 378 | } catch (e) { 379 | console.error(`[ERROR] Failed to parse JSON response: ${e.message}`); 380 | console.log(`[ERROR] Raw response: ${rawText.substring(0, 200)}...`); 381 | return null; 382 | } 383 | } 384 | 385 | // Main execution 386 | async function main() { 387 | try { 388 | console.log('=== HDREZKA SCRAPER TEST ==='); 389 | 390 | let media; 391 | 392 | // Check if we have command line arguments 393 | if (argOptions.title) { 394 | // Use command line arguments 395 | media = { 396 | type: argOptions.type || 'show', 397 | title: argOptions.title, 398 | releaseYear: argOptions.year || null 399 | }; 400 | 401 | // If it's a show, add season and episode 402 | if (media.type === 'show') { 403 | media.season = { number: argOptions.season || 1 }; 404 | media.episode = { number: argOptions.episode || 1 }; 405 | 406 | console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`); 407 | } else { 408 | console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`); 409 | } 410 | } else { 411 | // Get user input interactively 412 | const title = await prompt('Enter title to search: '); 413 | const mediaType = await prompt('Enter media type (movie/show): ').then(type => 414 | type.toLowerCase() === 'movie' || type.toLowerCase() === 'show' ? type.toLowerCase() : 'show' 415 | ); 416 | const releaseYear = await prompt('Enter release year (optional): ').then(year => 417 | year ? parseInt(year) : null 418 | ); 419 | 420 | // Create media object 421 | media = { 422 | type: mediaType, 423 | title: title, 424 | releaseYear: releaseYear 425 | }; 426 | 427 | // If it's a show, get season and episode 428 | if (mediaType === 'show') { 429 | const seasonNum = await prompt('Enter season number: ').then(num => parseInt(num) || 1); 430 | const episodeNum = await prompt('Enter episode number: ').then(num => parseInt(num) || 1); 431 | 432 | media.season = { number: seasonNum }; 433 | media.episode = { number: episodeNum }; 434 | 435 | console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`); 436 | } else { 437 | console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`); 438 | } 439 | } 440 | 441 | // Step 1: Search and find media ID 442 | const result = await searchAndFindMediaId(media); 443 | if (!result || !result.id) { 444 | console.log('No result found, exiting'); 445 | rl.close(); 446 | return; 447 | } 448 | 449 | // Step 2: Get translator ID 450 | const translatorId = await getTranslatorId(result.url, result.id, media); 451 | if (!translatorId) { 452 | console.log('No translator ID found, exiting'); 453 | rl.close(); 454 | return; 455 | } 456 | 457 | // Step 3: Get stream 458 | const streamData = await getStream(result.id, translatorId, media); 459 | if (!streamData) { 460 | console.log('No stream data found, exiting'); 461 | rl.close(); 462 | return; 463 | } 464 | 465 | // Format output in clean JSON similar to CLI output 466 | const formattedOutput = { 467 | embeds: [], 468 | stream: [ 469 | { 470 | id: 'primary', 471 | type: 'file', 472 | flags: ['cors-allowed', 'ip-locked'], 473 | captions: streamData.formattedCaptions.map(caption => ({ 474 | id: caption.url, 475 | language: caption.language === 'Русский' ? 'ru' : 476 | caption.language === 'Українська' ? 'uk' : 477 | caption.language === 'English' ? 'en' : caption.language.toLowerCase(), 478 | hasCorsRestrictions: false, 479 | type: 'vtt', 480 | url: caption.url 481 | })), 482 | qualities: Object.entries(streamData.formattedQualities).reduce((acc, [quality, data]) => { 483 | // Convert quality format to match CLI output 484 | // "360p" -> "360", "1080p Ultra" -> "1080" (or keep as is if needed) 485 | let qualityKey = quality; 486 | const numericMatch = quality.match(/^(\d+)p/); 487 | if (numericMatch) { 488 | qualityKey = numericMatch[1]; 489 | } 490 | 491 | acc[qualityKey] = { 492 | type: data.type, 493 | url: data.url 494 | }; 495 | return acc; 496 | }, {}) 497 | } 498 | ] 499 | }; 500 | 501 | // Display the formatted output 502 | console.log('✓ Done!'); 503 | console.log(JSON.stringify(formattedOutput, null, 2).replace(/"([^"]+)":/g, '$1:')); 504 | 505 | console.log('=== SCRAPING COMPLETE ==='); 506 | } catch (error) { 507 | console.error(`Error: ${error.message}`); 508 | if (error.cause) { 509 | console.error(`Cause: ${error.cause.message}`); 510 | } 511 | } finally { 512 | rl.close(); 513 | } 514 | } 515 | 516 | main(); -------------------------------------------------------------------------------- /DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # Nuvio Streams Self-Hosting Guide 2 | 3 | This guide will help you set up your own personal Nuvio Streams addon for Stremio. Don't worry if you're new to this - we'll go through each step clearly! 4 | 5 | ## What's In This Guide 6 | 7 | - [Super Quick Start](#super-quick-start) - The fastest way to get up and running 8 | - [Step-by-Step Installation](#step-by-step-installation) - Detailed instructions with explanations 9 | - [Configuration Options](#configuration-options) - All the settings you can change 10 | - [Troubleshooting](#troubleshooting) - Help if something goes wrong 11 | - [Optimization Tips](#optimization-tips) - Making your addon run better 12 | - [Complete Example](#complete-example) - Full configuration example 13 | 14 | ## Super Quick Start 15 | 16 | If you just want to get things running fast: 17 | 18 | 1. Make sure you have [Node.js](https://nodejs.org/) installed (download the "LTS" version) 19 | 2. Open your terminal or command prompt 20 | 3. Run these commands: 21 | 22 | ```bash 23 | # Get the code 24 | git clone https://github.com/tapframe/NuvioStreamsAddon.git 25 | cd NuvioStreamsAddon 26 | 27 | # Install what's needed 28 | npm install 29 | 30 | # Copy the example settings file 31 | cp .env.example .env 32 | 33 | # IMPORTANT: Edit the .env file to add your TMDB API key and provider settings 34 | # Open .env in any text editor and set TMDB_API_KEY=your_key_here (see Example .env below) 35 | 36 | # Start the addon only AFTER setting up your .env file 37 | npm start 38 | ``` 39 | 40 | 4. Open `http://localhost:7000` in your browser 41 | 5. Install the addon in Stremio by clicking the "Install Addon" button 42 | 43 | ## Step-by-Step Installation 44 | 45 | ### What You'll Need 46 | 47 | - **Computer** with internet access (Windows, Mac, or Linux) 48 | - **Node.js** (version 16 or newer) - This runs the addon 49 | - **npm** (comes with Node.js) - This helps install the needed files 50 | - **TMDB API Key** - Required for movie/TV information 51 | - **Basic computer skills** - Using terminal/command prompt, editing text files 52 | 53 | ### 1. Install Node.js 54 | 55 | 1. Visit [nodejs.org](https://nodejs.org/) 56 | 2. Download the "LTS" (Long Term Support) version 57 | 3. Follow the installation instructions for your operating system 58 | 4. To verify it's installed, open terminal/command prompt and type: 59 | ```bash 60 | node --version 61 | npm --version 62 | ``` 63 | You should see version numbers for both 64 | 65 | ### 2. Get the Addon Code 66 | 67 | 1. Open terminal/command prompt 68 | 2. Navigate to where you want to store the addon 69 | 3. Run these commands: 70 | 71 | ```bash 72 | # This downloads the code 73 | git clone https://github.com/tapframe/NuvioStreamsAddon.git 74 | 75 | # This moves into the downloaded folder 76 | cd NuvioStreamsAddon 77 | ``` 78 | 79 | If you don't have `git` installed, you can: 80 | - [Download the ZIP file](https://github.com/tapframe/NuvioStreamsAddon/archive/refs/heads/main.zip) 81 | - Extract it to a folder 82 | - Open terminal/command prompt and navigate to that folder 83 | 84 | ### 3. Install Dependencies 85 | 86 | Dependencies are extra pieces of code the addon needs to work. 87 | 88 | ```bash 89 | # This installs everything needed 90 | npm install 91 | ``` 92 | 93 | This might take a minute or two. You'll see a progress bar and some text output. 94 | 95 | ### 4. Set Up Configuration File (.env) 96 | 97 | This is the most important step! You need to create and edit a file called `.env` that contains all your settings. 98 | 99 | 1. First, copy the example configuration file: 100 | ```bash 101 | cp .env.example .env 102 | ``` 103 | 104 | 2. Now open the `.env` file in any text editor (Notepad, VS Code, etc.) 105 | 106 | 3. Find and set the required TMDB API key: 107 | ```env 108 | TMDB_API_KEY=your_tmdb_api_key_here 109 | ``` 110 | 111 | To get a TMDB API key: 112 | - Create a free account at [themoviedb.org](https://www.themoviedb.org/signup) 113 | - Go to [Settings → API](https://www.themoviedb.org/settings/api) after logging in 114 | - Request an API key for personal use 115 | - Copy the API key they give you 116 | 117 | 4. Configure providers and options. See the "Example .env" further below for a complete up-to-date template. 118 | 119 | 5. Enable caching for better performance: 120 | ```env 121 | # Cache settings - "false" means caching is ON 122 | DISABLE_CACHE=false 123 | DISABLE_STREAM_CACHE=false 124 | ``` 125 | 126 | 6. Set up a ShowBox proxy (recommended): 127 | ```env 128 | # ShowBox often needs a proxy to work properly 129 | SHOWBOX_PROXY_URL_VALUE=https://your-proxy-url.netlify.app/?destination= 130 | ``` 131 | 132 | To get a proxy URL: 133 | - Deploy a proxy using the button in the [Advanced Options](#advanced-options) section 134 | - Or use a public proxy (less reliable) 135 | 136 | 7. Save and close the file 137 | 138 | ### 5. Set Up ShowBox Cookie (Optional but Recommended) 139 | 140 | For the best streaming experience: 141 | 142 | 1. Create a file named `cookies.txt` in the main folder 143 | 2. Add your ShowBox cookie to this file 144 | 145 | #### Detailed Guide: How to Get ShowBox Cookie 146 | 147 | 1. **Create a FebBox account**: 148 | - Visit [FebBox.com](https://www.febbox.com) 149 | - Sign up using your Google account or email 150 | 151 | 2. **Log in to your account** 152 | 153 | 3. **Open developer tools in your browser**: 154 | - **Chrome/Edge**: Press `F12` or right-click anywhere and select "Inspect" 155 | - **Firefox**: Press `F12` or right-click and select "Inspect Element" 156 | - **Safari**: Enable developer tools in Preferences → Advanced, then press `Command+Option+I` 157 | 158 | 4. **Navigate to the cookies section**: 159 | - **Chrome/Edge**: Click on "Application" tab → expand "Storage" → "Cookies" → click on "febbox.com" 160 | - **Firefox**: Click on "Storage" tab → "Cookies" → select "febbox.com" 161 | - **Safari**: Click on "Storage" tab → "Cookies" 162 | 163 | 5. **Find the "ui" cookie**: 164 | - Look for a cookie named `ui` in the list 165 | - This is a long string that usually starts with "ey" 166 | - If you don't see it, try refreshing the page and checking again 167 | 168 | 6. **Copy the cookie value**: 169 | - Click on the `ui` cookie 170 | - Double-click the value field to select it all 171 | - Copy the entire string (Ctrl+C or Command+C) 172 | 173 | 7. **Paste into `cookies.txt`**: 174 | - Open/create the `cookies.txt` file in the root of your addon folder 175 | - Paste the cookie value (just the value, nothing else) 176 | - Save the file 177 | 178 | **Visual Cues:** 179 | - The `ui` cookie is usually the one with the longest value 180 | - It typically starts with "ey" followed by many random characters 181 | - The cookie value is what you need, not the cookie name 182 | 183 | **Important Notes:** 184 | - Cookies expire after some time, so you might need to repeat this process occasionally 185 | - Each account gets its own 100GB monthly quota 186 | - Using your own cookie gives you access to 4K/HDR/DV content 187 | - With a personal cookie, streams will be faster and display a lightning indicator in the UI 188 | 189 | ### 6. Start the Addon 190 | 191 | Now that you've configured everything, you can start the addon: 192 | 193 | ```bash 194 | npm start 195 | ``` 196 | 197 | You should see output that ends with something like: 198 | ``` 199 | Addon running at: http://localhost:7000/manifest.json 200 | ``` 201 | 202 | ### 7. Install in Stremio 203 | 204 | 1. Open your web browser and go to: `http://localhost:7000` 205 | 2. You'll see a page with an "Install Addon" button 206 | 3. Click the button - this will open Stremio with an installation prompt 207 | 4. Click "Install" in Stremio 208 | 5. That's it! The addon is now installed in your Stremio 209 | 210 | ## Configuration Options 211 | 212 | Let's look at the important settings you can change in the `.env` file. Don't worry - we'll explain what each one does! 213 | 214 | ### Basic Settings (Most Important) 215 | 216 | ```env 217 | # The only REQUIRED setting - get from themoviedb.org 218 | TMDB_API_KEY=your_key_here 219 | ``` 220 | 221 | ### Provider Settings 222 | 223 | These control which streaming sources are active. Only currently supported providers are shown here. Set to true/false. 224 | 225 | ```env 226 | # Core 227 | ENABLE_VIDZEE_PROVIDER=true 228 | ENABLE_MP4HYDRA_PROVIDER=true 229 | ENABLE_UHDMOVIES_PROVIDER=true 230 | ENABLE_MOVIESMOD_PROVIDER=true 231 | ENABLE_TOPMOVIES_PROVIDER=true 232 | ENABLE_MOVIESDRIVE_PROVIDER=true 233 | ENABLE_4KHDHUB_PROVIDER=true 234 | ENABLE_VIXSRC_PROVIDER=true 235 | ENABLE_MOVIEBOX_PROVIDER=true 236 | ENABLE_SOAPERTV_PROVIDER=true 237 | ``` 238 | 239 | | Provider | What It Offers | Notes | 240 | |----------|----------------|-------| 241 | | VidZee | Movies | General sources | 242 | | MP4Hydra | Movies/TV | Multiple servers; quality tagged | 243 | | UHDMovies | Movies | Good quality; supports external service mode | 244 | | MoviesMod | Movies | Pre-formatted titles with rich metadata | 245 | | TopMovies | Movies | Bollywood/regional focus | 246 | | MoviesDrive | Movies | Direct links (e.g., Pixeldrain) | 247 | | 4KHDHub | Movies/TV | Multiple servers; 4K/HDR/DV tagging | 248 | | Vixsrc | Movies/TV | Alternative source | 249 | | MovieBox | Movies/TV | General source | 250 | | SoaperTV | TV | Episodic content | 251 | 252 | ### Performance Settings 253 | 254 | These settings help your addon run faster and use less resources: 255 | 256 | ```env 257 | # Cache settings - "false" means caching is ON (which is good) 258 | DISABLE_CACHE=false 259 | DISABLE_STREAM_CACHE=false 260 | ``` 261 | 262 | Caching saves previous searches and results, making everything faster! 263 | 264 | ### ShowBox Configuration 265 | 266 | ShowBox is one of the best providers but needs a bit more setup: 267 | 268 | #### Personal Cookie (Best Experience) 269 | 270 | 1. Create a file named `cookies.txt` in the main folder 271 | 2. Add your ShowBox cookie to this file 272 | 273 | With your own cookie: 274 | - You get your own 100GB monthly quota 275 | - Access to higher quality streams (4K/HDR) 276 | - Faster speeds 277 | 278 | ## Troubleshooting 279 | 280 | ### Common Problem: No Streams Found 281 | 282 | **What to try:** 283 | 1. **Be patient** - sometimes it takes 30+ seconds to find streams 284 | 2. **Try again** - click the same movie/show again after a minute 285 | 3. **Check provider settings** - make sure providers are enabled 286 | 287 | ### Common Problem: Addon Won't Start 288 | 289 | **What to try:** 290 | 1. Make sure Node.js is installed correctly 291 | 2. Check you've run `npm install` 292 | 3. Verify the `.env` file exists and has TMDB_API_KEY set 293 | 4. Look for error messages in the terminal 294 | 295 | ### Common Problem: Slow Performance 296 | 297 | **What to try:** 298 | 1. Enable caching: Set `DISABLE_CACHE=false` and `DISABLE_STREAM_CACHE=false` 299 | 2. Use your own ShowBox cookie 300 | 3. Only enable the providers you actually use 301 | 302 | ### Common Problem: Cookie Not Working 303 | 304 | **What to try:** 305 | 1. **Verify the cookie** - Make sure you copied the entire value 306 | 2. **Check for whitespace** - There should be no extra spaces before or after the cookie 307 | 3. **Get a fresh cookie** - Cookies expire, so you might need to get a new one 308 | 4. **Check the format** - The `cookies.txt` file should only contain the cookie value, nothing else 309 | 5. **Restart the addon** - After updating the cookie, restart the addon with `npm start` 310 | 311 | ## Running Your Addon All the Time 312 | 313 | If you want your addon to keep running even when you close the terminal: 314 | 315 | ### Windows Method: 316 | 317 | 1. Create a file called `start.bat` with these contents: 318 | ``` 319 | @echo off 320 | cd /d %~dp0 321 | npm start 322 | pause 323 | ``` 324 | 2. Double-click this file to start your addon 325 | 326 | ### Using PM2 (Advanced): 327 | 328 | ```bash 329 | # Install PM2 330 | npm install -g pm2 331 | 332 | # Start the addon with PM2 333 | pm2 start npm --name "nuvio-streams" -- start 334 | 335 | # Make it start when your computer restarts 336 | pm2 save 337 | pm2 startup 338 | ``` 339 | 340 | ## Accessing From Other Devices 341 | 342 | Once your addon is running, you can use it on any device on your home network: 343 | 344 | 1. Find your computer's IP address: 345 | - Windows: Type `ipconfig` in command prompt 346 | - Mac/Linux: Type `ifconfig` or `ip addr` in terminal 347 | 348 | 2. Use this address in Stremio on other devices: 349 | - Example: `http://192.168.1.100:7000/manifest.json` 350 | 351 | ## Optimization Tips 352 | 353 | For the best experience: 354 | 355 | 1. **Enable caching** - Makes everything faster 356 | ```env 357 | DISABLE_CACHE=false 358 | DISABLE_STREAM_CACHE=false 359 | ``` 360 | 361 | 2. **Use personal cookies** - Get your own bandwidth quota 362 | - Create and set up `cookies.txt` file 363 | 364 | 3. **Set up a ShowBox proxy** - Recommended for reliable streams 365 | ```env 366 | SHOWBOX_PROXY_URL_VALUE=https://your-proxy-url.netlify.app/?destination= 367 | ``` 368 | 369 | 4. **Only enable providers you use** - Reduces search time 370 | - Turn off unused providers in your `.env` file 371 | 372 | 5. **Keep your addon updated** 373 | - Check for updates weekly: 374 | ```bash 375 | cd NuvioStreamsAddon 376 | git pull 377 | npm install 378 | ``` 379 | 380 | ## Example .env (Aligned with this repo) 381 | 382 | Use the following template, which matches the `.env` in this repository and the current code: 383 | 384 | ```env 385 | # Cache Settings 386 | DISABLE_CACHE=false 387 | DISABLE_STREAM_CACHE=false 388 | USE_REDIS_CACHE=false 389 | REDIS_URL= 390 | 391 | # Enable PStream (ShowBox-backed CDN) handling 392 | ENABLE_PSTREAM_API=false 393 | 394 | # URL Validation Settings 395 | DISABLE_URL_VALIDATION=false 396 | DISABLE_4KHDHUB_URL_VALIDATION=true 397 | 398 | # ShowBox proxy rotation (recommended) 399 | # Comma-separated list of edge proxies; each must end with ?destination= 400 | SHOWBOX_PROXY_URLS=https://proxy-primary.example.workers.dev/?destination=,https://proxy-alt-1.example.workers.dev/?destination=,https://proxy-alt-2.example.workers.dev/?destination= 401 | 402 | # FebBox proxy rotation (optional; used when resolving personal cookie calls) 403 | FEBBOX_PROXY_URLS=https://proxy-primary.example.workers.dev/?destination=,https://proxy-alt-1.example.workers.dev/?destination= 404 | 405 | # Provider-specific Proxy URLs (optional; leave empty for direct) 406 | VIDSRC_PROXY_URL= 407 | VIDZEE_PROXY_URL= 408 | SOAPERTV_PROXY_URL= 409 | UHDMOVIES_PROXY_URL= 410 | MOVIESMOD_PROXY_URL= 411 | TOPMOVIES_PROXY_URL= 412 | 413 | # Provider Enablement 414 | ENABLE_VIDZEE_PROVIDER=true 415 | ENABLE_VIXSRC_PROVIDER=true 416 | ENABLE_MP4HYDRA_PROVIDER=true 417 | ENABLE_UHDMOVIES_PROVIDER=true 418 | ENABLE_MOVIESMOD_PROVIDER=true 419 | ENABLE_TOPMOVIES_PROVIDER=true 420 | ENABLE_MOVIESDRIVE_PROVIDER=true 421 | ENABLE_4KHDHUB_PROVIDER=true 422 | ENABLE_MOVIEBOX_PROVIDER=true 423 | ENABLE_SOAPERTV_PROVIDER=true 424 | 425 | # API Keys 426 | TMDB_API_KEY=your_tmdb_api_key_here 427 | 428 | # External Provider Services 429 | USE_EXTERNAL_PROVIDERS=false 430 | EXTERNAL_UHDMOVIES_URL= 431 | EXTERNAL_TOPMOVIES_URL= 432 | EXTERNAL_MOVIESMOD_URL= 433 | 434 | # Port configuration 435 | PORT=7000 436 | ``` 437 | 438 | Important notes: 439 | 1. Replace `your_tmdb_api_key_here` with your actual TMDB API key 440 | 2. Replace proxy URLs with your deployed Cloudflare Workers (or Netlify) proxy URL(s) 441 | 3. The `cookies.txt` file is separate from this configuration and is auto-read by the addon 442 | 4. Only enable the providers you actually use 443 | 5. Uncomment lines (remove #) only if you need those features 444 | 445 | ### About ShowBox Personal Cookie and PStream 446 | 447 | - Place your FebBox `ui` cookie value into `cookies.txt` at the project root (single-line value). 448 | - With a valid cookie, the addon will: 449 | - Prefer faster ShowBox links and display a lightning icon next to ShowBox 450 | - Show your remaining quota on ShowBox/PStream entries when available 451 | - PStream links (a ShowBox-backed CDN) appear as streaming sources and are not cached; they inherit ShowBox display conventions in the UI. 452 | 453 | ### ShowBox Multi-Proxy Setup (High Throughput) 454 | 455 | To handle large numbers of requests or bursty traffic, configure multiple proxy endpoints and enable rotation: 456 | 457 | 1) Add multiple proxies in `.env` (as shown in the Example .env above). Use the `SHOWBOX_PROXY_URLS` comma-separated list for ShowBox, and `FEBBOX_PROXY_URLS` for FebBox calls when using a personal cookie: 458 | 459 | ```env 460 | SHOWBOX_PROXY_URLS=https://proxy-primary.example.workers.dev/?destination=,https://proxy-alt-1.example.workers.dev/?destination=,https://proxy-alt-2.example.workers.dev/?destination= 461 | FEBBOX_PROXY_URLS=https://proxy-primary.example.workers.dev/?destination=,https://proxy-alt-1.example.workers.dev/?destination= 462 | ``` 463 | 464 | - The addon round-robins across `SHOWBOX_PROXY_URLS` and `FEBBOX_PROXY_URLS` values automatically. 465 | - Ensure each proxy ends with `?destination=` so the addon can append the upstream URL. 466 | 467 | 2) Recommended limits and best practices: 468 | - Distribute traffic across multiple regions in Cloudflare to reduce egress concentration. 469 | - Keep Workers simple (no heavy parsing) and forward only required headers. 470 | - Consider enabling caching at the Worker/edge for static assets if appropriate (not for signed or user-specific URLs). 471 | 472 | ### Proxy Setup Notes 473 | 474 | You may use your own HTTP edge proxy (for example on Cloudflare Workers or similar) as the target for `SHOWBOX_PROXY_URLS` and `FEBBOX_PROXY_URLS`. Ensure each proxy URL ends with `?destination=` and properly forwards method, headers, and body while adding permissive CORS for your deployment. Avoid copying proxy code here; follow your platform’s security best practices. 475 | 476 | ## Success 477 | 478 | Congratulations! You now have your own personal streaming addon with: 479 | 480 | - Multiple streaming sources 481 | - Your own bandwidth quotas 482 | - No limits on stream quality 483 | - Full control over settings 484 | 485 | Happy streaming! 486 | 487 | --- 488 | 489 | ## Advanced Options 490 | 491 | *Note: This section is for more experienced users.* 492 | 493 | If you want to dive deeper into configuration options, check these sections: 494 | 495 | ### Advanced Proxy Configuration 496 | 497 | ShowBox usually requires a proxy to work properly in most regions: 498 | 499 | ```env 500 | # Set up a proxy for ShowBox (recommended) 501 | SHOWBOX_PROXY_URL_VALUE=https://your-proxy-url.netlify.app/?destination= 502 | ``` 503 | 504 | ### Setting Up Proxies 505 | 506 | 1. Deploy: [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/p-stream/simple-proxy) 507 | 2. Copy the deployed URL and add `?destination=` at the end 508 | 3. Add to your `.env` file as `SHOWBOX_PROXY_URL_VALUE=your-url/?destination=` 509 | 510 | ### Provider-Specific Proxies (optional) 511 | 512 | ```env 513 | # Example placeholders (use only if you operate your own proxies) 514 | VIDSRC_PROXY_URL= 515 | VIDZEE_PROXY_URL= 516 | SOAPERTV_PROXY_URL= 517 | ``` 518 | 519 | ### External Provider Services 520 | 521 | If you operate separate services that implement the addon’s external provider API for certain providers, you can point the addon to them: 522 | 523 | ```env 524 | USE_EXTERNAL_PROVIDERS=true 525 | EXTERNAL_UHDMOVIES_URL=https://your-uhdmovies-service.example.com 526 | EXTERNAL_TOPMOVIES_URL=https://your-topmovies-service.example.com 527 | EXTERNAL_MOVIESMOD_URL=https://your-moviesmod-service.example.com 528 | ``` 529 | 530 | -------------------------------------------------------------------------------- /scrapersdirect/myflixer-extractor.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const axios = require('axios'); 4 | const cheerio = require('cheerio'); 5 | const { URL } = require('url'); 6 | 7 | class MyFlixerExtractor { 8 | constructor() { 9 | this.mainUrl = 'https://watch32.sx'; 10 | this.videostrUrl = 'https://videostr.net'; 11 | } 12 | 13 | async search(query) { 14 | try { 15 | const searchUrl = `${this.mainUrl}/search/${query.replace(/\s+/g, '-')}`; 16 | console.log(`Searching: ${searchUrl}`); 17 | 18 | const response = await axios.get(searchUrl); 19 | const $ = cheerio.load(response.data); 20 | 21 | const results = []; 22 | $('.flw-item').each((i, element) => { 23 | const title = $(element).find('h2.film-name > a').attr('title'); 24 | const link = $(element).find('h2.film-name > a').attr('href'); 25 | const poster = $(element).find('img.film-poster-img').attr('data-src'); 26 | 27 | if (title && link) { 28 | results.push({ 29 | title, 30 | url: link.startsWith('http') ? link : `${this.mainUrl}${link}`, 31 | poster 32 | }); 33 | } 34 | }); 35 | 36 | console.log('Search results found:'); 37 | results.forEach((result, index) => { 38 | console.log(`${index + 1}. ${result.title}`); 39 | }); 40 | 41 | return results; 42 | } catch (error) { 43 | console.error('Search error:', error.message); 44 | return []; 45 | } 46 | } 47 | 48 | async getContentDetails(url) { 49 | try { 50 | console.log(`Getting content details: ${url}`); 51 | const response = await axios.get(url); 52 | const $ = cheerio.load(response.data); 53 | 54 | const contentId = $('.detail_page-watch').attr('data-id'); 55 | const name = $('.detail_page-infor h2.heading-name > a').text(); 56 | const isMovie = url.includes('movie'); 57 | 58 | if (isMovie) { 59 | return { 60 | type: 'movie', 61 | name, 62 | data: `list/${contentId}` 63 | }; 64 | } else { 65 | // Get TV series episodes 66 | const episodes = []; 67 | const seasonsResponse = await axios.get(`${this.mainUrl}/ajax/season/list/${contentId}`); 68 | const $seasons = cheerio.load(seasonsResponse.data); 69 | 70 | for (const season of $seasons('a.ss-item').toArray()) { 71 | const seasonId = $(season).attr('data-id'); 72 | const seasonNum = $(season).text().replace('Season ', ''); 73 | 74 | const episodesResponse = await axios.get(`${this.mainUrl}/ajax/season/episodes/${seasonId}`); 75 | const $episodes = cheerio.load(episodesResponse.data); 76 | 77 | $episodes('a.eps-item').each((i, episode) => { 78 | const epId = $(episode).attr('data-id'); 79 | const title = $(episode).attr('title'); 80 | const match = title.match(/Eps (\d+): (.+)/); 81 | 82 | if (match) { 83 | episodes.push({ 84 | id: epId, 85 | episode: parseInt(match[1]), 86 | name: match[2], 87 | season: parseInt(seasonNum.replace('Series', '').trim()), 88 | data: `servers/${epId}` 89 | }); 90 | } 91 | }); 92 | } 93 | 94 | return { 95 | type: 'series', 96 | name, 97 | episodes 98 | }; 99 | } 100 | } catch (error) { 101 | console.error('Content details error:', error.message); 102 | return null; 103 | } 104 | } 105 | 106 | async getServerLinks(data) { 107 | try { 108 | console.log(`Getting server links: ${data}`); 109 | const response = await axios.get(`${this.mainUrl}/ajax/episode/${data}`); 110 | const $ = cheerio.load(response.data); 111 | 112 | const servers = []; 113 | $('a.link-item').each((i, element) => { 114 | const linkId = $(element).attr('data-linkid') || $(element).attr('data-id'); 115 | if (linkId) { 116 | servers.push(linkId); 117 | } 118 | }); 119 | 120 | return servers; 121 | } catch (error) { 122 | console.error('Server links error:', error.message); 123 | return []; 124 | } 125 | } 126 | 127 | async getSourceUrl(linkId) { 128 | try { 129 | console.log(`Getting source URL for linkId: ${linkId}`); 130 | const response = await axios.get(`${this.mainUrl}/ajax/episode/sources/${linkId}`); 131 | return response.data.link; 132 | } catch (error) { 133 | console.error('Source URL error:', error.message); 134 | return null; 135 | } 136 | } 137 | 138 | async extractVideostrM3u8(url) { 139 | try { 140 | console.log(`Extracting from Videostr: ${url}`); 141 | 142 | const headers = { 143 | 'Accept': '*/*', 144 | 'X-Requested-With': 'XMLHttpRequest', 145 | 'Referer': this.videostrUrl, 146 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 147 | }; 148 | 149 | // Extract ID from URL 150 | const id = url.split('/').pop().split('?')[0]; 151 | 152 | // Get nonce from embed page 153 | const embedResponse = await axios.get(url, { headers }); 154 | const embedHtml = embedResponse.data; 155 | 156 | // Try to find 48-character nonce 157 | let nonce = embedHtml.match(/\b[a-zA-Z0-9]{48}\b/); 158 | if (nonce) { 159 | nonce = nonce[0]; 160 | } else { 161 | // Try to find three 16-character segments 162 | const matches = embedHtml.match(/\b([a-zA-Z0-9]{16})\b.*?\b([a-zA-Z0-9]{16})\b.*?\b([a-zA-Z0-9]{16})\b/); 163 | if (matches) { 164 | nonce = matches[1] + matches[2] + matches[3]; 165 | } 166 | } 167 | 168 | if (!nonce) { 169 | throw new Error('Could not extract nonce'); 170 | } 171 | 172 | console.log(`Extracted nonce: ${nonce}`); 173 | 174 | // Get sources from API 175 | const apiUrl = `${this.videostrUrl}/embed-1/v3/e-1/getSources?id=${id}&_k=${nonce}`; 176 | console.log(`API URL: ${apiUrl}`); 177 | 178 | const sourcesResponse = await axios.get(apiUrl, { headers }); 179 | const sourcesData = sourcesResponse.data; 180 | 181 | if (!sourcesData.sources) { 182 | throw new Error('No sources found in response'); 183 | } 184 | 185 | let m3u8Url = sourcesData.sources; 186 | 187 | // Check if sources is already an M3U8 URL 188 | if (!m3u8Url.includes('.m3u8')) { 189 | console.log('Sources are encrypted, attempting to decrypt...'); 190 | 191 | // Get decryption key 192 | const keyResponse = await axios.get('https://raw.githubusercontent.com/yogesh-hacker/MegacloudKeys/refs/heads/main/keys.json'); 193 | const key = keyResponse.data.vidstr; 194 | 195 | if (!key) { 196 | throw new Error('Could not get decryption key'); 197 | } 198 | 199 | // Decrypt using Google Apps Script 200 | const decodeUrl = 'https://script.google.com/macros/s/AKfycbx-yHTwupis_JD0lNzoOnxYcEYeXmJZrg7JeMxYnEZnLBy5V0--UxEvP-y9txHyy1TX9Q/exec'; 201 | const fullUrl = `${decodeUrl}?encrypted_data=${encodeURIComponent(m3u8Url)}&nonce=${encodeURIComponent(nonce)}&secret=${encodeURIComponent(key)}`; 202 | 203 | const decryptResponse = await axios.get(fullUrl); 204 | const decryptedData = decryptResponse.data; 205 | 206 | // Extract file URL from decrypted response 207 | const fileMatch = decryptedData.match(/"file":"(.*?)"/); 208 | if (fileMatch) { 209 | m3u8Url = fileMatch[1]; 210 | } else { 211 | throw new Error('Could not extract video URL from decrypted response'); 212 | } 213 | } 214 | 215 | console.log(`Final M3U8 URL: ${m3u8Url}`); 216 | 217 | // Filter only megacdn links 218 | if (!m3u8Url.includes('megacdn.co')) { 219 | console.log('Skipping non-megacdn link'); 220 | return null; 221 | } 222 | 223 | // Parse master playlist to extract quality streams 224 | const qualities = await this.parseM3U8Qualities(m3u8Url); 225 | 226 | return { 227 | m3u8Url, 228 | qualities, 229 | headers: { 230 | 'Referer': 'https://videostr.net/', 231 | 'Origin': 'https://videostr.net/' 232 | } 233 | }; 234 | 235 | } catch (error) { 236 | console.error('Videostr extraction error:', error.message); 237 | return null; 238 | } 239 | } 240 | 241 | async parseM3U8Qualities(masterUrl) { 242 | try { 243 | const response = await axios.get(masterUrl, { 244 | headers: { 245 | 'Referer': 'https://videostr.net/', 246 | 'Origin': 'https://videostr.net/' 247 | } 248 | }); 249 | 250 | const playlist = response.data; 251 | const qualities = []; 252 | 253 | // Parse M3U8 master playlist 254 | const lines = playlist.split('\n'); 255 | for (let i = 0; i < lines.length; i++) { 256 | const line = lines[i].trim(); 257 | if (line.startsWith('#EXT-X-STREAM-INF:')) { 258 | const nextLine = lines[i + 1]?.trim(); 259 | if (nextLine && !nextLine.startsWith('#')) { 260 | // Extract resolution and bandwidth 261 | const resolutionMatch = line.match(/RESOLUTION=(\d+x\d+)/); 262 | const bandwidthMatch = line.match(/BANDWIDTH=(\d+)/); 263 | 264 | const resolution = resolutionMatch ? resolutionMatch[1] : 'Unknown'; 265 | const bandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1]) : 0; 266 | 267 | // Determine quality label 268 | let quality = 'Unknown'; 269 | if (resolution.includes('1920x1080')) quality = '1080p'; 270 | else if (resolution.includes('1280x720')) quality = '720p'; 271 | else if (resolution.includes('640x360')) quality = '360p'; 272 | else if (resolution.includes('854x480')) quality = '480p'; 273 | 274 | qualities.push({ 275 | quality, 276 | resolution, 277 | bandwidth, 278 | url: nextLine.startsWith('http') ? nextLine : new URL(nextLine, masterUrl).href 279 | }); 280 | } 281 | } 282 | } 283 | 284 | // Sort by bandwidth (highest first) 285 | qualities.sort((a, b) => b.bandwidth - a.bandwidth); 286 | 287 | return qualities; 288 | } catch (error) { 289 | console.error('Error parsing M3U8 qualities:', error.message); 290 | return []; 291 | } 292 | } 293 | 294 | async extractM3u8Links(query, episodeNumber = null, seasonNumber = null) { 295 | try { 296 | // Search for content 297 | const searchResults = await this.search(query); 298 | if (searchResults.length === 0) { 299 | console.log('No search results found'); 300 | return []; 301 | } 302 | 303 | console.log(`Found ${searchResults.length} results`); 304 | 305 | // Try to find exact match first, then partial match 306 | let selectedResult = searchResults.find(result => 307 | result.title.toLowerCase() === query.toLowerCase() 308 | ); 309 | 310 | if (!selectedResult) { 311 | // Look for best partial match (contains all words from query) 312 | const queryWords = query.toLowerCase().split(' '); 313 | selectedResult = searchResults.find(result => { 314 | const titleLower = result.title.toLowerCase(); 315 | return queryWords.every(word => titleLower.includes(word)); 316 | }); 317 | } 318 | 319 | // Fallback to first result if no good match found 320 | if (!selectedResult) { 321 | selectedResult = searchResults[0]; 322 | } 323 | 324 | console.log(`Selected: ${selectedResult.title}`); 325 | 326 | // Get content details 327 | const contentDetails = await this.getContentDetails(selectedResult.url); 328 | if (!contentDetails) { 329 | console.log('Could not get content details'); 330 | return []; 331 | } 332 | 333 | let dataToProcess = []; 334 | 335 | if (contentDetails.type === 'movie') { 336 | dataToProcess.push(contentDetails.data); 337 | } else { 338 | // For TV series, filter by episode/season if specified 339 | let episodes = contentDetails.episodes; 340 | 341 | if (seasonNumber) { 342 | episodes = episodes.filter(ep => ep.season === seasonNumber); 343 | } 344 | 345 | if (episodeNumber) { 346 | episodes = episodes.filter(ep => ep.episode === episodeNumber); 347 | } 348 | 349 | if (episodes.length === 0) { 350 | console.log('No matching episodes found'); 351 | return []; 352 | } 353 | 354 | // Use first matching episode or all if no specific episode requested 355 | const targetEpisode = episodeNumber ? episodes[0] : episodes[0]; 356 | console.log(`Selected episode: S${targetEpisode.season}E${targetEpisode.episode} - ${targetEpisode.name}`); 357 | dataToProcess.push(targetEpisode.data); 358 | } 359 | 360 | const allM3u8Links = []; 361 | 362 | // Process all data in parallel 363 | const allPromises = []; 364 | 365 | for (const data of dataToProcess) { 366 | // Get server links 367 | const serverLinksPromise = this.getServerLinks(data).then(async (serverLinks) => { 368 | console.log(`Found ${serverLinks.length} servers`); 369 | 370 | // Process all server links in parallel 371 | const linkPromises = serverLinks.map(async (linkId) => { 372 | try { 373 | // Get source URL 374 | const sourceUrl = await this.getSourceUrl(linkId); 375 | if (!sourceUrl) return null; 376 | 377 | console.log(`Source URL: ${sourceUrl}`); 378 | 379 | // Check if it's a videostr URL 380 | if (sourceUrl.includes('videostr.net')) { 381 | const result = await this.extractVideostrM3u8(sourceUrl); 382 | if (result) { 383 | return { 384 | source: 'videostr', 385 | m3u8Url: result.m3u8Url, 386 | qualities: result.qualities, 387 | headers: result.headers 388 | }; 389 | } 390 | } 391 | return null; 392 | } catch (error) { 393 | console.error(`Error processing link ${linkId}:`, error.message); 394 | return null; 395 | } 396 | }); 397 | 398 | return Promise.all(linkPromises); 399 | }); 400 | 401 | allPromises.push(serverLinksPromise); 402 | } 403 | 404 | // Wait for all promises to complete 405 | const results = await Promise.all(allPromises); 406 | 407 | // Flatten and filter results 408 | for (const serverResults of results) { 409 | for (const result of serverResults) { 410 | if (result) { 411 | allM3u8Links.push(result); 412 | } 413 | } 414 | } 415 | 416 | return allM3u8Links; 417 | 418 | } catch (error) { 419 | console.error('Extraction error:', error.message); 420 | return []; 421 | } 422 | } 423 | } 424 | 425 | // CLI usage 426 | if (require.main === module) { 427 | const args = process.argv.slice(2); 428 | 429 | if (args.length === 0) { 430 | console.log('Usage: node myflixer-extractor.js "<search query>" [episode] [season]'); 431 | console.log('Examples:'); 432 | console.log(' node myflixer-extractor.js "Avengers Endgame"'); 433 | console.log(' node myflixer-extractor.js "Breaking Bad" 1 1 # Season 1, Episode 1'); 434 | process.exit(1); 435 | } 436 | 437 | const query = args[0]; 438 | const episode = args[1] ? parseInt(args[1]) : null; 439 | const season = args[2] ? parseInt(args[2]) : null; 440 | 441 | const extractor = new MyFlixerExtractor(); 442 | 443 | extractor.extractM3u8Links(query, episode, season) 444 | .then(links => { 445 | if (links.length === 0) { 446 | console.log('No M3U8 links found'); 447 | } else { 448 | console.log('\n=== EXTRACTED M3U8 LINKS ==='); 449 | links.forEach((link, index) => { 450 | console.log(`\nLink ${index + 1}:`); 451 | console.log(`Source: ${link.source}`); 452 | console.log(`Master M3U8 URL: ${link.m3u8Url}`); 453 | console.log(`Headers: ${JSON.stringify(link.headers, null, 2)}`); 454 | 455 | if (link.qualities && link.qualities.length > 0) { 456 | console.log('Available Qualities:'); 457 | link.qualities.forEach((quality, qIndex) => { 458 | console.log(` ${qIndex + 1}. ${quality.quality} (${quality.resolution}) - ${Math.round(quality.bandwidth/1000)}kbps`); 459 | console.log(` URL: ${quality.url}`); 460 | }); 461 | } 462 | }); 463 | } 464 | }) 465 | .catch(error => { 466 | console.error('Error:', error.message); 467 | process.exit(1); 468 | }); 469 | } 470 | 471 | module.exports = MyFlixerExtractor; -------------------------------------------------------------------------------- /providers/vidsrcextractor.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const cheerio = require('cheerio'); 3 | // --- Constants --- 4 | const VIDSRC_PROXY_URL = process.env.VIDSRC_PROXY_URL; 5 | let BASEDOM = "https://cloudnestra.com"; // This can be updated by serversLoad 6 | const SOURCE_URL = "https://vidsrc.xyz/embed"; 7 | // --- Helper: Conditional Proxied Fetch --- 8 | // This function wraps the native fetch. If VIDSRC_PROXY_URL is set in the environment, 9 | // it routes requests through the proxy. Otherwise, it makes a direct request. 10 | async function fetchWrapper(url, options) { 11 | if (VIDSRC_PROXY_URL) { 12 | const proxiedUrl = `${VIDSRC_PROXY_URL}${encodeURIComponent(url)}`; 13 | console.log(`[VidSrc Proxy] Fetching: ${url} via proxy`); 14 | // Note: The proxy will handle the actual fetching, so we send the request to the proxy URL. 15 | // We pass the original headers in the options, the proxy should forward them. 16 | return fetch(proxiedUrl, options); 17 | } 18 | // If no proxy is set, fetch directly. 19 | console.log(`[VidSrc Direct] Fetching: ${url}`); 20 | return fetch(url, options); 21 | } 22 | // --- Helper Functions (copied and adapted from src/extractor.ts) --- 23 | async function serversLoad(html) { 24 | const $ = cheerio.load(html); 25 | const servers = []; 26 | const title = $("title").text() ?? ""; 27 | const baseFrameSrc = $("iframe").attr("src") ?? ""; 28 | if (baseFrameSrc) { 29 | try { 30 | const fullUrl = baseFrameSrc.startsWith("//") ? "https:" + baseFrameSrc : baseFrameSrc; 31 | BASEDOM = new URL(fullUrl).origin; 32 | } 33 | catch (e) { 34 | console.warn(`(Attempt 1) Failed to parse base URL from iframe src: ${baseFrameSrc} using new URL(), error: ${e.message}`); 35 | // Attempt 2: Regex fallback for origin 36 | const originMatch = (baseFrameSrc.startsWith("//") ? "https:" + baseFrameSrc : baseFrameSrc).match(/^(https?:\/\/[^/]+)/); 37 | if (originMatch && originMatch[1]) { 38 | BASEDOM = originMatch[1]; 39 | console.log(`(Attempt 2) Successfully extracted origin using regex: ${BASEDOM}`); 40 | } else { 41 | console.error(`(Attempt 2) Failed to extract origin using regex from: ${baseFrameSrc}. Using default: ${BASEDOM}`); 42 | // Keep the default BASEDOM = "https://cloudnestra.com" if all fails 43 | } 44 | } 45 | } 46 | $(".serversList .server").each((index, element) => { 47 | const server = $(element); 48 | servers.push({ 49 | name: server.text().trim(), 50 | dataHash: server.attr("data-hash") ?? null, 51 | }); 52 | }); 53 | return { 54 | servers: servers, 55 | title: title, 56 | }; 57 | } 58 | async function parseMasterM3U8(m3u8Content, masterM3U8Url) { 59 | const lines = m3u8Content.split('\n').map(line => line.trim()); 60 | const streams = []; 61 | for (let i = 0; i < lines.length; i++) { 62 | if (lines[i].startsWith("#EXT-X-STREAM-INF:")) { 63 | const infoLine = lines[i]; 64 | let quality = "unknown"; 65 | const resolutionMatch = infoLine.match(/RESOLUTION=(\d+x\d+)/); 66 | if (resolutionMatch && resolutionMatch[1]) { 67 | quality = resolutionMatch[1]; 68 | } 69 | else { 70 | const bandwidthMatch = infoLine.match(/BANDWIDTH=(\d+)/); 71 | if (bandwidthMatch && bandwidthMatch[1]) { 72 | quality = `${Math.round(parseInt(bandwidthMatch[1]) / 1000)}kbps`; 73 | } 74 | } 75 | if (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].startsWith("#")) { 76 | const streamUrlPart = lines[i + 1]; 77 | try { 78 | const fullStreamUrl = new URL(streamUrlPart, masterM3U8Url).href; 79 | streams.push({ quality: quality, url: fullStreamUrl }); 80 | } 81 | catch (e) { 82 | console.error(`Error constructing URL for stream part: ${streamUrlPart} with base: ${masterM3U8Url}`, e); 83 | streams.push({ quality: quality, url: streamUrlPart }); // Store partial URL as a fallback 84 | } 85 | i++; 86 | } 87 | } 88 | } 89 | 90 | // Sort streams by quality (highest first) 91 | streams.sort((a, b) => { 92 | // Extract resolution height from quality (e.g., "1280x720" -> 720) 93 | const getHeight = (quality) => { 94 | const match = quality.match(/(\d+)x(\d+)/); 95 | return match ? parseInt(match[2], 10) : 0; 96 | }; 97 | 98 | const heightA = getHeight(a.quality); 99 | const heightB = getHeight(b.quality); 100 | 101 | // Higher resolution comes first 102 | return heightB - heightA; 103 | }); 104 | 105 | return streams; 106 | } 107 | async function PRORCPhandler(prorcp) { 108 | try { 109 | const prorcpUrl = `${BASEDOM}/prorcp/${prorcp}`; 110 | const prorcpFetch = await fetchWrapper(prorcpUrl, { 111 | headers: { 112 | "accept": "*/*", "accept-language": "en-US,en;q=0.9", "priority": "u=1", 113 | "sec-ch-ua": "\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"", 114 | "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"", 115 | "sec-fetch-dest": "script", "sec-fetch-mode": "no-cors", "sec-fetch-site": "same-origin", 116 | 'Sec-Fetch-Dest': 'iframe', "Referer": `${BASEDOM}/`, "Referrer-Policy": "origin", 117 | }, 118 | timeout: 10000 119 | }); 120 | if (!prorcpFetch.ok) { 121 | console.error(`Failed to fetch prorcp: ${prorcpUrl}, status: ${prorcpFetch.status}`); 122 | return null; 123 | } 124 | const prorcpResponse = await prorcpFetch.text(); 125 | const regex = /file:\s*'([^']*)'/gm; 126 | const match = regex.exec(prorcpResponse); 127 | if (match && match[1]) { 128 | const masterM3U8Url = match[1]; 129 | const m3u8FileFetch = await fetchWrapper(masterM3U8Url, { 130 | headers: { "Referer": prorcpUrl, "Accept": "*/*" }, 131 | timeout: 10000 132 | }); 133 | if (!m3u8FileFetch.ok) { 134 | console.error(`Failed to fetch master M3U8: ${masterM3U8Url}, status: ${m3u8FileFetch.status}`); 135 | return null; 136 | } 137 | const m3u8Content = await m3u8FileFetch.text(); 138 | return parseMasterM3U8(m3u8Content, masterM3U8Url); 139 | } 140 | console.warn("No master M3U8 URL found in prorcp response for:", prorcpUrl); 141 | return null; 142 | } 143 | catch (error) { 144 | console.error(`Error in PRORCPhandler for ${BASEDOM}/prorcp/${prorcp}:`, error); 145 | return null; 146 | } 147 | } 148 | async function SRCRCPhandler(srcrcpPath, refererForSrcrcp) { 149 | try { 150 | const srcrcpUrl = BASEDOM + srcrcpPath; 151 | console.log(`[VidSrc - SRCRCP] Fetching: ${srcrcpUrl} (Referer: ${refererForSrcrcp})`); 152 | const response = await fetchWrapper(srcrcpUrl, { 153 | headers: { 154 | "accept": "*/*", 155 | "accept-language": "en-US,en;q=0.9", 156 | "sec-ch-ua": "\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"", 157 | "sec-ch-ua-mobile": "?0", 158 | "sec-ch-ua-platform": "\"Windows\"", 159 | "sec-fetch-dest": "iframe", 160 | "sec-fetch-mode": "navigate", 161 | "sec-fetch-site": "same-origin", // This might need to be 'cross-site' if BASEDOM's origin differs from srcrcp link's origin 162 | "Referer": refererForSrcrcp, 163 | "Referrer-Policy": "origin", 164 | }, 165 | timeout: 10000 166 | }); 167 | 168 | if (!response.ok) { 169 | console.error(`[VidSrc - SRCRCP] Failed to fetch ${srcrcpUrl}, status: ${response.status}`); 170 | return null; 171 | } 172 | 173 | const responseText = await response.text(); 174 | console.log(`[VidSrc - SRCRCP] Response from ${srcrcpUrl} (first 500 chars): ${responseText.substring(0, 500)}`); 175 | 176 | // Attempt 1: Check for "file: '...'" like in PRORCP 177 | const fileRegex = /file:\s*'([^']*)'/gm; 178 | const fileMatch = fileRegex.exec(responseText); 179 | if (fileMatch && fileMatch[1]) { 180 | const masterM3U8Url = fileMatch[1]; 181 | console.log(`[VidSrc - SRCRCP] Found M3U8 URL (via fileMatch): ${masterM3U8Url}`); 182 | const m3u8FileFetch = await fetchWrapper(masterM3U8Url, { 183 | headers: { "Referer": srcrcpUrl, "Accept": "*/*" }, 184 | timeout: 10000 185 | }); 186 | if (!m3u8FileFetch.ok) { 187 | console.error(`[VidSrc - SRCRCP] Failed to fetch master M3U8: ${masterM3U8Url}, status: ${m3u8FileFetch.status}`); 188 | return null; 189 | } 190 | const m3u8Content = await m3u8FileFetch.text(); 191 | return parseMasterM3U8(m3u8Content, masterM3U8Url); 192 | } 193 | 194 | // Attempt 2: Check if the responseText itself is an M3U8 playlist 195 | if (responseText.trim().startsWith("#EXTM3U")) { 196 | console.log(`[VidSrc - SRCRCP] Response from ${srcrcpUrl} appears to be an M3U8 playlist directly.`); 197 | return parseMasterM3U8(responseText, srcrcpUrl); 198 | } 199 | 200 | // Attempt 3: Look for sources = [...] or sources: [...] in script tags or in JSON-like structures 201 | const $ = cheerio.load(responseText); 202 | let sourcesFound = null; 203 | $('script').each((i, script) => { 204 | const scriptContent = $(script).html(); 205 | if (scriptContent) { 206 | // Regex for various ways sources might be defined 207 | const sourcesRegexes = [ 208 | /sources\s*[:=]\s*(\[[^\]]*\{(?:\s*|.*?)file\s*:\s*['"]([^'"]+)['"](?:\s*|.*?)\}[^\]]*\])/si, // extracts the URL from sources: [{file: "URL"}] 209 | /playerInstance\.setup\s*\(\s*\{\s*sources\s*:\s*(\[[^\]]*\{(?:\s*|.*?)file\s*:\s*['"]([^'"]+)['"](?:\s*|.*?)\}[^\]]*\])/si, // for playerInstance.setup({sources: [{file: "URL"}]}) 210 | /file\s*:\s*['"]([^'"]+\.m3u8[^'"]*)['"]/i, // Direct M3U8 link in a var or object e.g. file: "URL.m3u8" 211 | /src\s*:\s*['"]([^'"]+\.m3u8[^'"]*)['"]/i, // Direct M3U8 link e.g. src: "URL.m3u8" 212 | /loadSource\(['"]([^'"]+\.m3u8[^'"]*)['"]\)/i, // For .loadSource("URL.m3u8") 213 | /new\s+Player\([^)]*\{\s*src\s*:\s*['"]([^'"]+)['"]\s*\}\s*\)/i // For new Player({src: "URL"}) 214 | ]; 215 | for (const regex of sourcesRegexes) { 216 | const sourcesMatch = scriptContent.match(regex); 217 | // For regexes that capture a JSON array-like structure in group 1 and then the URL in group 2 (first two regexes) 218 | if (regex.source.includes('file\\s*:\\s*[\'\"]([\'\"]+)[\'\"]')) { // Heuristic to identify these complex regexes 219 | if (sourcesMatch && sourcesMatch[2]) { // URL is in group 2 220 | console.log(`[VidSrc - SRCRCP] Found M3U8 URL (script complex): ${sourcesMatch[2]}`); 221 | sourcesFound = [{ quality: 'default', url: sourcesMatch[2] }]; 222 | return false; // break cheerio loop 223 | } 224 | } 225 | // For simpler regexes where URL is in group 1 226 | else if (sourcesMatch && sourcesMatch[1]) { 227 | console.log(`[VidSrc - SRCRCP] Found M3U8 URL (script simple): ${sourcesMatch[1]}`); 228 | sourcesFound = [{ quality: 'default', url: sourcesMatch[1] }]; 229 | return false; // break cheerio loop 230 | } 231 | } 232 | // Fallback: Look for any absolute .m3u8 URL within the script tag 233 | if (!sourcesFound) { 234 | const m3u8GenericMatch = scriptContent.match(/['"](https?:\/\/[^'"\s]+\.m3u8[^'"\s]*)['"]/i); 235 | if (m3u8GenericMatch && m3u8GenericMatch[1]) { 236 | console.log(`[VidSrc - SRCRCP] Found M3U8 URL (script generic fallback): ${m3u8GenericMatch[1]}`); 237 | sourcesFound = [{ quality: 'default', url: m3u8GenericMatch[1] }]; 238 | return false; // break cheerio loop 239 | } 240 | } 241 | } 242 | }); 243 | 244 | if (sourcesFound && sourcesFound.length > 0) { 245 | // Process the first valid M3U8 URL found, or return direct links 246 | const m3u8Source = sourcesFound.find(s => s.url && s.url.includes('.m3u8')); 247 | if (m3u8Source) { 248 | console.log(`[VidSrc - SRCRCP] First M3U8 source from script: ${m3u8Source.url}`); 249 | // Ensure URL is absolute 250 | const absoluteM3u8Url = m3u8Source.url.startsWith('http') ? m3u8Source.url : new URL(m3u8Source.url, srcrcpUrl).href; 251 | const m3u8FileFetch = await fetchWrapper(absoluteM3u8Url, { 252 | headers: { "Referer": srcrcpUrl, "Accept": "*/*" }, 253 | timeout: 10000 254 | }); 255 | if (!m3u8FileFetch.ok) { 256 | console.error(`[VidSrc - SRCRCP] Failed to fetch M3U8 from script source: ${absoluteM3u8Url}, status: ${m3u8FileFetch.status}`); 257 | return null; 258 | } 259 | const m3u8Content = await m3u8FileFetch.text(); 260 | return parseMasterM3U8(m3u8Content, absoluteM3u8Url); 261 | } else { 262 | // Assuming direct links if no .m3u8 found in the sources array 263 | console.log(`[VidSrc - SRCRCP] Assuming direct links from script sources:`, sourcesFound); 264 | return sourcesFound.map(s => ({ 265 | quality: s.quality || s.label || 'auto', 266 | url: s.url.startsWith('http') ? s.url : new URL(s.url, srcrcpUrl).href 267 | })); 268 | } 269 | } 270 | 271 | console.warn(`[VidSrc - SRCRCP] No stream extraction method succeeded for ${srcrcpUrl}`); 272 | return null; 273 | } catch (error) { 274 | console.error(`[VidSrc - SRCRCP] Error in SRCRCPhandler for ${srcrcpPath}:`, error); 275 | return null; 276 | } 277 | } 278 | async function rcpGrabber(html) { 279 | const regex = /src:\s*'([^']*)'/; 280 | const match = html.match(regex); 281 | if (!match || !match[1]) 282 | return null; 283 | return { metadata: { image: "" }, data: match[1] }; 284 | } 285 | function getObject(id) { 286 | const arr = id.split(':'); 287 | return { id: arr[0], season: arr[1], episode: arr[2] }; 288 | } 289 | function getUrl(id, type) { 290 | if (type === "movie") { 291 | return `${SOURCE_URL}/movie/${id}`; 292 | } 293 | else { 294 | const obj = getObject(id); 295 | return `${SOURCE_URL}/tv/${obj.id}/${obj.season}-${obj.episode}`; 296 | } 297 | } 298 | async function getStreamContent(id, type) { 299 | const url = getUrl(id, type); 300 | const embedRes = await fetchWrapper(url, { headers: { "Referer": SOURCE_URL } }); 301 | if (!embedRes.ok) { 302 | console.error(`Failed to fetch embed page ${url}: ${embedRes.status}`); 303 | return []; 304 | } 305 | const embedResp = await embedRes.text(); 306 | const { servers, title } = await serversLoad(embedResp); 307 | const apiResponse = []; 308 | 309 | // MODIFIED: Process servers in parallel 310 | const serverPromises = servers.map(async (server) => { 311 | if (!server.dataHash) return null; // Skip servers without dataHash 312 | 313 | try { 314 | const rcpUrl = `${BASEDOM}/rcp/${server.dataHash}`; 315 | const rcpRes = await fetchWrapper(rcpUrl, { 316 | headers: { 'Sec-Fetch-Dest': 'iframe', "Referer": url } 317 | }); 318 | if (!rcpRes.ok) { 319 | console.warn(`RCP fetch failed for server ${server.name}: ${rcpRes.status}`); 320 | return null; // Failed to fetch RCP 321 | } 322 | const rcpHtml = await rcpRes.text(); 323 | const rcpData = await rcpGrabber(rcpHtml); 324 | 325 | if (!rcpData || !rcpData.data) { 326 | console.warn(`Skipping server ${server.name} due to missing rcp data.`); 327 | return null; // Missing RCP data 328 | } 329 | 330 | let streamDetails = null; 331 | if (rcpData.data.startsWith("/prorcp/")) { 332 | streamDetails = await PRORCPhandler(rcpData.data.replace("/prorcp/", "")); 333 | } else if (rcpData.data.startsWith("/srcrcp/")) { 334 | if (server.name === "Superembed" || server.name === "2Embed") { 335 | console.warn(`[VidSrc] Skipping SRCRCP for known problematic server: ${server.name}`); 336 | return null; 337 | } 338 | streamDetails = await SRCRCPhandler(rcpData.data, rcpUrl); // Pass rcpUrl as referer 339 | } else { 340 | console.warn(`Unhandled rcp data type for server ${server.name}: ${rcpData.data.substring(0, 50)}`); 341 | return null; // Unhandled type 342 | } 343 | 344 | if (streamDetails && streamDetails.length > 0) { 345 | return { 346 | name: title, 347 | image: rcpData.metadata.image, 348 | mediaId: id, 349 | streams: streamDetails, 350 | referer: BASEDOM, 351 | }; 352 | } else { 353 | console.warn(`No stream details from handler for server ${server.name} (${rcpData.data})`); 354 | return null; // No stream details found by the handler 355 | } 356 | } catch (e) { 357 | console.error(`Error processing server ${server.name} (${server.dataHash}):`, e); 358 | return null; // Error during server processing 359 | } 360 | }); 361 | 362 | const results = await Promise.all(serverPromises); 363 | // Filter out null results (failed servers or no streams) and add valid results to apiResponse 364 | results.forEach(result => { 365 | if (result) { 366 | apiResponse.push(result); 367 | } 368 | }); 369 | // END MODIFICATION 370 | 371 | return apiResponse; 372 | } 373 | // --- Main execution logic (conditional) --- 374 | async function main() { 375 | const args = process.argv.slice(2); 376 | if (args.length < 2) { 377 | console.error("Usage: node vidsrcextractor.js <id> <type>"); 378 | console.error("Example (movie): node vidsrcextractor.js tt0111161 movie"); 379 | console.error("Example (series): node vidsrcextractor.js tt0944947:1:1 series"); // Game of Thrones S1E1 380 | process.exit(1); 381 | } 382 | const id = args[0]; 383 | const type = args[1]; 384 | if (type !== "movie" && type !== "series") { 385 | console.error("Invalid type. Must be 'movie' or 'series'."); 386 | process.exit(1); 387 | } 388 | // Basic validation for series ID format 389 | if (type === "series" && id.split(':').length < 3) { 390 | console.error("Invalid series ID format. Expected 'ttID:season:episode' (e.g., tt0944947:1:1)."); 391 | process.exit(1); 392 | } 393 | console.log(`Fetching streams for ID: ${id}, Type: ${type}`); 394 | try { 395 | const results = await getStreamContent(id, type); 396 | if (results && results.length > 0) { 397 | console.log("Extracted Data:"); 398 | console.log(JSON.stringify(results, null, 2)); 399 | } 400 | else { 401 | console.log("No streams found or an error occurred."); 402 | } 403 | } 404 | catch (error) { 405 | console.error("Error during extraction process:", error); 406 | } 407 | } 408 | 409 | // Export the function for use in other modules 410 | module.exports = { getStreamContent }; 411 | 412 | // Run main only if the script is executed directly 413 | if (require.main === module) { 414 | main().catch(error => { 415 | console.error("Unhandled error in main execution:", error); 416 | process.exit(1); 417 | }); 418 | } 419 | -------------------------------------------------------------------------------- /providers/Showbox.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const axios = require('axios'); 3 | 4 | // TMDB API Configuration (for convertImdbToTmdb helper) 5 | const TMDB_API_KEY = process.env.TMDB_API_KEY || '439c478a771f35c05022f9feabcca01c'; 6 | const TMDB_BASE_URL = 'https://api.themoviedb.org/3'; 7 | 8 | // API Base URL 9 | const FEBAPI_BASE_URL = 'https://febapi.nuvioapp.space/api/media'; 10 | 11 | /** 12 | * Parse quality from label string 13 | */ 14 | const parseQualityFromLabel = (label) => { 15 | if (!label) return "ORG"; 16 | 17 | const labelLower = String(label).toLowerCase(); 18 | 19 | if (labelLower.includes('1080p') || labelLower.includes('1080')) { 20 | return "1080p"; 21 | } else if (labelLower.includes('720p') || labelLower.includes('720')) { 22 | return "720p"; 23 | } else if (labelLower.includes('480p') || labelLower.includes('480')) { 24 | return "480p"; 25 | } else if (labelLower.includes('360p') || labelLower.includes('360')) { 26 | return "360p"; 27 | } else if (labelLower.includes('2160p') || labelLower.includes('2160') || 28 | labelLower.includes('4k') || labelLower.includes('uhd')) { 29 | return "2160p"; 30 | } else if (labelLower.includes('hd')) { 31 | return "720p"; // Assuming HD is 720p 32 | } else if (labelLower.includes('sd')) { 33 | return "480p"; // Assuming SD is 480p 34 | } 35 | 36 | // Use ORG (original) label for unknown quality 37 | return "ORG"; 38 | }; 39 | 40 | /** 41 | * Extract codec details from filename/text 42 | */ 43 | const extractCodecDetails = (text) => { 44 | if (!text || typeof text !== 'string') return []; 45 | const details = new Set(); 46 | const lowerText = text.toLowerCase(); 47 | 48 | // Video Codecs & Technologies 49 | if (lowerText.includes('dolby vision') || lowerText.includes('dovi') || lowerText.includes('.dv.')) details.add('DV'); 50 | if (lowerText.includes('hdr10+') || lowerText.includes('hdr10plus')) details.add('HDR10+'); 51 | else if (lowerText.includes('hdr')) details.add('HDR'); // General HDR if not HDR10+ 52 | if (lowerText.includes('sdr')) details.add('SDR'); 53 | 54 | if (lowerText.includes('av1')) details.add('AV1'); 55 | else if (lowerText.includes('h265') || lowerText.includes('x265') || lowerText.includes('hevc')) details.add('H.265'); 56 | else if (lowerText.includes('h264') || lowerText.includes('x264') || lowerText.includes('avc')) details.add('H.264'); 57 | 58 | // Audio Codecs 59 | if (lowerText.includes('atmos')) details.add('Atmos'); 60 | if (lowerText.includes('truehd') || lowerText.includes('true-hd')) details.add('TrueHD'); 61 | if (lowerText.includes('dts-hd ma') || lowerText.includes('dtshdma') || lowerText.includes('dts-hdhr')) details.add('DTS-HD MA'); 62 | else if (lowerText.includes('dts-hd')) details.add('DTS-HD'); // General DTS-HD if not MA/HR 63 | else if (lowerText.includes('dts') && !lowerText.includes('dts-hd')) details.add('DTS'); // Plain DTS 64 | 65 | if (lowerText.includes('eac3') || lowerText.includes('e-ac-3') || lowerText.includes('dd+') || lowerText.includes('ddplus')) details.add('EAC3'); 66 | else if (lowerText.includes('ac3') || (lowerText.includes('dd') && !lowerText.includes('dd+') && !lowerText.includes('ddp'))) details.add('AC3'); // Plain AC3/DD 67 | 68 | if (lowerText.includes('aac')) details.add('AAC'); 69 | if (lowerText.includes('opus')) details.add('Opus'); 70 | if (lowerText.includes('mp3')) details.add('MP3'); 71 | 72 | // Bit depth (less common but useful) 73 | if (lowerText.includes('10bit') || lowerText.includes('10-bit')) details.add('10-bit'); 74 | else if (lowerText.includes('8bit') || lowerText.includes('8-bit')) details.add('8-bit'); 75 | 76 | return Array.from(details); 77 | }; 78 | 79 | /** 80 | * Helper function to parse size string to bytes 81 | */ 82 | const parseSizeToBytes = (sizeString) => { 83 | if (!sizeString || typeof sizeString !== 'string') return Number.MAX_SAFE_INTEGER; 84 | 85 | const sizeLower = sizeString.toLowerCase(); 86 | 87 | if (sizeLower.includes('unknown') || sizeLower.includes('n/a')) { 88 | return Number.MAX_SAFE_INTEGER; // Sort unknown/NA sizes last 89 | } 90 | 91 | const units = { 92 | gb: 1024 * 1024 * 1024, 93 | mb: 1024 * 1024, 94 | kb: 1024, 95 | b: 1 96 | }; 97 | 98 | const match = sizeString.match(/([\d.]+)\s*(gb|mb|kb|b)/i); 99 | if (match && match[1] && match[2]) { 100 | const value = parseFloat(match[1]); 101 | const unit = match[2].toLowerCase(); 102 | if (!isNaN(value) && units[unit]) { 103 | return Math.floor(value * units[unit]); 104 | } 105 | } 106 | return Number.MAX_SAFE_INTEGER; // Fallback for unparsed strings 107 | }; 108 | 109 | /** 110 | * Utility function to sort streams by quality in order of resolution 111 | */ 112 | const sortStreamsByQuality = (streams) => { 113 | // Since Stremio displays streams from bottom to top, 114 | // we need to sort in reverse order to what we want to show 115 | const qualityOrder = { 116 | "ORG": 1, // ORG will show at the top (since it's at the bottom of the list) 117 | "2160p": 2, 118 | "1080p": 3, 119 | "720p": 4, 120 | "480p": 5, 121 | "360p": 6 // 360p will show at the bottom 122 | }; 123 | 124 | // Provider sort order: lower number means earlier in array (lower in Stremio UI for same quality/size) 125 | const providerSortKeys = { 126 | 'ShowBox': 1, 127 | 'Xprime.tv': 2, 128 | 'HollyMovieHD': 3, 129 | 'Soaper TV': 4, 130 | // Default for unknown providers 131 | default: 99 132 | }; 133 | 134 | return [...streams].sort((a, b) => { 135 | const qualityA = a.quality || "ORG"; 136 | const qualityB = b.quality || "ORG"; 137 | 138 | const orderA = qualityOrder[qualityA] || 10; 139 | const orderB = qualityOrder[qualityB] || 10; 140 | 141 | // First, compare by quality order 142 | if (orderA !== orderB) { 143 | return orderA - orderB; 144 | } 145 | 146 | // If qualities are the same, compare by size (descending - larger sizes first means earlier in array) 147 | const sizeAInBytes = parseSizeToBytes(a.size); 148 | const sizeBInBytes = parseSizeToBytes(b.size); 149 | 150 | if (sizeAInBytes !== sizeBInBytes) { 151 | return sizeBInBytes - sizeAInBytes; 152 | } 153 | 154 | // If quality AND size are the same, compare by provider 155 | const providerA = a.provider || 'default'; 156 | const providerB = b.provider || 'default'; 157 | 158 | const providerOrderA = providerSortKeys[providerA] || providerSortKeys.default; 159 | const providerOrderB = providerSortKeys[providerB] || providerSortKeys.default; 160 | 161 | return providerOrderA - providerOrderB; 162 | }); 163 | }; 164 | 165 | /** 166 | * Convert IMDb ID to TMDB ID 167 | * Used by addon.js for IMDb ID resolution 168 | */ 169 | const convertImdbToTmdb = async (imdbId, regionPreference = null, expectedType = null) => { 170 | console.time(`convertImdbToTmdb_total_${imdbId}`); 171 | if (!imdbId || !imdbId.startsWith('tt')) { 172 | console.log(' Invalid IMDb ID format provided for conversion.', imdbId); 173 | console.timeEnd(`convertImdbToTmdb_total_${imdbId}`); 174 | return null; 175 | } 176 | console.log(` Attempting to convert IMDb ID: ${imdbId} to TMDB ID${expectedType ? ` (expected type: ${expectedType})` : ''}.`); 177 | 178 | const findApiUrl = `${TMDB_BASE_URL}/find/${imdbId}?api_key=${TMDB_API_KEY}&external_source=imdb_id`; 179 | console.log(` Fetching from TMDB find API: ${findApiUrl}`); 180 | console.time(`convertImdbToTmdb_apiCall_${imdbId}`); 181 | 182 | try { 183 | const response = await axios.get(findApiUrl, { timeout: 10000 }); 184 | console.timeEnd(`convertImdbToTmdb_apiCall_${imdbId}`); 185 | const findResults = response.data; 186 | 187 | if (findResults) { 188 | let result = null; 189 | 190 | // Context-aware prioritization based on expected type 191 | if (expectedType === 'tv' || expectedType === 'series') { 192 | // For series requests, prioritize TV results 193 | if (findResults.tv_results && findResults.tv_results.length > 0) { 194 | result = { tmdbId: String(findResults.tv_results[0].id), tmdbType: 'tv', title: findResults.tv_results[0].name || findResults.tv_results[0].original_name }; 195 | console.log(` Prioritized TV result for series request: ${result.title}`); 196 | } else if (findResults.movie_results && findResults.movie_results.length > 0) { 197 | result = { tmdbId: String(findResults.movie_results[0].id), tmdbType: 'movie', title: findResults.movie_results[0].title || findResults.movie_results[0].original_title }; 198 | console.log(` Fallback to movie result for series request: ${result.title}`); 199 | } 200 | } else if (expectedType === 'movie') { 201 | // For movie requests, prioritize movie results 202 | if (findResults.movie_results && findResults.movie_results.length > 0) { 203 | result = { tmdbId: String(findResults.movie_results[0].id), tmdbType: 'movie', title: findResults.movie_results[0].title || findResults.movie_results[0].original_title }; 204 | console.log(` Prioritized movie result for movie request: ${result.title}`); 205 | } else if (findResults.tv_results && findResults.tv_results.length > 0) { 206 | result = { tmdbId: String(findResults.tv_results[0].id), tmdbType: 'tv', title: findResults.tv_results[0].name || findResults.tv_results[0].original_name }; 207 | console.log(` Fallback to TV result for movie request: ${result.title}`); 208 | } 209 | } else { 210 | // Default behavior: prioritize movie results, then tv results (backward compatibility) 211 | if (findResults.movie_results && findResults.movie_results.length > 0) { 212 | result = { tmdbId: String(findResults.movie_results[0].id), tmdbType: 'movie', title: findResults.movie_results[0].title || findResults.movie_results[0].original_title }; 213 | } else if (findResults.tv_results && findResults.tv_results.length > 0) { 214 | result = { tmdbId: String(findResults.tv_results[0].id), tmdbType: 'tv', title: findResults.tv_results[0].name || findResults.tv_results[0].original_name }; 215 | } 216 | } 217 | 218 | if (findResults.person_results && findResults.person_results.length > 0 && !result) { 219 | // Could handle other types if necessary, e.g. person, but for streams, movie/tv are key 220 | console.log(` IMDb ID ${imdbId} resolved to a person, not a movie or TV show on TMDB.`); 221 | } else if (!result) { 222 | console.log(` No movie or TV results found on TMDB for IMDb ID ${imdbId}. Response:`, JSON.stringify(findResults).substring(0, 200)); 223 | } 224 | 225 | if (result && result.tmdbId && result.tmdbType) { 226 | console.log(` Successfully converted IMDb ID ${imdbId} to TMDB ${result.tmdbType} ID ${result.tmdbId} (${result.title})`); 227 | console.timeEnd(`convertImdbToTmdb_total_${imdbId}`); 228 | return result; 229 | } else { 230 | console.log(` Could not convert IMDb ID ${imdbId} to a usable TMDB movie/tv ID.`); 231 | } 232 | } 233 | } catch (error) { 234 | if (console.timeEnd && typeof console.timeEnd === 'function') console.timeEnd(`convertImdbToTmdb_apiCall_${imdbId}`); // Ensure timer ends on error 235 | const errorMessage = error.response ? `${error.message} (Status: ${error.response.status})` : error.message; 236 | console.log(` Error during TMDB find API call for IMDb ID ${imdbId}: ${errorMessage}`); 237 | } 238 | console.timeEnd(`convertImdbToTmdb_total_${imdbId}`); 239 | return null; 240 | }; 241 | 242 | /** 243 | * Check quota for a cookie and return remaining MB 244 | * Returns { ok: boolean, remainingMB: number, cookie: string } 245 | */ 246 | const checkCookieQuota = async (cookie) => { 247 | try { 248 | const headers = { 249 | 'Cookie': cookie.startsWith('ui=') ? cookie : `ui=${cookie}`, 250 | 'Accept': 'application/json, text/javascript, */*; q=0.01', 251 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 252 | }; 253 | const resp = await axios.get('https://www.febbox.com/console/user_cards', { 254 | headers, 255 | timeout: 8000, 256 | validateStatus: () => true 257 | }); 258 | if (resp.status === 200 && resp.data && resp.data.data && resp.data.data.flow) { 259 | const flow = resp.data.data.flow; 260 | const remaining = (Number(flow.traffic_limit_mb) || 0) - (Number(flow.traffic_usage_mb) || 0); 261 | return { ok: true, remainingMB: remaining, cookie }; 262 | } 263 | } catch (e) { 264 | console.log(`[ShowBox] Quota check failed for cookie: ${e.message}`); 265 | } 266 | // Return ok: false but still include the cookie so we can use it as fallback 267 | return { ok: false, remainingMB: -1, cookie }; 268 | }; 269 | 270 | /** 271 | * Select the best cookie from an array of cookies 272 | * Tries to check quota for each, picks the one with highest remaining quota 273 | * Falls back to any cookie if quota checks fail 274 | * @param {string|string[]} cookies - Single cookie string or array of cookies 275 | * @returns {Promise<{cookie: string|null, remainingMB: number}>} 276 | */ 277 | const selectBestCookie = async (cookies) => { 278 | // Normalize to array 279 | let cookieArray = []; 280 | if (typeof cookies === 'string' && cookies.trim()) { 281 | cookieArray = [cookies.trim()]; 282 | } else if (Array.isArray(cookies)) { 283 | cookieArray = cookies.filter(c => c && typeof c === 'string' && c.trim()).map(c => c.trim()); 284 | } 285 | 286 | if (cookieArray.length === 0) { 287 | return { cookie: null, remainingMB: -1 }; 288 | } 289 | 290 | // If only one cookie, use it directly (optionally check quota) 291 | if (cookieArray.length === 1) { 292 | const quotaResult = await checkCookieQuota(cookieArray[0]); 293 | global.currentRequestUserCookie = quotaResult.cookie; 294 | global.currentRequestUserCookieRemainingMB = quotaResult.ok ? quotaResult.remainingMB : undefined; 295 | console.log(`[ShowBox] Using single cookie${quotaResult.ok ? ` (${quotaResult.remainingMB} MB remaining)` : ' (quota check skipped/failed)'}`); 296 | return { cookie: quotaResult.cookie, remainingMB: quotaResult.remainingMB }; 297 | } 298 | 299 | // Multiple cookies - try to check quota for all in parallel 300 | console.log(`[ShowBox] Checking quota for ${cookieArray.length} cookies...`); 301 | const quotaPromises = cookieArray.map(c => checkCookieQuota(c)); 302 | const results = await Promise.all(quotaPromises); 303 | 304 | // Separate successful quota checks from failed ones 305 | const successfulChecks = results.filter(r => r.ok); 306 | const failedChecks = results.filter(r => !r.ok); 307 | 308 | if (successfulChecks.length > 0) { 309 | // Sort by remaining quota descending and pick the best 310 | successfulChecks.sort((a, b) => b.remainingMB - a.remainingMB); 311 | const best = successfulChecks[0]; 312 | global.currentRequestUserCookie = best.cookie; 313 | global.currentRequestUserCookieRemainingMB = best.remainingMB; 314 | console.log(`[ShowBox] Selected best cookie by quota: ${best.remainingMB} MB remaining (out of ${successfulChecks.length} valid cookies)`); 315 | return { cookie: best.cookie, remainingMB: best.remainingMB }; 316 | } 317 | 318 | // All quota checks failed - use the first cookie anyway (fallback) 319 | const fallbackCookie = cookieArray[0]; 320 | global.currentRequestUserCookie = fallbackCookie; 321 | global.currentRequestUserCookieRemainingMB = undefined; 322 | console.log(`[ShowBox] All quota checks failed, using first cookie as fallback`); 323 | return { cookie: fallbackCookie, remainingMB: -1 }; 324 | }; 325 | 326 | /** 327 | * Main function to get streams from TMDB ID using the new API 328 | * @param {string} tmdbType - 'movie' or 'tv' 329 | * @param {string} tmdbId - TMDB ID 330 | * @param {number|null} seasonNum - Season number (for TV) 331 | * @param {number|null} episodeNum - Episode number (for TV) 332 | * @param {string|null} regionPreference - OSS region (e.g., 'USA7', 'IN1') 333 | * @param {string|string[]|null} cookies - Single cookie string or array of cookies 334 | * @param {string|null} userScraperApiKey - Not used in new implementation 335 | */ 336 | const getStreamsFromTmdbId = async (tmdbType, tmdbId, seasonNum = null, episodeNum = null, regionPreference = null, cookies = null, userScraperApiKey = null) => { 337 | const mainTimerLabel = `getStreamsFromTmdbId_total_${tmdbType}_${tmdbId}` + (seasonNum ? `_s${seasonNum}` : '') + (episodeNum ? `_e${episodeNum}` : ''); 338 | console.time(mainTimerLabel); 339 | console.log(`[ShowBox] Getting streams for TMDB ${tmdbType}/${tmdbId}${seasonNum !== null ? `, Season ${seasonNum}` : ''}${episodeNum !== null ? `, Episode ${episodeNum}` : ''}`); 340 | 341 | try { 342 | // Select the best cookie from available cookies (single or array) 343 | const { cookie: selectedCookie, remainingMB } = await selectBestCookie(cookies); 344 | 345 | // Build API URL 346 | let apiUrl; 347 | const oss = regionPreference || 'USA7'; // Default to USA7 if no region preference 348 | 349 | if (tmdbType === 'tv' || tmdbType === 'series') { 350 | if (seasonNum === null || episodeNum === null) { 351 | console.log(`[ShowBox] TV show requires both season and episode numbers`); 352 | console.timeEnd(mainTimerLabel); 353 | return []; 354 | } 355 | apiUrl = `${FEBAPI_BASE_URL}/tv/${tmdbId}/oss=${oss}/${seasonNum}/${episodeNum}`; 356 | } else if (tmdbType === 'movie') { 357 | apiUrl = `${FEBAPI_BASE_URL}/movie/${tmdbId}/oss=${oss}`; 358 | } else { 359 | console.log(`[ShowBox] Unsupported media type: ${tmdbType}`); 360 | console.timeEnd(mainTimerLabel); 361 | return []; 362 | } 363 | 364 | // Add cookie as query parameter if available 365 | if (selectedCookie) { 366 | apiUrl += `?cookie=${encodeURIComponent(selectedCookie)}`; 367 | } 368 | 369 | console.log(`[ShowBox] Making request to: ${apiUrl.replace(/\?cookie=.*/, '?cookie=***')}`); // Hide cookie in logs 370 | 371 | // Make API request 372 | const response = await axios.get(apiUrl, { 373 | timeout: 30000, 374 | headers: { 375 | 'User-Agent': 'NuvioStreamsAddon/1.0' 376 | } 377 | }); 378 | 379 | if (!response.data || !response.data.success) { 380 | console.log(`[ShowBox] API returned unsuccessful response`); 381 | console.timeEnd(mainTimerLabel); 382 | return []; 383 | } 384 | 385 | const apiData = response.data; 386 | const streams = []; 387 | 388 | // Process versions array 389 | if (apiData.versions && Array.isArray(apiData.versions)) { 390 | for (const version of apiData.versions) { 391 | const versionName = version.name || 'Unknown'; 392 | const versionSize = version.size || 'Unknown size'; 393 | 394 | // Process links array for each version 395 | if (version.links && Array.isArray(version.links)) { 396 | for (const link of version.links) { 397 | if (!link.url) { 398 | continue; // Skip links without URL 399 | } 400 | 401 | const streamName = link.name || 'Auto'; 402 | const streamTitle = versionName; 403 | const streamUrl = link.url; 404 | const streamQuality = parseQualityFromLabel(link.quality || link.name); 405 | const streamSize = link.size || versionSize; 406 | const streamCodecs = extractCodecDetails(versionName); 407 | 408 | streams.push({ 409 | name: streamName, 410 | title: streamTitle, 411 | url: streamUrl, 412 | quality: streamQuality, 413 | codecs: streamCodecs, 414 | size: streamSize, 415 | provider: 'ShowBox' 416 | }); 417 | } 418 | } 419 | } 420 | } 421 | 422 | console.log(`[ShowBox] Successfully parsed ${streams.length} streams from API response`); 423 | 424 | // Sort streams by quality before returning 425 | const sortedStreams = sortStreamsByQuality(streams); 426 | console.timeEnd(mainTimerLabel); 427 | return sortedStreams; 428 | 429 | } catch (error) { 430 | const errorMessage = error.response ? `${error.message} (Status: ${error.response.status})` : error.message; 431 | console.error(`[ShowBox] Error fetching streams: ${errorMessage}`); 432 | if (error.response && error.response.data) { 433 | console.error(`[ShowBox] Response data:`, JSON.stringify(error.response.data).substring(0, 500)); 434 | } 435 | console.timeEnd(mainTimerLabel); 436 | return []; 437 | } 438 | }; 439 | 440 | // Export required functions 441 | module.exports = { 442 | getStreamsFromTmdbId, 443 | parseQualityFromLabel, 444 | convertImdbToTmdb, 445 | sortStreamsByQuality 446 | }; 447 | -------------------------------------------------------------------------------- /scrapersdirect/animepahe-scraper.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const cheerio = require('cheerio'); 3 | const { VM } = require('vm2'); 4 | const FormData = require('form-data'); 5 | const pLimit = require('p-limit').default; 6 | const inquirer = require('inquirer'); 7 | 8 | // Configuration 9 | const MAIN_URL = 'https://animepahe.ru'; 10 | const PROXY_URL = 'https://animepaheproxy.phisheranimepahe.workers.dev/?url='; 11 | const HEADERS = { 12 | 'Cookie': '__ddg2_=1234567890', 13 | 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' 14 | }; 15 | 16 | // Helper function to get episode title 17 | function getEpisodeTitle(episodeData) { 18 | return episodeData.title || `Episode ${episodeData.episode}`; 19 | } 20 | 21 | // Helper function to determine anime type 22 | function getType(t) { 23 | if (t.includes('OVA') || t.includes('Special')) return 'OVA'; 24 | else if (t.includes('Movie')) return 'AnimeMovie'; 25 | else return 'Anime'; 26 | } 27 | 28 | // Search function 29 | async function search(query) { 30 | try { 31 | const url = `${PROXY_URL}${MAIN_URL}/api?m=search&l=8&q=${encodeURIComponent(query)}`; 32 | const headers = { 33 | ...HEADERS, 34 | 'referer': `${MAIN_URL}/` 35 | }; 36 | 37 | const response = await axios.get(url, { headers }); 38 | const data = response.data; 39 | 40 | if (!data || !data.data) { 41 | console.error('No search results found'); 42 | return []; 43 | } 44 | 45 | return data.data.map(item => ({ 46 | id: item.id, 47 | title: item.title, 48 | type: item.type, 49 | episodes: item.episodes, 50 | status: item.status, 51 | season: item.season, 52 | year: item.year, 53 | score: item.score, 54 | poster: item.poster, 55 | session: item.session 56 | })); 57 | } catch (error) { 58 | console.error('Error searching:', error.message); 59 | return []; 60 | } 61 | } 62 | 63 | // Get latest releases 64 | async function getLatestReleases(page = 1) { 65 | try { 66 | const url = `${PROXY_URL}${MAIN_URL}/api?m=airing&page=${page}`; 67 | const response = await axios.get(url, { headers: HEADERS }); 68 | const data = response.data; 69 | 70 | if (!data || !data.data) { 71 | console.error('No latest releases found'); 72 | return []; 73 | } 74 | 75 | return data.data.map(item => ({ 76 | animeTitle: item.anime_title, 77 | episode: item.episode, 78 | snapshot: item.snapshot, 79 | createdAt: item.created_at, 80 | animeSession: item.anime_session 81 | })); 82 | } catch (error) { 83 | console.error('Error getting latest releases:', error.message); 84 | return []; 85 | } 86 | } 87 | 88 | // Load anime details 89 | async function loadAnimeDetails(session) { 90 | try { 91 | const url = `${PROXY_URL}${MAIN_URL}/anime/${session}`; 92 | const response = await axios.get(url, { headers: HEADERS }); 93 | const $ = cheerio.load(response.data); 94 | 95 | const japTitle = $('h2.japanese').text(); 96 | const animeTitle = $('span.sr-only.unselectable').text(); 97 | const poster = $('.anime-poster a').attr('href'); 98 | const tvType = $('a[href*="/anime/type/"]').text(); 99 | 100 | const year = response.data.match(/<strong>Aired:<\/strong>[^,]*, (\d+)/)?.[1]; 101 | 102 | let status = 'Unknown'; 103 | if ($('a[href="/anime/airing"]').length > 0) status = 'Ongoing'; 104 | else if ($('a[href="/anime/completed"]').length > 0) status = 'Completed'; 105 | 106 | const synopsis = $('.anime-synopsis').text(); 107 | 108 | let anilistId = null; 109 | let malId = null; 110 | 111 | $('.external-links > a').each((i, elem) => { 112 | const href = $(elem).attr('href'); 113 | if (href.includes('anilist.co')) { 114 | const parts = href.split('/'); 115 | anilistId = parseInt(parts[parts.length - 1]); 116 | } else if (href.includes('myanimelist.net')) { 117 | const parts = href.split('/'); 118 | malId = parseInt(parts[parts.length - 1]); 119 | } 120 | }); 121 | 122 | const genres = []; 123 | $('.anime-genre > ul a').each((i, elem) => { 124 | genres.push($(elem).text()); 125 | }); 126 | 127 | return { 128 | title: animeTitle || japTitle || '', 129 | engName: animeTitle, 130 | japName: japTitle, 131 | poster: poster, 132 | type: getType(tvType), 133 | year: parseInt(year) || null, 134 | status: status, 135 | synopsis: synopsis, 136 | genres: genres, 137 | anilistId: anilistId, 138 | malId: malId, 139 | session: session 140 | }; 141 | } catch (error) { 142 | console.error('Error loading anime details:', error.message); 143 | return null; 144 | } 145 | } 146 | 147 | // Generate list of episodes with concurrent fetching 148 | async function generateListOfEpisodes(session) { 149 | try { 150 | const episodes = []; 151 | const limit = pLimit(5); // Limit to 5 concurrent requests 152 | 153 | // First, get the first page to determine total pages 154 | const firstPageUrl = `${PROXY_URL}${MAIN_URL}/api?m=release&id=${session}&sort=episode_asc&page=1`; 155 | const firstPageResponse = await axios.get(firstPageUrl, { headers: HEADERS }); 156 | const firstPageData = firstPageResponse.data; 157 | 158 | if (!firstPageData || !firstPageData.data) { 159 | console.error('No episodes found'); 160 | return []; 161 | } 162 | 163 | const { last_page: lastPage, per_page: perPage, total } = firstPageData; 164 | 165 | // If only one page, process all episodes in that page 166 | if (lastPage === 1 && perPage > total) { 167 | firstPageData.data.forEach(episodeData => { 168 | episodes.push({ 169 | episode: episodeData.episode, 170 | title: getEpisodeTitle(episodeData), 171 | snapshot: episodeData.snapshot, 172 | session: episodeData.session, 173 | createdAt: episodeData.created_at, 174 | animeSession: session 175 | }); 176 | }); 177 | } else { 178 | // Fetch multiple pages concurrently 179 | const pagePromises = []; 180 | 181 | for (let page = 1; page <= lastPage; page++) { 182 | pagePromises.push( 183 | limit(async () => { 184 | try { 185 | const pageUrl = `${PROXY_URL}${MAIN_URL}/api?m=release&id=${session}&sort=episode_asc&page=${page}`; 186 | const pageResponse = await axios.get(pageUrl, { headers: HEADERS }); 187 | const pageData = pageResponse.data; 188 | 189 | if (pageData && pageData.data) { 190 | return pageData.data.map(episodeData => ({ 191 | episode: episodeData.episode, 192 | title: getEpisodeTitle(episodeData), 193 | snapshot: episodeData.snapshot, 194 | session: episodeData.session, 195 | createdAt: episodeData.created_at, 196 | animeSession: session 197 | })); 198 | } 199 | return []; 200 | } catch (error) { 201 | console.error(`Error fetching page ${page}:`, error.message); 202 | return []; 203 | } 204 | }) 205 | ); 206 | } 207 | 208 | // Wait for all pages and flatten results 209 | const allPageResults = await Promise.all(pagePromises); 210 | allPageResults.forEach(pageEpisodes => { 211 | episodes.push(...pageEpisodes); 212 | }); 213 | } 214 | 215 | // Sort episodes by episode number 216 | episodes.sort((a, b) => a.episode - b.episode); 217 | 218 | return episodes; 219 | } catch (error) { 220 | console.error('Error generating episodes list:', error.message); 221 | return []; 222 | } 223 | } 224 | 225 | // Load video links from episode 226 | async function loadVideoLinks(animeSession, episodeSession) { 227 | try { 228 | const episodeUrl = `${PROXY_URL}${MAIN_URL}/play/${animeSession}/${episodeSession}`; 229 | const response = await axios.get(episodeUrl, { headers: HEADERS }); 230 | const $ = cheerio.load(response.data); 231 | 232 | const links = []; 233 | 234 | // Extract Pahe links from download section 235 | $('div#pickDownload > a').each((i, elem) => { 236 | const $elem = $(elem); 237 | const href = $elem.attr('href'); 238 | const dubText = $elem.find('span').text(); 239 | const type = dubText.includes('eng') ? 'DUB' : 'SUB'; 240 | 241 | const text = $elem.text(); 242 | const qualityMatch = text.match(/(.+?)\s+·\s+(\d{3,4}p)/); 243 | const source = qualityMatch?.[1] || 'Unknown'; 244 | const quality = qualityMatch?.[2]?.replace('p', '') || 'Unknown'; 245 | 246 | if (href) { 247 | links.push({ 248 | source: `Animepahe [Pahe] ${source} [${type}]`, 249 | url: href, 250 | quality: quality, 251 | type: type, 252 | extractor: 'pahe' 253 | }); 254 | } 255 | }); 256 | 257 | return links; 258 | } catch (error) { 259 | console.error('Error loading video links:', error.message); 260 | return []; 261 | } 262 | } 263 | 264 | // Pahe extractor - complex extraction with decryption 265 | async function extractPahe(url) { 266 | try { 267 | // Step 1: Get redirect location from /i endpoint 268 | const redirectResponse = await axios.get(`${url}/i`, { 269 | maxRedirects: 0, 270 | validateStatus: (status) => status >= 200 && status < 400, 271 | headers: HEADERS 272 | }); 273 | 274 | const location = redirectResponse.headers.location; 275 | if (!location) { 276 | console.error('No redirect location found'); 277 | return null; 278 | } 279 | 280 | const kwikUrl = 'https://' + location.split('https://').pop(); 281 | 282 | // Step 2: Get the Kwik page content 283 | const kwikResponse = await axios.get(kwikUrl, { 284 | headers: { 285 | ...HEADERS, 286 | 'Referer': 'https://kwik.cx/' 287 | } 288 | }); 289 | 290 | const kwikContent = kwikResponse.data; 291 | 292 | // Step 3: Extract parameters for decryption 293 | const paramsMatch = kwikContent.match(/\("(\w+)",\d+,"(\w+)",(\d+),(\d+),\d+\)/); 294 | if (!paramsMatch) { 295 | console.error('Could not find decryption parameters'); 296 | return null; 297 | } 298 | 299 | const [, fullString, key, v1, v2] = paramsMatch; 300 | 301 | // Step 4: Decrypt using the custom algorithm 302 | const decrypted = decryptPahe(fullString, key, parseInt(v1), parseInt(v2)); 303 | 304 | // Step 5: Extract URL and token from decrypted content 305 | const urlMatch = decrypted.match(/action="([^"]+)"/); 306 | const tokenMatch = decrypted.match(/value="([^"]+)"/); 307 | 308 | if (!urlMatch || !tokenMatch) { 309 | console.error('Could not extract URL or token from decrypted content'); 310 | return null; 311 | } 312 | 313 | const postUrl = urlMatch[1]; 314 | const token = tokenMatch[1]; 315 | 316 | // Step 6: Make POST request with form data to get final URL 317 | const formData = new FormData(); 318 | formData.append('_token', token); 319 | 320 | let finalResponse; 321 | let attempts = 0; 322 | const maxAttempts = 20; 323 | 324 | // Keep trying until we get a redirect 325 | while (attempts < maxAttempts) { 326 | try { 327 | finalResponse = await axios.post(postUrl, formData, { 328 | headers: { 329 | ...HEADERS, 330 | 'Referer': kwikResponse.request.res.responseUrl, 331 | 'Cookie': kwikResponse.headers['set-cookie']?.[0] || '' 332 | }, 333 | maxRedirects: 0, 334 | validateStatus: (status) => status >= 200 && status < 400 335 | }); 336 | 337 | if (finalResponse.status === 302) { 338 | break; 339 | } 340 | } catch (error) { 341 | // Continue trying 342 | } 343 | 344 | attempts++; 345 | await new Promise(resolve => setTimeout(resolve, 100)); // Small delay between attempts 346 | } 347 | 348 | if (!finalResponse || finalResponse.status !== 302) { 349 | console.error('Failed to get redirect after multiple attempts'); 350 | return null; 351 | } 352 | 353 | const finalUrl = finalResponse.headers.location; 354 | 355 | return { 356 | url: finalUrl, 357 | headers: { 358 | 'Referer': '' 359 | }, 360 | type: 'direct' 361 | }; 362 | 363 | } catch (error) { 364 | console.error('Error extracting from Pahe:', error.message); 365 | return null; 366 | } 367 | } 368 | 369 | // Pahe decryption algorithm 370 | function decryptPahe(fullString, key, v1, v2) { 371 | const keyIndexMap = {}; 372 | for (let i = 0; i < key.length; i++) { 373 | keyIndexMap[key[i]] = i; 374 | } 375 | 376 | let result = ''; 377 | let i = 0; 378 | const toFind = key[v2]; 379 | 380 | while (i < fullString.length) { 381 | const nextIndex = fullString.indexOf(toFind, i); 382 | if (nextIndex === -1) break; 383 | 384 | let decodedCharStr = ''; 385 | for (let j = i; j < nextIndex; j++) { 386 | const index = keyIndexMap[fullString[j]]; 387 | if (index !== undefined) { 388 | decodedCharStr += index; 389 | } else { 390 | decodedCharStr += '-1'; 391 | } 392 | } 393 | 394 | i = nextIndex + 1; 395 | 396 | const decodedValue = parseInt(decodedCharStr, v2) - v1; 397 | const decodedChar = String.fromCharCode(decodedValue); 398 | result += decodedChar; 399 | } 400 | 401 | return result; 402 | } 403 | 404 | // Main function to extract final video URLs 405 | async function extractFinalUrl(link) { 406 | if (link.extractor === 'pahe') { 407 | return await extractPahe(link.url); 408 | } 409 | return null; 410 | } 411 | 412 | // Helper function to verify if a URL is accessible 413 | async function verifyUrl(url, headers = {}) { 414 | try { 415 | console.log(`Verifying URL: ${url}`); 416 | const response = await axios.head(url, { 417 | headers, 418 | timeout: 5000, 419 | validateStatus: status => status < 400 420 | }); 421 | console.log(`URL verification successful! Status: ${response.status}`); 422 | return true; 423 | } catch (error) { 424 | console.error(`URL verification failed: ${error.message}`); 425 | return false; 426 | } 427 | } 428 | 429 | // Interactive main function 430 | async function main() { 431 | console.log('Welcome to the Interactive AnimePahe Scraper!\n'); 432 | 433 | try { 434 | // 1. Get search query from user 435 | const { searchQuery } = await inquirer.prompt([ 436 | { 437 | type: 'input', 438 | name: 'searchQuery', 439 | message: 'What anime would you like to search for?', 440 | validate: input => input ? true : 'Please enter an anime name.' 441 | } 442 | ]); 443 | 444 | console.log(`\nSearching for "${searchQuery}"...`); 445 | const searchResults = await search(searchQuery); 446 | 447 | if (searchResults.length === 0) { 448 | console.log('No results found. Please try another search.'); 449 | return; 450 | } 451 | 452 | // 2. Let user choose an anime from the results 453 | const { selectedAnime } = await inquirer.prompt([ 454 | { 455 | type: 'list', 456 | name: 'selectedAnime', 457 | message: 'Please select an anime:', 458 | choices: searchResults.map(anime => ({ 459 | name: `${anime.title} (${anime.year || 'N/A'}, ${anime.episodes} episodes, ${anime.status})`, 460 | value: anime 461 | })) 462 | } 463 | ]); 464 | 465 | console.log(`\nFetching details for "${selectedAnime.title}"...`); 466 | const animeDetails = await loadAnimeDetails(selectedAnime.session); 467 | if (animeDetails) { 468 | console.log(`> Type: ${animeDetails.type}`); 469 | console.log(`> Status: ${animeDetails.status}`); 470 | console.log(`> Genres: ${animeDetails.genres.join(', ')}`); 471 | console.log(`> Synopsis: ${animeDetails.synopsis.substring(0, 100)}...`); 472 | } 473 | 474 | // 3. Fetch and let user choose an episode 475 | console.log('\nFetching episode list...'); 476 | const episodes = await generateListOfEpisodes(selectedAnime.session); 477 | 478 | if (episodes.length === 0) { 479 | console.log('No episodes found for this anime.'); 480 | return; 481 | } 482 | 483 | const { selectedEpisode } = await inquirer.prompt([ 484 | { 485 | type: 'list', 486 | name: 'selectedEpisode', 487 | message: 'Please select an episode:', 488 | choices: episodes.map(ep => ({ 489 | name: `Episode ${ep.episode}: ${ep.title}`, 490 | value: ep 491 | })), 492 | pageSize: 15 493 | } 494 | ]); 495 | 496 | // 4. Fetch and let user choose a video link/quality 497 | console.log(`\nGetting video links for Episode ${selectedEpisode.episode}...`); 498 | const videoLinks = await loadVideoLinks(selectedAnime.session, selectedEpisode.session); 499 | 500 | if (videoLinks.length === 0) { 501 | console.log('No video links found for this episode.'); 502 | return; 503 | } 504 | 505 | // Filter to only show Pahe links 506 | const paheLinks = videoLinks.filter(link => link.extractor === 'pahe'); 507 | 508 | if (paheLinks.length === 0) { 509 | console.log('No Pahe links found for this episode. Please try another episode.'); 510 | return; 511 | } 512 | 513 | const { selectedLink } = await inquirer.prompt([ 514 | { 515 | type: 'list', 516 | name: 'selectedLink', 517 | message: 'Please select a video source and quality:', 518 | choices: paheLinks.map(link => ({ 519 | name: `${link.source} - ${link.quality}p`, 520 | value: link 521 | })) 522 | } 523 | ]); 524 | 525 | // 5. Extract and display the final URL 526 | console.log('\nExtracting final streaming URL...'); 527 | console.log(`Using ${selectedLink.extractor} extractor for ${selectedLink.source}...`); 528 | 529 | const finalUrl = await extractFinalUrl(selectedLink); 530 | 531 | if (finalUrl) { 532 | console.log('\n✅ Success! Final streaming URL extracted:'); 533 | console.log(`URL: ${finalUrl.url}`); 534 | console.log(`Type: ${finalUrl.type}`); 535 | console.log('Headers:', JSON.stringify(finalUrl.headers, null, 2)); 536 | 537 | // Verify if the URL is accessible 538 | console.log('\nVerifying if the URL is accessible...'); 539 | const isValid = await verifyUrl(finalUrl.url, finalUrl.headers); 540 | 541 | if (isValid) { 542 | console.log('\n🎉 URL verification successful! The streaming URL is working.'); 543 | } else { 544 | console.log('\n⚠️ URL verification failed. The streaming URL may not be accessible.'); 545 | } 546 | } else { 547 | console.log('\n❌ Failed to extract streaming URL from the selected source.'); 548 | } 549 | 550 | } catch (error) { 551 | if (error.isTtyError) { 552 | console.error('Error: This program needs to be run in an interactive terminal.'); 553 | } else { 554 | console.error('An unexpected error occurred in main:', error); 555 | } 556 | } 557 | } 558 | 559 | // Export all functions for use as a module 560 | module.exports = { 561 | search, 562 | getLatestReleases, 563 | loadAnimeDetails, 564 | generateListOfEpisodes, 565 | loadVideoLinks, 566 | extractPahe, 567 | extractFinalUrl 568 | }; 569 | 570 | // Run main function if this file is executed directly 571 | if (require.main === module) { 572 | main(); 573 | } --------------------------------------------------------------------------------