5 Commits
22 changed files with 1888 additions and 1241 deletions
+2
View File
@@ -1 +1,3 @@
/node_modules /node_modules
restart.trigger
.vscode/
+110
View File
@@ -2,6 +2,111 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [1.0.8] - 2025-10-03
### Added
- Added `processStreamsForProxy` so the aggregate and provider-specific endpoints automatically rewrite stream URLs through the internal `/m3u8-proxy`/`/ts-proxy` layer whenever `enableProxy` is turned on, stripping provider headers safely.
### Changed
- Showbox provider overhaul: filesystem-only caching (Redis removed), smarter FebBox cookie selection with region fallbacks, TMDB title/image validation that recognises romanized names, plus cached HEAD size lookups for faster listings.
- Vixsrc provider rewritten to parse the `window.masterPlaylist` payload (token + expiry), returning a single master playlist with English subtitle lookup and correct Referer headers.
- Config loader now prefers `utils/user-config.json`, dedupes TMDB keys, defaults provider enable flags to `true`, and scrubs legacy proxy env values when mirroring overrides back to `process.env`.
- 4KHDHub provider permanently drops `.zip` archive links instead of trimming extensions and improves host distribution logging.
- Provider registry dynamically enumerates available modules (removing MoviesClub/Xprime remnants) while keeping per-request cookie stats for the admin debug panel.
### Removed
- Deleted legacy `providers/moviesclub.js`, `providers/xprime.js`, and other unused proxy/auth remnants that were no longer referenced.
### Fixed
- Aggregated responses now obey the proxy flag without client changes—streams returned from `/api/streams/...` are proxy-wrapped and omit upstream header hints when `enableProxy` is active.
## [1.0.7] - 2025-09-21
### Added
- VidZee provider: definitive AES-256-CBC decoder (replaced heuristic token attempts). Decodes newly obfuscated `atob(token) => ivBase64:cipherBase64` structure using padded key `qrincywincyspider` and PKCS7.
- Tail segment acceleration: internal tail prefetch map (`tailPrefetchMap`) with TTL cleanup and configurable `tailPrefetchKB` (default 256 KB) now documented and instrumented.
### Changed
- Proxy range negotiation: clarified interaction between `progressiveOpen` and synthetic initial partial (auto-suppression when progressive active). Additional debug logging around tail cache hits and forced host overrides.
- VidZee streams now always return direct decrypted URLs (per-stream `originalToken` retained for debugging when `VIDZEE_DEBUG=1`).
- Updated README version badge to 1.0.7.
### Fixed
- Eliminated edge cases where encoded VidZee tokens were leaking through without decoding.
- Reduced VLC initial loop behavior via tail prefetch serving last-byte probes from cache faster (combined with earlier 1.0.6 range logic).
### Documentation
- README: implicit improvements carried forward (no separate section required) plus badge bump.
- CHANGELOG: recorded AES decoder details & tail prefetch instrumentation.
### Notes
- Future proxy tuning items (size meta map, dynamic progressive growth) tracked but not part of this release.
## [1.0.6] - 2025-09-20
### Removed
- MoviesClub provider (multi-server scraping complexities & Turnstile challenge; deprecated permanently)
- Xprime provider (upstream Xprime.tv offline due to security changes)
- 4khdhub provider: all `.zip` archive links are now omitted entirely (previous releases experimented with stripping the extension which produced non‑playable pseudo‑MKV links)
### Added
- Structured multi-server debug instrumentation (session summaries, per-fetch metrics, pattern counters)
- Turnstile challenge detection & bypass attempt scaffold (synthetic `/rcp_verify` token posting)
- Optional internal stream proxy (`enableProxy` flag): mounts `/m3u8-proxy`, `/ts-proxy`, `/sub-proxy` with playlist + segment + subtitle handling and segment prefetch cache.
- Proxy range management features:
- `clampOpen` (default on) – caps ambiguous open‑ended `bytes=0-` requests to a bounded initial window (`openChunkKB`, default 4096 KB)
- `progressiveOpen` (default on) – incremental expansion of the head range on successive `bytes=0-` requests instead of a single huge span
- `initChunkKB` (default 512 KB) – size of the synthetic initial 206 response when neither clamp/progressive produce a range and `noSynth` is not set
- `tailPrefetch` (default on) + `tailPrefetchKB` (default 256 KB) – asynchronous fetch & in‑memory cache of the file tail to satisfy rapid VLC tail probes
- `force200` (opt‑in) – normalizes upstream 206 responses to 200 for diagnostics
- `noSynth` (opt‑in) – disables synthetic initial partial response generation
- Tail prefetch TTL cleanup task (30 min window) and in‑memory maps for: segment cache, open range clamp, progressive growth, and tail buffers
- Host routing overrides: `pixeldrain.*` & `video-downloads.googleusercontent.com` are forced through `/ts-proxy` (extensionless or ambiguous content)
### Changed
- Centralized multi-server request headers with realistic `sec-ch-ua*` & `Sec-Fetch-*` values
- Added retry, rotating User-Agent, and cookie jar logic to multi-server fetch pipeline
- Showbox provider priority map updated after Xprime removal
- README/Docs trimmed to reflect current active providers only
- When `enableProxy` is active, stream response objects have their original `headers` field removed (proxy handles all required headers internally).
- 4khdhub provider now filters out archive endpoints instead of attempting extension normalization (prevents feeding ZIP files to players)
- Open‑ended range handling improved to reduce VLC negotiation loops by throttling first‑pass read size and growing progressively
- Synthetic initial partial response is automatically suppressed when `progressiveOpen` is active (real range growth preferred)
### Fixed
- Ensured multi-server fallback attempts (direct rcp player/m3u8 extraction) operate with improved diagnostics
- Eliminated repeated VLC tail probe stalls caused by archive masquerading as video content (root cause was filtered by dropping `.zip` URLs)
### Documentation
- Updated README version badge to 1.0.6 and provider list (removed MoviesClub & Xprime, clarified active providers list)
- Added proxy tuning parameter reference (clamp/progressive/tail prefetch, synthetic partial, force200) and host override notes
- Expanded explanation that per‑stream headers are stripped when proxying is enabled
## [1.0.5] - 2025-09-19
### Improved
- 4khdhub provider: Permanently block `r2.dev` FSL links (previous optional flag removed).
- Added Referer/Origin headers automatically for FSL Server links during validation (prior to block enforcement ensured proper behavior).
- Tightened URL validation: removed unconditional trust for `r2.dev`; validation logic now consistent across hosts.
- Host distribution instrumentation logs final hostname counts for easier diagnostics.
- Preserved HubCloud worker `.zip` links by stripping the `.zip` extension instead of discarding them (enables direct playback attempts).
### Notes
- `r2.dev` links are always removed from final output; no env flag required.
### Documentation
## [1.0.4] - 2025-09-18
### Added
### Changed
```json
{ "title": "…", "url": "…", "quality": "…", "provider": "…", "headers": { } }
```
## [1.0.3] - 2025-09-17 ## [1.0.3] - 2025-09-17
### Fixed ### Fixed
@@ -45,7 +150,12 @@ All notable changes to this project will be documented in this file.
## [1.0.0] - 2025-09-16 ## [1.0.0] - 2025-09-16
- Initial stable release. - Initial stable release.
[1.0.8]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.7...v1.0.8
[1.0.7]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.6...v1.0.7
[1.0.6]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.5...v1.0.6
[1.0.5]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.4...v1.0.5
[1.0.3]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.2...v1.0.3 [1.0.3]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.2...v1.0.3
[1.0.4]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.3...v1.0.4
[1.0.2]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.1...v1.0.2 [1.0.2]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.0...v1.0.1 [1.0.1]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.0...v1.0.1
+99 -26
View File
@@ -6,7 +6,7 @@
<img src="https://img.shields.io/badge/Node.js-18%2B-brightgreen?style=flat" /> <img src="https://img.shields.io/badge/Node.js-18%2B-brightgreen?style=flat" />
<img src="https://img.shields.io/badge/Status-Active-success?style=flat" /> <img src="https://img.shields.io/badge/Status-Active-success?style=flat" />
<img src="https://img.shields.io/badge/License-MIT-blue?style=flat" /> <img src="https://img.shields.io/badge/License-MIT-blue?style=flat" />
<img src="https://img.shields.io/badge/Version-1.0.3-informational?style=flat" /> <img src="https://img.shields.io/badge/Version-1.0.8-informational?style=flat" />
<img src="https://img.shields.io/docker/pulls/inside4ndroid/tmdb-embed-api?label=Docker%20Pulls&style=flat" /> <img src="https://img.shields.io/docker/pulls/inside4ndroid/tmdb-embed-api?label=Docker%20Pulls&style=flat" />
</p> </p>
@@ -34,14 +34,32 @@
## ✨ Features ## ✨ Features
- **Multi‑TMDB Key Rotation** – Supply multiple API keys; one is chosen randomly per request. - **Multi‑TMDB Key Rotation** – Supply multiple API keys; one is chosen randomly per request.
- **Provider Aggregation** – Pluggable providers (Showbox, 4khdhub, MoviesMod, MP4Hydra, VidZee, Vixsrc, Xprime) with per‑provider enable toggles + default selection. - **Provider Aggregation** – Pluggable providers (Showbox, 4khdhub, MoviesMod, MP4Hydra, VidZee, Vixsrc, UHDMovies) with per‑provider enable toggles + default selection.
- **🔥 Plugin System** – Drop new provider files in `providers/` and add its exported function to the registry map (`providers/registry.js` → `providerFunctionMap`).
- **Dynamic Filtering** – Minimum quality presets, custom JSON quality map, codec exclusion rules (presets + JSON). - **Dynamic Filtering** – Minimum quality presets, custom JSON quality map, codec exclusion rules (presets + JSON).
- **Runtime Overrides UI** – Fully interactive web admin at `/` (login protected) writing to `utils/user-config.json`. - **Runtime Overrides UI** – Fully interactive web admin at `/` (login protected) writing to `utils/user-config.json`.
- **Session Auth + Rate Limiting** – Login system with brute‑force lockouts, logout, password change. - **Session Auth + Rate Limiting** – Login system with brute‑force lockouts, logout, password change.
- **Status & Health Panel** – Live metrics, provider status, endpoint list, functional provider checks. - **Status & Health Panel** – Live metrics, provider status, endpoint list, functional provider checks.
- **Config Propagation** – Overrides mirrored to `process.env` for legacy compatibility (no `.env` required after first save). - **Config Propagation** – Overrides mirrored to `process.env` for legacy compatibility (no `.env` required after first save).
- **Back‑Navigation Safe** – Cache-control + visibility/session revalidation. - **Back‑Navigation Safe** – Cache-control + visibility/session revalidation.
- **Extensible** – Simple provider registry pattern. - **Extensible** – Simple drop-in provider plugin system.
- **Optional Stream Proxy Layer** – When enabled, rewrites returned stream URLs so HLS playlists, TS segments, and subtitles are served through internal endpoints (`/m3u8-proxy`, `/ts-proxy`, `/sub-proxy`) allowing uniform headers, origin shielding, and optional segment caching.
When active the API omits per-stream `headers` objects from responses (they're no longer needed by clients) to avoid leaking upstream header requirements.
Proxy Tuning Parameters (query flags accepted by `/ts-proxy` – defaults shown):
- `clampOpen` (on) – If a client sends an ambiguous `Range: bytes=0-`, constrain it to an initial window of `openChunkKB` (default 4096 KB) to avoid huge first reads.
- `openChunkKB=4096` – Size (KB) used for both clamp window and each progressive expansion increment.
- `progressiveOpen` (on) – Grow successive ambiguous head requests (`bytes=0-`) incrementally instead of one large span. Maintains a per‑URL expansion map.
- `initChunkKB=512` – Size used for a synthetic initial partial (206) when no client range is provided and progressive growth is disabled. Capped 64–2048 KB.
- `noSynth=1` – Disable synthetic initial partial generation (forces pass‑through behavior).
- `force200=1` – Normalize upstream 206 responses to 200 (diagnostics / edge player testing).
- `tailPrefetch` (on) – Enable asynchronous tail fetch of the file’s last bytes to satisfy rapid player tail probes.
- `tailPrefetchKB=256` – Tail window size (64–2048 KB). Cached in memory with TTL cleanup.
Behavior Notes:
- Synthetic partials auto‑disable when `progressiveOpen` is active (real progressive ranges preferred).
- Player tail probes (e.g., VLC metadata scans) are accelerated by the cached tail window.
- Forced 200 mode strips `Content-Range` to emulate full responses for troubleshooting.
- Host Overrides: `pixeldrain.*` and `video-downloads.googleusercontent.com` URLs are routed through `/ts-proxy` regardless of extension to ensure correct range + MIME handling.
--- ---
@@ -79,6 +97,11 @@ docker run --name tmdb-embed-api -p 8787:8787 \
inside4ndroid/tmdb-embed-api:latest inside4ndroid/tmdb-embed-api:latest
``` ```
Or the minimal quick-test run:
```bash
docker run -it -p 8787:8787 inside4ndroid/tmdb-embed-api:latest
```
Persist overrides (Windows PowerShell example) by mounting a local file: Persist overrides (Windows PowerShell example) by mounting a local file:
```powershell ```powershell
New-Item -ItemType File -Path .\utils\user-config.json -Force | Out-Null New-Item -ItemType File -Path .\utils\user-config.json -Force | Out-Null
@@ -202,22 +225,61 @@ Session is revalidated on visibility and back/forward navigation to prevent stal
--- ---
## 🔌 Providers ## 🔌 Providers
Current built‑in providers: The API supports a plugin system. Drop a new provider file in the `providers/` folder and register its exported function in `providers/registry.js` under `providerFunctionMap`.
- `showbox`
- `4khdhub`
- `moviesmod`
- `mp4hydra`
- `vidzee`
- `vixsrc`
- `xprime`
You can enable/disable and mark defaults via the Provider Matrix in the UI. If **no defaults** are selected, all enabled providers participate in aggregation. ### Current Built-in Providers
- `showbox` - Showbox/PStream integration
- `4khdhub` - 4KHDHub streams
- `moviesmod` - MoviesMod streams
- `mp4hydra` - MP4Hydra streams
- `vidzee` - VidZee streams
- `vixsrc` - Vixsrc streams
- `uhdmovies` - UHD Movies streams
### Adding a New Provider (Outline)
1. Create `providers/myprovider.js` exporting an async fetch function (see existing files for patterns). ### Adding a New Provider
2. Lazy‑load it in `providers/registry.js` similar to others. 1. **Create** `providers/yourprovider.js` with your stream fetching logic
3. Add a config flag (`enableMyproviderProvider`) if you want toggle support. 2. **Export** a function like `getYourproviderStreams(tmdbId, mediaType, season, episode)`
4. Update the provider matrix arrays in `public/index.js`. 3. **Register** it in `providers/registry.js` → `providerFunctionMap`:
```js
// providers/registry.js
const providerFunctionMap = {
'Showbox.js': 'getStreamsFromTmdbId',
'4khdhub.js': 'get4KHDHubStreams',
'moviesmod.js': 'getMoviesModStreams',
'MP4Hydra.js': 'getMP4HydraStreams',
'VidZee.js': 'getVidZeeStreams',
'vixsrc.js': 'getVixsrcStreams',
'uhdmovies.js': 'getUHDMoviesStreams',
'yourprovider.js': 'getYourproviderStreams'
};
```
4. The provider will appear in the admin UI with an enable/disable toggle.
**Example Provider (Unified Output):**
```javascript
async function getYourproviderStreams(tmdbId, mediaType, season, episode) {
// Your scraping/API logic here
return [{
title: "Fight Club - 1080p [YourProvider #1]",
url: "https://stream.url/video.mp4",
quality: "1080p",
provider: "yourprovider",
headers: { "User-Agent": "Mozilla/5.0" }
}];
}
module.exports = { getYourproviderStreams };
```
> **⚠️ Important**: All providers must return streams in the unified JSON format to ensure compatibility with filtering and aggregation.
The system automatically:
- ✅ Detects new provider files
- ✅ Adds enable/disable toggles in the admin UI
- ✅ Includes them in stream aggregation
- ✅ Applies filtering and quality controls
- ✅ No core file edits required!
--- ---
@@ -238,23 +300,34 @@ Aggregate endpoint auto-resolves IMDb when needed and merges provider output bef
--- ---
## 🧪 Stream Object Shape (Typical) ## 🧪 Stream Object Schema (Unified)
```json ```json
{ {
"title": "Example Title", "title": "Fight Club - 1080p [MP4Hydra #2]",
"url": "https://...", "url": "https://stream.url/video.mp4",
"quality": "1080p", "quality": "1080p",
"provider": "showbox", "provider": "mp4hydra",
"size": null, "headers": { "User-Agent": "Mozilla/5.0" }
"languages": ["en"],
"subtitles": [],
"codecs": ["H264"],
"headers": { "Referer": "..." }
} }
``` ```
Filtering passes through `applyFilters` to enforce min quality + codec exclusions. Filtering passes through `applyFilters` to enforce min quality + codec exclusions.
> Note: When the `enableProxy` flag is turned on, provider-specific request headers are stripped from each stream object before responding. Clients should use the proxied URL directly without adding custom Referer/Origin headers.
---
## ⚙️ Configuration Flags (Advanced Panel)
| Flag | Default | Purpose |
|------|---------|---------|
| disableCache | false | Disables internal caches (Showbox + proxy segment cache) |
| enablePStreamApi | true | Enables Showbox PStream-specific handling |
| disableUrlValidation | false | Skip general URL pattern validation checks |
| disable4khdhubUrlValidation | false | Skip 4khdhub-specific URL validation |
| enableProxy | false | Mounts proxy routes and rewrites stream URLs through them |
Toggle `enableProxy` to activate the internal proxy. This adds lightweight playlist/segment/subtitle rewriting without modifying provider code. Disable it to return direct upstream URLs.
--- ---
## 🧩 Quality & Codec Filtering ## 🧩 Quality & Codec Filtering
+21 -1
View File
@@ -3,14 +3,23 @@ const express = require('express');
const cors = require('cors'); const cors = require('cors');
const os = require('os'); const os = require('os');
const { config, saveConfigPatch, OVERRIDE_PATH } = require('./utils/config'); const { config, saveConfigPatch, OVERRIDE_PATH } = require('./utils/config');
const { authenticate, issueSession, requireAuth, getSession, updatePassword, AUTH_FILE } = require('./utils/auth'); const { authenticate, issueSession, requireAuth, getSession, updatePassword } = require('./utils/auth');
const path = require('path'); const path = require('path');
const { listProviders, getProvider, getCookieStats } = require('./providers/registry'); const { listProviders, getProvider, getCookieStats } = require('./providers/registry');
const { createProxyRoutes, processStreamsForProxy } = require('./proxy/proxyServer');
const { resolveImdbId } = require('./utils/tmdb'); const { resolveImdbId } = require('./utils/tmdb');
const { applyFilters } = require('./utils/streamFilters'); const { applyFilters } = require('./utils/streamFilters');
const app = express(); const app = express();
// Conditionally mount proxy routes early so downstream handlers can use them
if (config.enableProxy) {
console.log('[startup] enableProxy flag active: mounting proxy routes');
createProxyRoutes(app);
} else {
console.log('[startup] enableProxy flag disabled: proxy routes not mounted');
}
// --- Simple In-Memory Rate Limiting for /auth/login --- // --- Simple In-Memory Rate Limiting for /auth/login ---
const loginAttempts = new Map(); // key: ip, value: { count, first, last, lockedUntil } const loginAttempts = new Map(); // key: ip, value: { count, first, last, lockedUntil }
const MAX_ATTEMPTS_WINDOW = 5; // attempts allowed const MAX_ATTEMPTS_WINDOW = 5; // attempts allowed
@@ -340,6 +349,12 @@ app.get('/api/streams/:type/:tmdbId', async (req,res) => {
let streams = results.flat(); let streams = results.flat();
streams = applyFilters(streams, 'aggregate', config.minQualities, config.excludeCodecs); streams = applyFilters(streams, 'aggregate', config.minQualities, config.excludeCodecs);
metrics.streamsReturned += streams.length; metrics.streamsReturned += streams.length;
if (config.enableProxy) {
const serverUrl = `${req.protocol}://${req.get('host')}`;
streams = processStreamsForProxy(streams, serverUrl);
// Omit original headers when proxying to avoid leaking upstream requirements
streams = streams.map(s => { if (s && typeof s === 'object') { const { headers, ...rest } = s; return rest; } return s; });
}
res.json({ success:true, tmdbId, imdbId, count: streams.length, providerTimings, streams }); res.json({ success:true, tmdbId, imdbId, count: streams.length, providerTimings, streams });
} catch (e) { } catch (e) {
metrics.lastError = e.message; metrics.lastError = e.message;
@@ -366,6 +381,11 @@ app.get('/api/streams/:provider/:type/:tmdbId', async (req,res) => {
const providerTimings = { [prov.name]: Date.now()-t0 }; const providerTimings = { [prov.name]: Date.now()-t0 };
streams = applyFilters(streams, prov.name, config.minQualities, config.excludeCodecs); streams = applyFilters(streams, prov.name, config.minQualities, config.excludeCodecs);
metrics.streamsReturned += streams.length; metrics.streamsReturned += streams.length;
if (config.enableProxy) {
const serverUrl = `${req.protocol}://${req.get('host')}`;
streams = processStreamsForProxy(streams, serverUrl);
streams = streams.map(s => { if (s && typeof s === 'object') { const { headers, ...rest } = s; return rest; } return s; });
}
res.json({ success:true, provider: prov.name, tmdbId, imdbId, count: streams.length, providerTimings, streams }); res.json({ success:true, provider: prov.name, tmdbId, imdbId, count: streams.length, providerTimings, streams });
} catch (e) { } catch (e) {
metrics.lastError = e.message; metrics.lastError = e.message;
+13 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "tmdb-embed-api", "name": "tmdb-embed-api",
"version": "1.0.0", "version": "1.0.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tmdb-embed-api", "name": "tmdb-embed-api",
"version": "1.0.0", "version": "1.0.8",
"dependencies": { "dependencies": {
"aromanize": "^0.1.5", "aromanize": "^0.1.5",
"axios": "^1.9.0", "axios": "^1.9.0",
@@ -674,6 +674,17 @@
} }
} }
}, },
"node_modules/axios-cookiejar-support/node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tmdb-embed-api", "name": "tmdb-embed-api",
"version": "1.0.3", "version": "1.0.8",
"description": "Standalone streaming embed source aggregation API (TMDB ID in, streams out)", "description": "Standalone streaming embed source aggregation API (TMDB ID in, streams out)",
"main": "apiServer.js", "main": "apiServer.js",
"scripts": { "scripts": {
+133 -34
View File
@@ -9,6 +9,9 @@ const path = require('path');
const DOMAINS_URL = 'https://raw.githubusercontent.com/phisher98/TVVVV/refs/heads/main/domains.json'; const DOMAINS_URL = 'https://raw.githubusercontent.com/phisher98/TVVVV/refs/heads/main/domains.json';
let cachedDomains = null; let cachedDomains = null;
// Permanent policy: block r2.dev links (previously optional via env flag)
console.log('[4KHDHub] r2.dev links will be removed (permanent policy)');
// --- Caching Configuration --- // --- Caching Configuration ---
const CACHE_ENABLED = process.env.DISABLE_CACHE !== 'true'; const CACHE_ENABLED = process.env.DISABLE_CACHE !== 'true';
console.log(`[4KHDHub] Internal cache is ${CACHE_ENABLED ? 'enabled' : 'disabled'}.`); console.log(`[4KHDHub] Internal cache is ${CACHE_ENABLED ? 'enabled' : 'disabled'}.`);
@@ -61,8 +64,7 @@ function validateUrl(url) {
// Skip validation for known reliable hosting services // Skip validation for known reliable hosting services
const trustedHosts = [ const trustedHosts = [
'pixeldrain.dev', 'pixeldrain.dev'
'r2.dev'
]; ];
const isTrustedHost = trustedHosts.some(host => urlObj.hostname.includes(host)); const isTrustedHost = trustedHosts.some(host => urlObj.hostname.includes(host));
@@ -74,11 +76,21 @@ function validateUrl(url) {
const protocol = urlObj.protocol === 'https:' ? https : http; const protocol = urlObj.protocol === 'https:' ? https : http;
// Add referer/origin for r2.dev validation attempts
const extraHeaders = {};
if (/r2\.dev$/i.test(urlObj.hostname)) {
const origin = `${urlObj.protocol}//${urlObj.hostname}`;
extraHeaders['Referer'] = origin + '/';
extraHeaders['Origin'] = origin;
console.log(`[4KHDHub] Adding Referer/Origin headers for validation of r2.dev URL: ${url}`);
}
const options = { const options = {
method: 'HEAD', method: 'HEAD',
timeout: 15000, timeout: 15000,
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...extraHeaders
} }
}; };
@@ -397,8 +409,10 @@ function generateAlternativeQueries(title, originalTitle = null) {
return romans[num] || match; return romans[num] || match;
}), }),
title.replace(/\b(I{1,3}|IV|V|VI{0,3}|IX|X)\b/g, match => { title.replace(/\b(I{1,3}|IV|V|VI{0,3}|IX|X)\b/g, match => {
const romans = { 'I': '1', 'II': '2', 'III': '3', 'IV': '4', 'V': '5', const romans = {
'VI': '6', 'VII': '7', 'VIII': '8', 'IX': '9', 'X': '10' }; 'I': '1', 'II': '2', 'III': '3', 'IV': '4', 'V': '5',
'VI': '6', 'VII': '7', 'VIII': '8', 'IX': '9', 'X': '10'
};
return romans[match] || match; return romans[match] || match;
}) })
]; ];
@@ -749,12 +763,24 @@ function extractHubCloudLinks(url, referer) {
if (displayFilename) titleParts.push(displayFilename); if (displayFilename) titleParts.push(displayFilename);
if (size) titleParts.push(size); if (size) titleParts.push(size);
const finalTitle = titleParts.join('\n'); const finalTitle = titleParts.join('\n');
let headers = {};
try {
const host = new URL(link).hostname;
if (/r2\.dev$/i.test(host)) {
headers = { Referer: baseUrl + '/', Origin: baseUrl };
console.log('[4KHDHub] Added Referer/Origin headers for r2.dev FSL link');
}
} catch {
// Ignore URL parsing errors
}
resolve({ resolve({
name: `4KHDHub - FSL Server${qualityLabel}`, name: `4KHDHub - FSL Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers
}); });
}) })
.catch(() => { .catch(() => {
@@ -763,12 +789,24 @@ function extractHubCloudLinks(url, referer) {
if (displayFilename) titleParts.push(displayFilename); if (displayFilename) titleParts.push(displayFilename);
if (size) titleParts.push(size); if (size) titleParts.push(size);
const finalTitle = titleParts.join('\n'); const finalTitle = titleParts.join('\n');
let headers = {};
try {
const host = new URL(link).hostname;
if (/r2\.dev$/i.test(host)) {
headers = { Referer: baseUrl + '/', Origin: baseUrl };
console.log('[4KHDHub] Added Referer/Origin headers for r2.dev FSL link (fallback)');
}
} catch {
// Ignore URL parsing errors
}
resolve({ resolve({
name: `4KHDHub - FSL Server${qualityLabel}`, name: `4KHDHub - FSL Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers
}); });
}); });
} else if (text.includes('Download File')) { } else if (text.includes('Download File')) {
@@ -786,7 +824,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - HubCloud${qualityLabel}`, name: `4KHDHub - HubCloud${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -800,7 +840,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - HubCloud${qualityLabel}`, name: `4KHDHub - HubCloud${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} else if (text.includes('BuzzServer')) { } else if (text.includes('BuzzServer')) {
@@ -829,7 +871,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - BuzzServer${qualityLabel}`, name: `4KHDHub - BuzzServer${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: finalUrl, url: finalUrl,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -843,7 +887,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - BuzzServer${qualityLabel}`, name: `4KHDHub - BuzzServer${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: finalUrl, url: finalUrl,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} else { } else {
@@ -880,7 +926,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - Pixeldrain${qualityLabel}`, name: `4KHDHub - Pixeldrain${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: convertedLink, url: convertedLink,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -894,7 +942,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - Pixeldrain${qualityLabel}`, name: `4KHDHub - Pixeldrain${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: convertedLink, url: convertedLink,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} else if (text.includes('S3 Server')) { } else if (text.includes('S3 Server')) {
@@ -912,7 +962,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - S3 Server${qualityLabel}`, name: `4KHDHub - S3 Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -926,7 +978,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - S3 Server${qualityLabel}`, name: `4KHDHub - S3 Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} else if (text.includes('10Gbps')) { } else if (text.includes('10Gbps')) {
@@ -966,7 +1020,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - 10Gbps Server${qualityLabel}`, name: `4KHDHub - 10Gbps Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: decodedUrl, url: decodedUrl,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}; };
}) })
.catch(() => { .catch(() => {
@@ -980,7 +1036,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - 10Gbps Server${qualityLabel}`, name: `4KHDHub - 10Gbps Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: decodedUrl, url: decodedUrl,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}; };
}); });
} }
@@ -1016,7 +1074,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - HubCloud${qualityLabel}`, name: `4KHDHub - HubCloud${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -1030,7 +1090,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - HubCloud${qualityLabel}`, name: `4KHDHub - HubCloud${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} }
@@ -1274,14 +1336,18 @@ function extractStreamingLinks(downloadLinks) {
return Promise.all(promises) return Promise.all(promises)
.then(results => { .then(results => {
const validResults = results.filter(result => result !== null); const validResults = results.filter(r => r !== null).flat();
const flatResults = validResults.flat(); const filtered = validResults.filter(link => {
// Filter out .zip files if (!link || !link.url) return false;
const filteredResults = flatResults.filter(link => { const lower = link.url.toLowerCase();
return link && link.url && !link.url.toLowerCase().endsWith('.zip'); if (lower.endsWith('.zip')) {
console.log(`[4KHDHub] Skipping archive (zip) link: ${link.url}`);
return false;
}
return true;
}); });
// Note: Link count will be logged after validation completes console.log(`[4KHDHub] Post-processing: kept ${filtered.length}, skipped ${validResults.length - filtered.length} zip archive links`);
return filteredResults; return filtered;
}); });
} }
@@ -1388,7 +1454,9 @@ function processHubDriveLink(href, referer, filename = 'Unknown', size = '', qua
name: `4KHDHub - HubDrive${qualityLabel}`, name: `4KHDHub - HubDrive${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: href, url: href,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}]; }];
}) })
.catch(() => { .catch(() => {
@@ -1402,7 +1470,9 @@ function processHubDriveLink(href, referer, filename = 'Unknown', size = '', qua
name: `4KHDHub - HubDrive${qualityLabel}`, name: `4KHDHub - HubDrive${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: href, url: href,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}]; }];
}); });
} }
@@ -1463,7 +1533,9 @@ function processExtractorLink(link, resolve, linkNumber) {
name: '4KHDHub Direct Link', name: '4KHDHub Direct Link',
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: 1080 quality: 1080,
provider: "4khdhub",
headers: {}
}]); }]);
}) })
.catch(() => { .catch(() => {
@@ -1477,7 +1549,9 @@ function processExtractorLink(link, resolve, linkNumber) {
name: '4KHDHub Direct Link', name: '4KHDHub Direct Link',
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: 1080 quality: 1080,
provider: "4khdhub",
headers: {}
}]); }]);
}); });
} else { } else {
@@ -1740,16 +1814,41 @@ async function get4KHDHubStreams(tmdbId, type, season = null, episode = null) {
} }
// Normalize to generic stream object format // Normalize to generic stream object format
const streams = validatedLinks.map(link => ({ let streams = validatedLinks.map(link => ({
name: link.name, // Don't add prefix since it's already included name: link.name, // Don't add prefix since it's already included
title: link.title || link.name, title: link.title || link.name,
url: link.url, url: link.url,
quality: `${link.quality}p`, quality: `${link.quality}p`,
behaviorHints: { provider: "4khdhub",
bingeGroup: '4khdhub-streams' headers: {}
}
})); }));
// Always remove r2.dev links
{
const before = streams.length;
streams = streams.filter(s => {
try { return !new URL(s.url).hostname.endsWith('r2.dev'); } catch { return true; }
});
const removed = before - streams.length;
if (removed > 0) console.log(`[4KHDHub] Removed ${removed} r2.dev streams (permanent policy)`);
}
// Host distribution instrumentation
try {
const hostCounts = streams.reduce((acc, s) => {
try {
const h = new URL(s.url).hostname;
acc[h] = (acc[h] || 0) + 1;
} catch {
// Ignore URL parsing errors
}
return acc;
}, {});
console.log('[4KHDHub] Final host distribution:', hostCounts);
} catch {
// Ignore URL parsing errors
}
console.log(`[4KHDHub] Returning ${streams.length} streams`); console.log(`[4KHDHub] Returning ${streams.length} streams`);
return streams; return streams;
+11 -29
View File
@@ -126,14 +126,11 @@ async function getMP4HydraStreams(tmdbId, mediaType, seasonNum = null, episodeNu
title: `${details.title} - ${seasonEpisode} - ${processedEpisode.quality} [MP4Hydra ${serverNumber}]`, title: `${details.title} - ${seasonEpisode} - ${processedEpisode.quality} [MP4Hydra ${serverNumber}]`,
url: processedEpisode.videoUrl, url: processedEpisode.videoUrl,
quality: processedEpisode.quality, quality: processedEpisode.quality,
provider: "MP4Hydra", provider: "mp4hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: { headers: {
'Referer': 'https://mp4hydra.org/' 'Referer': 'https://mp4hydra.org/'
} }
}
}); });
// Add subtitle tracks if available // Add subtitle tracks if available
@@ -172,14 +169,11 @@ async function getMP4HydraStreams(tmdbId, mediaType, seasonNum = null, episodeNu
title: `${details.title} - ${processedItem.quality} [MP4Hydra ${serverNumber}]`, title: `${details.title} - ${processedItem.quality} [MP4Hydra ${serverNumber}]`,
url: processedItem.videoUrl, url: processedItem.videoUrl,
quality: processedItem.quality, quality: processedItem.quality,
provider: "MP4Hydra", provider: "mp4hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: { headers: {
'Referer': 'https://mp4hydra.org/' 'Referer': 'https://mp4hydra.org/'
} }
}
}); });
// Add subtitle tracks if available // Add subtitle tracks if available
@@ -297,14 +291,11 @@ async function tryAlternativeTitle(details, mediaType, seasonNum, episodeNum) {
title: `${details.original_title} - ${seasonEpisode} - ${processedEpisode.quality} [MP4Hydra ${serverNumber}]`, title: `${details.original_title} - ${seasonEpisode} - ${processedEpisode.quality} [MP4Hydra ${serverNumber}]`,
url: processedEpisode.videoUrl, url: processedEpisode.videoUrl,
quality: processedEpisode.quality, quality: processedEpisode.quality,
provider: "MP4Hydra", provider: "mp4hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: { headers: {
'Referer': 'https://mp4hydra.org/' 'Referer': 'https://mp4hydra.org/'
} }
}
}); });
// Add subtitle tracks if available // Add subtitle tracks if available
@@ -340,17 +331,10 @@ async function tryAlternativeTitle(details, mediaType, seasonNum, episodeNum) {
// Convert to standard stream format // Convert to standard stream format
streams.push({ streams.push({
name: `${details.original_title}`,
title: `${details.original_title} - ${processedItem.quality} [MP4Hydra ${serverNumber}]`, title: `${details.original_title} - ${processedItem.quality} [MP4Hydra ${serverNumber}]`,
url: processedItem.videoUrl, url: processedItem.videoUrl,
quality: processedItem.quality, quality: processedItem.quality
provider: "MP4Hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: {
'Referer': 'https://mp4hydra.org/'
}
}
}); });
// Add subtitle tracks if available // Add subtitle tracks if available
@@ -435,17 +419,15 @@ async function tryWithoutYear(details, mediaType, seasonNum, episodeNum) {
// Convert to standard stream format // Convert to standard stream format
streams.push({ streams.push({
name: `${details.title}`,
title: `${details.title} - ${processedItem.quality} [MP4Hydra ${serverNumber}]`, title: `${details.title} - ${processedItem.quality} [MP4Hydra ${serverNumber}]`,
url: processedItem.videoUrl, url: processedItem.videoUrl,
quality: processedItem.quality, quality: processedItem.quality,
provider: "MP4Hydra", provider: "mp4hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: { headers: {
'Referer': 'https://mp4hydra.org/' 'Referer': 'https://mp4hydra.org/'
} }
}
}); });
// Add subtitle tracks if available // Add subtitle tracks if available
+26 -23
View File
@@ -1980,10 +1980,12 @@ const getStreamsFromTmdbId = async (tmdbType, tmdbId, seasonNum = null, episodeN
} }
} catch (e) { /* ignore if URL parsing fails */ } } catch (e) { /* ignore if URL parsing fails */ }
streamsFromThisShareInfo.push({ streamsFromThisShareInfo.push({
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(key5FromDirectSource || streamTitle) provider: 'Febbox Direct',
headers: {}
}); });
} }
// If direct sources are found, original code used 'continue', // If direct sources are found, original code used 'continue',
@@ -2007,10 +2009,12 @@ const getStreamsFromTmdbId = async (tmdbType, tmdbId, seasonNum = null, episodeN
const streamTitle = `${baseStreamTitle} - ${source.label}`; const streamTitle = `${baseStreamTitle} - ${source.label}`;
streamsFromThisShareInfo.push({ streamsFromThisShareInfo.push({
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle) provider: 'Febbox FID',
headers: {}
}); });
} }
} }
@@ -2202,10 +2206,12 @@ const processShowWithSeasonsEpisodes = async (febboxUrl, showboxTitle, seasonNum
for (const source of directSources) { for (const source of directSources) {
const streamTitle = `${showboxTitle} - ${source.label}`; const streamTitle = `${showboxTitle} - ${source.label}`;
streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle) provider: 'Febbox Direct',
headers: {}
}); });
} }
console.timeEnd(processTimerLabel); console.timeEnd(processTimerLabel);
@@ -2222,10 +2228,12 @@ const processShowWithSeasonsEpisodes = async (febboxUrl, showboxTitle, seasonNum
for (const source of sources) { for (const source of sources) {
const streamTitle = `${showboxTitle} - ${source.label}`; const streamTitle = `${showboxTitle} - ${source.label}`;
streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle) provider: 'Febbox FID',
headers: {}
}); });
} }
} }
@@ -2506,10 +2514,12 @@ const processShowWithSeasonsEpisodes = async (febboxUrl, showboxTitle, seasonNum
const streamTitle = `${showboxTitle} - S${seasonNum}${episodeNum && selectedEpisode && fid === selectedEpisode.fid ? `E${episodeNum}` : (episodeDetail ? `E${episodeDetail.episodeNum}` : '')} - ${episodeName} - ${source.label}`; const streamTitle = `${showboxTitle} - S${seasonNum}${episodeNum && selectedEpisode && fid === selectedEpisode.fid ? `E${episodeNum}` : (episodeDetail ? `E${episodeDetail.episodeNum}` : '')} - ${episodeName} - ${source.label}`;
streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle) provider: 'Febbox FID',
headers: {}
}); });
} }
} else { } else {
@@ -2670,9 +2680,8 @@ const sortStreamsByQuality = (streams) => {
// Provider priority ordering (lower number = higher priority) // Provider priority ordering (lower number = higher priority)
const providerSortKeys = { const providerSortKeys = {
'ShowBox': 1, 'ShowBox': 1,
'Xprime.tv': 2, 'HollyMovieHD': 2,
'HollyMovieHD': 3, 'Soaper TV': 3,
'Soaper TV': 4,
// Default for unknown providers // Default for unknown providers
default: 99 default: 99
}; };
@@ -2862,17 +2871,15 @@ const processPStreamResponse = (apiResponse, tmdbType, seasonNum, episodeNum) =>
for (const [quality, url] of Object.entries(apiResponse.streams)) { for (const [quality, url] of Object.entries(apiResponse.streams)) {
if (url && typeof url === 'string') { if (url && typeof url === 'string') {
// Use "Auto" for name and actual filename for title // Use "Auto" for name and actual filename for title
const streamName = "Auto";
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${quality}`; const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${quality}`;
streams.push({ streams.push({
name: streamName, name: streamTitle,
title: streamTitle, title: streamTitle,
url: url, url: url,
quality: parseQualityFromLabel(quality), quality: parseQualityFromLabel(quality),
codecs: extractCodecDetails(apiResponse.name || quality), // Use filename for codec detection provider: 'PStream',
size: apiResponse.size || 'Unknown size', headers: {}
provider: 'PStream'
}); });
} }
} }
@@ -2890,17 +2897,15 @@ const processPStreamResponse = (apiResponse, tmdbType, seasonNum, episodeNum) =>
continue; continue;
} }
const streamName = "Auto";
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${source.quality}`; const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${source.quality}`;
streams.push({ streams.push({
name: streamName, name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.quality), quality: parseQualityFromLabel(source.quality),
codecs: source.codec ? [source.codec] : [], provider: 'PStream',
size: source.size || 'Unknown size', headers: {}
provider: 'PStream'
}); });
} }
@@ -2913,17 +2918,15 @@ const processPStreamResponse = (apiResponse, tmdbType, seasonNum, episodeNum) =>
for (const item of apiResponse.data) { for (const item of apiResponse.data) {
if (item.url || item.file) { if (item.url || item.file) {
const streamName = "Auto";
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${item.quality || item.label || 'Unknown'}`; const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${item.quality || item.label || 'Unknown'}`;
streams.push({ streams.push({
name: streamName, name: streamTitle,
title: streamTitle, title: streamTitle,
url: item.url || item.file, url: item.url || item.file,
quality: parseQualityFromLabel(item.quality || item.label), quality: parseQualityFromLabel(item.quality || item.label),
codecs: item.codec ? [item.codec] : [], provider: 'PStream',
size: item.size || 'Unknown size', headers: {}
provider: 'PStream'
}); });
} }
} }
+48 -11
View File
@@ -1,5 +1,43 @@
const axios = require('axios'); const axios = require('axios');
// Confirmed AES-CBC decoder for VidZee encrypted link format.
// Format (after initial atob in site script): ivBase64:cipherBase64
// ivBase64 -> CryptoJS Base64 parsed to WordArray (IV)
// Key: UTF-8 bytes of "qrincywincyspider" padded with nulls to 32 bytes (AES-256-CBC)
// Cipher: base64 ciphertext, PKCS7 padding, mode CBC.
let cryptoJs; // lazy load to avoid cost if not needed
function decodeVidZeeToken(token, debug) {
try {
if (typeof token !== 'string') return null;
if (/^https?:\/\//i.test(token)) return null; // already a URL
// Expect pattern base64:base64 (iv:cipher)
const raw = Buffer.from(token, 'base64').toString('utf8');
if (!raw.includes(':')) return null;
const [ivB64, cipherB64] = raw.split(':');
if (!ivB64 || !cipherB64) return null;
if (!cryptoJs) cryptoJs = require('crypto-js');
const iv = cryptoJs.enc.Base64.parse(ivB64.trim());
const keyStr = 'qrincywincyspider';
// Pad key with nulls to 32 bytes
const keyUtf8 = cryptoJs.enc.Utf8.parse(keyStr.padEnd(32, '\0'));
const decrypted = cryptoJs.AES.decrypt(cipherB64.trim(), keyUtf8, {
iv,
mode: cryptoJs.mode.CBC,
padding: cryptoJs.pad.Pkcs7
}).toString(cryptoJs.enc.Utf8);
if (!decrypted) return null;
if (!/^https?:\/\//i.test(decrypted)) {
if (debug) console.log('[VidZee] decrypted but not a URL', decrypted.slice(0,80));
return null;
}
if (debug) console.log('[VidZee] AES decoded token', { tokenSnippet: token.slice(0, 24)+'...', url: decrypted.slice(0,120) });
return decrypted.trim();
} catch (e) {
if (debug) console.log('[VidZee] AES decode error', e.message);
return null;
}
}
// Removed unused parseArgs helper // Removed unused parseArgs helper
const getVidZeeStreams = async (tmdbId, mediaType, seasonNum, episodeNum) => { const getVidZeeStreams = async (tmdbId, mediaType, seasonNum, episodeNum) => {
@@ -73,29 +111,28 @@ const getVidZeeStreams = async (tmdbId, mediaType, seasonNum, episodeNum) => {
} }
const streams = apiSources.map(sourceItem => { const streams = apiSources.map(sourceItem => {
// Prefer sourceItem.name as label, fallback to sourceItem.type, then 'VidZee Stream'
const label = sourceItem.name || sourceItem.type || 'VidZee'; const label = sourceItem.name || sourceItem.type || 'VidZee';
// Ensure quality has 'p' if it's a resolution, or keep it as is
let quality = String(label).match(/^\d+$/) ? `${label}p` : label; let quality = String(label).match(/^\d+$/) ? `${label}p` : label;
// Normalize non-numeric or ambiguous quality labels to a baseline so they survive minQuality filter
if (!/(\d{3,4})p/.test(quality.toLowerCase())) { if (!/(\d{3,4})p/.test(quality.toLowerCase())) {
quality = '720p'; quality = '720p';
} }
const language = sourceItem.language || sourceItem.lang; const language = sourceItem.language || sourceItem.lang;
let rawLink = sourceItem.link;
const debug = process.env.VIDZEE_DEBUG === '1';
const decoded = decodeVidZeeToken(rawLink, debug);
if (decoded && /^https?:\/\//i.test(decoded)) {
if (debug) console.log('[VidZee] decoded link', { beforeSample: rawLink.slice(0,40)+'...', after: decoded.slice(0,80) });
rawLink = decoded;
}
return { return {
title: `VidZee S${sr} - ${quality}`, name: `VidZee Server${sr} - ${quality} - ${language}`,
url: sourceItem.link, // Use sourceItem.link for the URL title: `VidZee Server${sr} - ${quality} - ${language}`,
url: rawLink,
quality: quality, quality: quality,
language: language,
provider: "VidZee", provider: "VidZee",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: { headers: {
'Referer': 'https://core.vidzee.wtf/' 'Referer': 'https://core.vidzee.wtf/'
} }
}
}; };
}).filter(stream => stream.url); }).filter(stream => stream.url);
+2
View File
@@ -1106,6 +1106,8 @@ async function getMoviesModStreams(tmdbId, mediaType, seasonNum = null, episodeN
title: `${cleanFileName}\n${sizeInfo || ''}${techDetailsString}`, title: `${cleanFileName}\n${sizeInfo || ''}${techDetailsString}`,
url: selectedResult.url, url: selectedResult.url,
quality: actualQuality, quality: actualQuality,
provider: 'moviesmod',
headers: {}
}; };
} else { } else {
console.warn(`[MoviesMod] Unsupported URL type for final processing: ${targetLink?.url}`); console.warn(`[MoviesMod] Unsupported URL type for final processing: ${targetLink?.url}`);
+92 -218
View File
@@ -1,12 +1,21 @@
const { config } = require('../utils/config'); const { config } = require('../utils/config');
// Lazy load showbox to avoid early side-effects; temporarily disabled until stabilized const fs = require('fs');
let showbox = null; const path = require('path');
let k4hdhub = null; // 4khdhub provider lazy ref
let moviesmod = null; // moviesmod provider lazy ref // Lazy load cache for providers
let mp4hydra = null; // MP4Hydra provider lazy ref const providerCache = new Map();
let vidzee = null; // VidZee provider lazy ref
let vixsrc = null; // vixsrc provider lazy ref // Function name mappings for existing providers
let xprime = null; // xprime provider lazy ref const providerFunctionMap = {
'Showbox.js': 'getStreamsFromTmdbId',
'4khdhub.js': 'get4KHDHubStreams',
'moviesmod.js': 'getMoviesModStreams',
'MP4Hydra.js': 'getMP4HydraStreams',
'VidZee.js': 'getVidZeeStreams',
'vixsrc.js': 'getVixsrcStreams',
'uhdmovies.js': 'getUHDMoviesStreams',
};
// Stats for debug endpoint // Stats for debug endpoint
let lastCookieStats = { selected: null, index: null, total: 0, remainingMB: null, timestamp: null }; let lastCookieStats = { selected: null, index: null, total: 0, remainingMB: null, timestamp: null };
@@ -14,11 +23,50 @@ async function getEffectiveCookies() {
return Array.isArray(config.febboxCookies) ? config.febboxCookies : []; return Array.isArray(config.febboxCookies) ? config.febboxCookies : [];
} }
// Adapter for Showbox provider using existing exported getStreamsFromTmdbId // Get all provider files
async function fetchShowbox(ctx) { function getProviderFiles() {
if (!showbox) { const providersDir = path.join(__dirname);
try { showbox = require('./Showbox.js'); } catch (e) { console.error('Failed to load Showbox provider:', e.message); return []; } return fs.readdirSync(providersDir)
.filter(file => file.endsWith('.js') && file !== 'registry.js' && file !== 'vidsrcextractor.js')
.map(file => ({
name: path.parse(file).name.toLowerCase(),
file: file,
functionName: providerFunctionMap[file]
}));
} }
// Load a provider module
function loadProvider(providerFile) {
if (!providerCache.has(providerFile)) {
try {
providerCache.set(providerFile, require(path.join(__dirname, providerFile)));
} catch (e) {
console.error(`Failed to load provider ${providerFile}:`, e.message);
return null;
}
}
return providerCache.get(providerFile);
}
// Create fetch function for a provider
function createFetchFunction(providerInfo) {
return async function(ctx) {
const module = loadProvider(providerInfo.file);
if (!module) return [];
const funcName = providerInfo.functionName;
if (!module[funcName]) {
console.error(`Provider ${providerInfo.name} does not export ${funcName}`);
return [];
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
let result;
if (providerInfo.name === 'showbox') {
// Special case for Showbox with TMDB key and cookies
const { getTmdbApiKey } = require('../utils/tmdbKey'); const { getTmdbApiKey } = require('../utils/tmdbKey');
const tmdbApiKey = getTmdbApiKey(); const tmdbApiKey = getTmdbApiKey();
if (!tmdbApiKey) { if (!tmdbApiKey) {
@@ -37,223 +85,49 @@ async function fetchShowbox(ctx) {
lastCookieStats = { selected: selected.slice(0, 16) + '...', index, total: cookies.length, remainingMB: null, timestamp: Date.now() }; lastCookieStats = { selected: selected.slice(0, 16) + '...', index, total: cookies.length, remainingMB: null, timestamp: Date.now() };
console.log(`[registry] Cookie random pick index=${index} total=${cookies.length}`); console.log(`[registry] Cookie random pick index=${index} total=${cookies.length}`);
} }
try { result = await module[funcName](mediaType, ctx.tmdbId, ctx.season || null, ctx.episode || null, null, selected);
const tmdbType = ctx.type === 'movie' ? 'movie' : 'tv';
console.log(`[registry] Fetching Showbox streams for ${tmdbType} tmdbId=${ctx.tmdbId} season=${ctx.season||''} episode=${ctx.episode||''}`);
const t0 = Date.now();
const streams = await showbox.getStreamsFromTmdbId(tmdbType, ctx.tmdbId, ctx.season || null, ctx.episode || null, null, selected);
const durationMs = Date.now() - t0;
console.log(`[registry] Provider showbox fetch duration ${durationMs}ms`);
if (global.currentRequestUserCookieRemainingMB != null) { if (global.currentRequestUserCookieRemainingMB != null) {
lastCookieStats.remainingMB = global.currentRequestUserCookieRemainingMB; lastCookieStats.remainingMB = global.currentRequestUserCookieRemainingMB;
} }
console.log(`[registry] Showbox returned ${Array.isArray(streams)?streams.length:0} streams`);
return Array.isArray(streams) ? streams : [];
} catch (e) {
console.error('[registry] Showbox fetch error:', e.message);
return [];
} finally {
global.currentRequestConfig = previousConfig || {}; global.currentRequestConfig = previousConfig || {};
} } else {
// Standard provider call
result = await module[funcName](ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
} }
const durationMs = Date.now() - t0;
console.log(`[registry] ${providerInfo.name} fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
// Add provider name if not present
return result.map(s => ({ ...s, provider: s.provider || providerInfo.name }));
} catch (e) {
console.error(`[registry] ${providerInfo.name} fetch error:`, e.message);
return [];
}
};
}
// Initialize providers
const providerFiles = getProviderFiles();
const providers = []; const providers = [];
if (config.enableShowboxProvider) { for (const providerInfo of providerFiles) {
providers.push({ name: 'showbox', enabled: true, fetch: fetchShowbox }); const enableFlag = `enable${providerInfo.name.charAt(0).toUpperCase() + providerInfo.name.slice(1)}Provider`;
} else { const enabled = config[enableFlag] !== false;
console.log('[registry] showbox disabled via config');
}
if (enabled) {
// 4KHDHub adapter providers.push({
async function fetch4khdhub(ctx) { name: providerInfo.name,
if (!k4hdhub) { enabled: true,
try { k4hdhub = require('./4khdhub.js'); } catch (e) { console.error('Failed to load 4khdhub provider:', e.message); return []; } fetch: createFetchFunction(providerInfo)
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await k4hdhub.get4KHDHubStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] 4khdhub fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || '4KHDHub', provider: '4khdhub' }));
} catch (e) {
console.error('[registry] 4khdhub fetch error:', e.message);
return [];
}
}
if (config.enable4khdhubProvider) {
providers.push({ name: '4khdhub', enabled: true, fetch: fetch4khdhub });
console.log('[registry] 4khdhub provider enabled');
} else {
console.log('[registry] 4khdhub disabled via config');
}
// MoviesMod adapter
async function fetchMoviesMod(ctx) {
if (!moviesmod) {
try { moviesmod = require('./moviesmod.js'); } catch (e) { console.error('Failed to load moviesmod provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await moviesmod.getMoviesModStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] moviesmod fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || 'MoviesMod', provider: 'moviesmod' }));
} catch (e) {
console.error('[registry] moviesmod fetch error:', e.message);
return [];
}
}
if (config.enableMoviesmodProvider) {
providers.push({ name: 'moviesmod', enabled: true, fetch: fetchMoviesMod });
console.log('[registry] moviesmod provider enabled');
} else {
console.log('[registry] moviesmod disabled via config');
}
// MP4Hydra adapter
async function fetchMp4hydra(ctx) {
if (!mp4hydra) {
try { mp4hydra = require('./MP4Hydra.js'); } catch (e) { console.error('Failed to load MP4Hydra provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await mp4hydra.getMP4HydraStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] mp4hydra fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || 'MP4Hydra', provider: 'mp4hydra' }));
} catch (e) {
console.error('[registry] mp4hydra fetch error:', e.message);
return [];
}
}
if (config.enableMp4hydraProvider) {
providers.push({ name: 'mp4hydra', enabled: true, fetch: fetchMp4hydra });
console.log('[registry] mp4hydra provider enabled');
} else {
console.log('[registry] mp4hydra disabled via config');
}
// VidZee adapter
async function fetchVidZee(ctx) {
if (!vidzee) {
try { vidzee = require('./VidZee.js'); } catch (e) { console.error('Failed to load VidZee provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await vidzee.getVidZeeStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] vidzee fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || 'VidZee', provider: 'vidzee' }));
} catch (e) {
console.error('[registry] vidzee fetch error:', e.message);
return [];
}
}
if (config.enableVidzeeProvider) {
providers.push({ name: 'vidzee', enabled: true, fetch: fetchVidZee });
console.log('[registry] vidzee provider enabled');
} else {
console.log('[registry] vidzee disabled via config');
}
// vixsrc adapter
async function fetchVixsrc(ctx) {
if (!vixsrc) {
try { vixsrc = require('./vixsrc.js'); } catch (e) { console.error('Failed to load vixsrc provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await vixsrc.getVixsrcStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] vixsrc fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || 'Vixsrc', provider: 'vixsrc' }));
} catch (e) {
console.error('[registry] vixsrc fetch error:', e.message);
return [];
}
}
if (config.enableVixsrcProvider) {
providers.push({ name: 'vixsrc', enabled: true, fetch: fetchVixsrc });
console.log('[registry] vixsrc provider enabled');
} else {
console.log('[registry] vixsrc disabled via config');
}
// xprime adapter (requires TMDB metadata lookup for title/year)
async function fetchXprime(ctx) {
if (!xprime) {
try { xprime = require('./xprime.js'); } catch (e) { console.error('Failed to load xprime provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const { getTmdbApiKey } = require('../utils/tmdbKey');
const tmdbApiKey = getTmdbApiKey();
if (!tmdbApiKey) {
console.warn('[registry] xprime skipped: TMDB API key missing');
return [];
}
// Fetch minimal TMDB metadata for title + year
let title = null; let year = null;
try {
const axios = require('axios');
const metaUrl = `https://api.themoviedb.org/3/${mediaType}/${ctx.tmdbId}?api_key=${tmdbApiKey}`;
const { data: meta } = await axios.get(metaUrl, { timeout: 8000 });
title = mediaType === 'movie' ? (meta.title || meta.original_title) : (meta.name || meta.original_name);
const dateStr = mediaType === 'movie' ? meta.release_date : meta.first_air_date;
if (dateStr) year = dateStr.split('-')[0];
} catch (mErr) {
console.warn('[registry] xprime metadata fetch failed:', mErr.message);
return [];
}
if (!title || !year) {
console.log('[registry] xprime missing title/year after TMDB lookup');
return [];
}
const t0 = Date.now();
const streams = await xprime.getXprimeStreams(title, year, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] xprime fetch duration ${durationMs}ms (title='${title}' year=${year}) returned ${Array.isArray(streams)?streams.length:0}`);
if (!Array.isArray(streams) || !streams.length) return [];
// Normalize quality labels that are non-numeric (e.g., FHD/HD/SD)
return streams.map(s => {
let q = s.quality || 'Unknown';
if (!/(\d{3,4})p/.test(q.toLowerCase())) {
const qLower = q.toLowerCase();
if (qLower.includes('fhd') || qLower.includes('full')) q = '1080p';
else if (qLower === 'hd' || qLower.includes('720')) q = '720p';
else if (qLower.includes('sd') || qLower.includes('480')) q = '480p';
else if (qLower.includes('cam')) q = '360p';
}
return { ...s, quality: q, name: s.name || 'Xprime', provider: 'xprime' };
}); });
} catch (e) { console.log(`[registry] ${providerInfo.name} provider enabled`);
console.error('[registry] xprime fetch error:', e.message);
return [];
}
}
if (config.enableXprimeProvider) {
providers.push({ name: 'xprime', enabled: true, fetch: fetchXprime });
console.log('[registry] xprime provider enabled');
} else { } else {
console.log('[registry] xprime disabled via config'); console.log(`[registry] ${providerInfo.name} disabled via config`);
}
} }
function listProviders() { return providers.map(p => ({ name: p.name, enabled: p.enabled })); } function listProviders() { return providers.map(p => ({ name: p.name, enabled: p.enabled })); }
+2 -5
View File
@@ -1380,11 +1380,8 @@ async function getUHDMoviesStreams(tmdbId, mediaType = 'movie', season = null, e
title: `${cleanFileName}\n${sizeInfo}`, title: `${cleanFileName}\n${sizeInfo}`,
url: finalUrl, url: finalUrl,
quality: linkInfo.quality, quality: linkInfo.quality,
size: sizeInfo, provider: 'uhdmovies',
fileName: fileName, headers: {}
fullTitle: rawQuality,
codecs: codecs,
behaviorHints: { bingeGroup: `uhdmovies-${linkInfo.quality}` }
}; };
} }
} }
+4 -5
View File
@@ -6,7 +6,8 @@
// Constants // Constants
const TMDB_API_KEY = "68e094699525b18a70bab2f86b1fa706"; const TMDB_API_KEY = "68e094699525b18a70bab2f86b1fa706";
const BASE_URL = 'https://vixsrc.to'; const BASE_URL = 'https://vixsrc.to';
let vixsrcUrl;
let subtitleApiUrl;
// Helper function to make HTTP requests with default headers // Helper function to make HTTP requests with default headers
function makeRequest(url, options = {}) { function makeRequest(url, options = {}) {
const defaultHeaders = { const defaultHeaders = {
@@ -51,8 +52,6 @@ function getTmdbInfo(tmdbId, mediaType) {
// Helper function to extract stream URL from Vixsrc page // Helper function to extract stream URL from Vixsrc page
function extractStreamFromPage(url, contentType, contentId, seasonNum, episodeNum) { function extractStreamFromPage(url, contentType, contentId, seasonNum, episodeNum) {
let vixsrcUrl;
let subtitleApiUrl;
if (contentType === 'movie') { if (contentType === 'movie') {
vixsrcUrl = `${BASE_URL}/movie/${contentId}`; vixsrcUrl = `${BASE_URL}/movie/${contentId}`;
@@ -195,9 +194,9 @@ function getVixsrcStreams(tmdbId, mediaType = 'movie', seasonNum = null, episode
title: "Auto Quality Stream", title: "Auto Quality Stream",
url: masterPlaylistUrl, url: masterPlaylistUrl,
quality: '1080p', quality: '1080p',
type: 'direct', provider: 'Vidsrc',
headers: { headers: {
'Referer': BASE_URL, 'Referer': vixsrcUrl,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
} }
}]; }];
-158
View File
@@ -1,158 +0,0 @@
// xprime.js (simplified & cleaned)
require('dotenv').config();
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const RETRY_DELAY_MS_XPRIME = 1000;
const MAX_RETRIES_XPRIME = 3;
// Cache directory (Vercel ephemeral vs local)
const CACHE_DIR = process.env.VERCEL ? path.join('/tmp', '.cache') : path.join(__dirname, '.cache');
async function ensureCacheDir(dirPath) {
try { await fs.mkdir(dirPath, { recursive: true }); } catch (_) {
// swallow errors creating cache dir (ephemeral env or permission)
}
}
async function getFromCache(cacheKey, subDir = '') {
if (process.env.DISABLE_CACHE === 'true') return null;
try {
const fullDir = path.join(CACHE_DIR, subDir);
const filePath = path.join(fullDir, cacheKey);
const data = await fs.readFile(filePath, 'utf8');
return data;
} catch (_) { return null; }
}
async function saveToCache(cacheKey, data, subDir = '') {
if (process.env.DISABLE_CACHE === 'true') return;
try {
const fullDir = path.join(CACHE_DIR, subDir);
await fs.mkdir(fullDir, { recursive: true });
const filePath = path.join(fullDir, cacheKey);
await fs.writeFile(filePath, data, 'utf8');
} catch (_) {
// ignore cache write errors (non-fatal)
}
}
const BROWSER_HEADERS_XPRIME = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Sec-Ch-Ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Connection': 'keep-alive'
};
async function fetchWithRetry(url, options, maxRetries = MAX_RETRIES_XPRIME) {
const { default: fetch } = await import('node-fetch');
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
} catch (err) {
lastError = err;
if (attempt < maxRetries) {
await new Promise(r => setTimeout(r, RETRY_DELAY_MS_XPRIME * Math.pow(2, attempt - 1)));
}
}
}
throw lastError;
}
async function fetchStreamSize(url) {
const subDir = 'xprime_stream_sizes';
const hash = crypto.createHash('md5').update(url).digest('hex');
const key = `${hash}.txt`;
const cached = await getFromCache(key, subDir);
if (cached) return cached;
if (url.toLowerCase().includes('.m3u8')) {
await saveToCache(key, 'Playlist (size N/A)', subDir);
return 'Playlist (size N/A)';
}
try {
const { default: fetch } = await import('node-fetch');
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000);
let size = 'Unknown size';
try {
const head = await fetch(url, { method: 'HEAD', signal: controller.signal });
const cl = head.headers.get('content-length');
if (cl) {
const bytes = parseInt(cl, 10);
if (!isNaN(bytes)) {
if (bytes < 1024) size = `${bytes} B`;
else if (bytes < 1024 * 1024) size = `${(bytes / 1024).toFixed(2)} KB`;
else if (bytes < 1024 * 1024 * 1024) size = `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
else size = `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
}
} finally { clearTimeout(t); }
await saveToCache(key, size, subDir);
return size;
} catch (_) {
await saveToCache(key, 'Unknown size', subDir);
return 'Unknown size';
}
}
async function getXprimeStreams(title, year, type, seasonNum, episodeNum) {
if (!title || !year) return [];
const encoded = encodeURIComponent(title);
let apiUrl;
if (type === 'movie') {
apiUrl = `https://backend.xprime.tv/primebox?name=${encoded}&year=${year}&fallback_year=${year}`;
} else if (type === 'tv') {
if (seasonNum == null || episodeNum == null) return [];
apiUrl = `https://backend.xprime.tv/primebox?name=${encoded}&year=${year}&fallback_year=${year}&season=${seasonNum}&episode=${episodeNum}`;
} else return [];
console.log(`[Xprime.tv] Fetch attempt '${title}' (${year}) type=${type}`);
let resJson;
try {
const r = await fetchWithRetry(apiUrl, { headers: BROWSER_HEADERS_XPRIME });
resJson = await r.json();
} catch (e) {
console.error('[Xprime.tv] Fetch failed:', e.message);
return [];
}
const streams = [];
const processItem = (item) => {
if (!item || item.error || !item.streams) return;
Object.entries(item.streams).forEach(([quality, url]) => {
if (!url || typeof url !== 'string') return;
streams.push({
url,
quality: quality || 'Unknown',
title: `${title} - ${type === 'tv' ? `S${String(seasonNum).padStart(2,'0')}E${String(episodeNum).padStart(2,'0')} ` : ''}${quality}`,
provider: 'Xprime.tv',
codecs: [],
size: 'Unknown size'
});
});
};
if (Array.isArray(resJson)) resJson.forEach(processItem); else processItem(resJson);
if (streams.length) {
console.time('[Xprime.tv] Fetch stream sizes');
await Promise.all(streams.map(async s => { s.size = await fetchStreamSize(s.url); }));
console.timeEnd('[Xprime.tv] Fetch stream sizes');
}
console.log(`[Xprime.tv] Returning ${streams.length} streams.`);
return streams;
}
ensureCacheDir(CACHE_DIR).catch(()=>{});
module.exports = { getXprimeStreams };
+451
View File
@@ -0,0 +1,451 @@
// Lightweight proxy/m3u8/segment/subtitle rewriting layer. thanks to https://github.com/cinepro-org/ for the original code.
// Adapted from external project (user supplied) to fit CommonJS style and existing config system.
// Provides conditional route registration via enableProxy flag.
const cors = require('cors');
const fetch = require('node-fetch');
const CACHE_MAX_SIZE = 2000;
const CACHE_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
const segmentCache = new Map();
// Track first open-ended (bytes=0-) range per target to clamp only once per TTL window
const openRangeClampMap = new Map();
const OPEN_RANGE_CLAMP_TTL_MS = 5 * 60 * 1000; // 5 minutes
// Progressive open-ended growth tracking: keeps expanding bytes=0- served window.
const progressiveOpenMap = new Map(); // url -> { lastEnd }
// Tail prefetch cache: stores last N KB for quick tail range responses
const tailPrefetchMap = new Map(); // url -> { data: Buffer, start: number, end: number, size: number, ts: number }
const TAIL_PREFETCH_TTL_MS = 10 * 60 * 1000; // 10 minutes
function isCacheDisabled() {
return process.env.DISABLE_CACHE === 'true';
}
function cleanupCache() {
const now = Date.now();
for (const [url, entry] of segmentCache.entries()) {
if (now - entry.timestamp > CACHE_EXPIRY_MS) segmentCache.delete(url);
}
if (segmentCache.size > CACHE_MAX_SIZE) {
const entries = Array.from(segmentCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp);
const toRemove = entries.slice(0, segmentCache.size - CACHE_MAX_SIZE);
toRemove.forEach(([u]) => segmentCache.delete(u));
}
return segmentCache.size;
}
setInterval(cleanupCache, 30 * 60 * 1000).unref();
function cleanupClampMap() {
const now = Date.now();
for (const [url, ts] of openRangeClampMap.entries()) {
if (now - ts > OPEN_RANGE_CLAMP_TTL_MS) openRangeClampMap.delete(url);
}
}
setInterval(cleanupClampMap, 10 * 60 * 1000).unref();
function cleanupTailPrefetch() {
const now = Date.now();
for (const [url, entry] of tailPrefetchMap.entries()) {
if (now - entry.ts > TAIL_PREFETCH_TTL_MS) tailPrefetchMap.delete(url);
}
}
setInterval(cleanupTailPrefetch, 15 * 60 * 1000).unref();
function getCachedSegment(url) {
if (isCacheDisabled()) return null;
const e = segmentCache.get(url);
if (!e) return null;
if (Date.now() - e.timestamp > CACHE_EXPIRY_MS) { segmentCache.delete(url); return null; }
return e;
}
async function prefetchSegment(url, headers) {
if (isCacheDisabled() || segmentCache.size >= CACHE_MAX_SIZE) return;
const existing = segmentCache.get(url);
if (existing && Date.now() - existing.timestamp <= CACHE_EXPIRY_MS) return;
try {
const resp = await fetch(url, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
if (!resp.ok) return;
const data = new Uint8Array(await resp.arrayBuffer());
const responseHeaders = {};
resp.headers.forEach((v, k) => responseHeaders[k] = v);
segmentCache.set(url, { data, headers: responseHeaders, timestamp: Date.now() });
} catch (_e) { /* ignore */ }
}
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
function inferContentType(upstreamCT, targetUrl) {
const bad = !upstreamCT || /application\/octet-stream/i.test(upstreamCT) || /application\/(x-)?zip/i.test(upstreamCT);
if (!bad) return upstreamCT;
if (/\.mkv(\?|$)/i.test(targetUrl)) return 'video/x-matroska';
if (/\.mp4(\?|$)/i.test(targetUrl)) return 'video/mp4';
if (/\.m3u8(\?|$)/i.test(targetUrl)) return 'application/vnd.apple.mpegurl';
if (/\.ts(\?|$)/i.test(targetUrl)) return 'video/mp2t';
return 'application/octet-stream';
}
function extractOriginalUrl(proxyUrl) {
try {
const url = new URL(proxyUrl);
if (url.pathname.includes('/proxy/')) {
const m = url.pathname.match(/\/proxy\/(.+)$/);
if (m) {
let decoded = decodeURIComponent(m[1]);
while (decoded.includes('%2F')) {
try { decoded = decodeURIComponent(decoded); } catch { break; }
}
return decoded;
}
}
if (url.searchParams.has('url')) return decodeURIComponent(url.searchParams.get('url'));
const patterns = [
/\/api\/[^/]+\/proxy\?url=(.+)$/,
/\/proxy\?.*url=([^&]+)/,
/\/stream\/proxy\/(.+)$/,
/\/p\/(.+)$/
];
for (const p of patterns) {
const m = proxyUrl.match(p); if (m) return decodeURIComponent(m[1]);
}
return proxyUrl;
} catch { return proxyUrl; }
}
function rewriteM3u8(content, targetUrl, baseProxyUrl, headers) {
const lines = content.split('\n');
const out = []; const segmentUrls = [];
for (const line of lines) {
if (line.startsWith('#')) {
if (line.startsWith('#EXT-X-KEY:')) {
const regex = /https?:\/\/[^""\s]+/g; const keyUrl = regex.exec(line)?.[0];
if (keyUrl) {
const proxyUrl = `${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(keyUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`;
out.push(line.replace(keyUrl, proxyUrl));
if (!isCacheDisabled()) prefetchSegment(keyUrl, headers);
} else out.push(line);
} else if (line.startsWith('#EXT-X-MEDIA:') || line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
try {
const mediaUrl = new URL(uriMatch[1], targetUrl).href;
const proxyUrl = `${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(mediaUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`;
out.push(line.replace(uriMatch[1], proxyUrl));
} catch { out.push(line); }
} else out.push(line);
} else out.push(line);
} else if (line.trim()) {
try {
const abs = new URL(line, targetUrl).href;
if (/\.m3u8(\?|$)/i.test(abs)) {
out.push(`${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(abs)}&headers=${encodeURIComponent(JSON.stringify(headers))}`);
} else if (/\.ts(\?|$)/i.test(abs)) {
segmentUrls.push(abs);
out.push(`${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(abs)}&headers=${encodeURIComponent(JSON.stringify(headers))}`);
} else out.push(line);
} catch { out.push(line); }
} else out.push(line);
}
if (segmentUrls.length && !isCacheDisabled()) {
Promise.all(segmentUrls.map(u => prefetchSegment(u, headers))).catch(() => undefined);
}
return out.join('\n');
}
function createProxyRoutes(app) {
// m3u8 playlist proxy
app.get('/m3u8-proxy', cors(), async (req, res) => {
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'URL parameter required' });
let headers = {};
try { headers = JSON.parse(req.query.headers || '{}'); } catch {
// Ignore URL parsing errors
}
try {
const response = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
if (!response.ok) return res.status(response.status).json({ error: `M3U8 fetch failed: ${response.status}` });
const text = await response.text();
const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const baseProxyUrl = `${protocol}://${req.get('host')}`;
const rewritten = rewriteM3u8(text, targetUrl, baseProxyUrl, headers);
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Access-Control-Allow-Origin', '*');
res.send(rewritten);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ts / key segment proxy
app.get('/ts-proxy', cors(), async (req, res) => {
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'URL parameter required' });
const debug = req.query.debug === '1';
const noSynth = req.query.noSynth === '1';
const force200 = req.query.force200 === '1';
const clampOpen = req.query.clampOpen !== '0';
const progressiveOpen = req.query.progressiveOpen !== '0';
const tailPrefetchEnabled = req.query.tailPrefetch !== '0';
let tailPrefetchKB = parseInt(req.query.tailPrefetchKB || '256', 10);
if (isNaN(tailPrefetchKB) || tailPrefetchKB < 64) tailPrefetchKB = 256;
if (tailPrefetchKB > 2048) tailPrefetchKB = 2048; // max 2MB tail window
let openChunkKB = parseInt(req.query.openChunkKB || '4096', 10);
if (isNaN(openChunkKB) || openChunkKB < 64) openChunkKB = 4096;
if (openChunkKB > 16384) openChunkKB = 16384; // cap at 16MB
let initChunkKB = parseInt(req.query.initChunkKB || '512', 10);
if (isNaN(initChunkKB) || initChunkKB < 64) initChunkKB = 512;
if (initChunkKB > 2048) initChunkKB = 2048; // hard cap 2MB
let headers = {}; try { headers = JSON.parse(req.query.headers || '{}'); } catch {
// Ignore URL parsing errors
}
// Pass through Range header for progressive playback / seeking
const range = req.headers['range'];
let appliedClamp = false;
let effectiveRange = range;
if (range) headers.Range = range;
// Clamp first open-ended bytes=0- request to a bounded span (once per TTL) to avoid gigantic initial 206
if (range && /^bytes=0-\s*$/i.test(range) && !force200) {
const now = Date.now();
// Progressive growth supersedes single-shot clamp if enabled
if (progressiveOpen) {
const prog = progressiveOpenMap.get(targetUrl) || { lastEnd: -1 };
const prevEnd = prog.lastEnd;
let nextEnd;
if (prog.lastEnd < 0) {
nextEnd = openChunkKB * 1024 - 1;
} else {
const increment = openChunkKB * 1024;
nextEnd = prog.lastEnd + increment;
const maxCap = 256 * 1024 * 1024 - 1; // 256MB
if (nextEnd > maxCap) nextEnd = maxCap;
}
const newRange = `bytes=0-${nextEnd}`;
headers.Range = newRange;
effectiveRange = newRange;
prog.lastEnd = nextEnd;
progressiveOpenMap.set(targetUrl, prog);
appliedClamp = true; // reuse flag for logging
if (debug) console.log('[ts-proxy] progressiveOpen expand', { prevEnd, nextEnd, increment: openChunkKB * 1024 });
} else if (clampOpen) {
const lastTs = openRangeClampMap.get(targetUrl);
if (!lastTs || (now - lastTs) > OPEN_RANGE_CLAMP_TTL_MS) {
const clampBytes = openChunkKB * 1024;
const newRange = `bytes=0-${clampBytes - 1}`;
headers.Range = newRange;
effectiveRange = newRange;
openRangeClampMap.set(targetUrl, now);
appliedClamp = true;
}
}
}
if (debug) console.log('[ts-proxy] incoming', { url: targetUrl, range, effectiveRange, appliedClamp, clampOpen, progressiveOpen, openChunkKB, noSynth, initChunkKB, force200, tailPrefetchEnabled, tailPrefetchKB });
if (!isCacheDisabled()) {
const cached = getCachedSegment(targetUrl);
if (cached) {
const ct = inferContentType(cached.headers['content-type'], targetUrl);
res.setHeader('Content-Type', ct);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Accept-Ranges', 'bytes');
if (debug) console.log('[ts-proxy] cache hit');
return res.send(Buffer.from(cached.data));
}
}
try {
// If we have a tail prefetch entry and the client requests a tail range fully inside it, serve from memory early.
if (effectiveRange && /^bytes=\d+-$/i.test(effectiveRange)) {
const tailEntry = tailPrefetchMap.get(targetUrl);
if (tailEntry) {
const m = effectiveRange.match(/bytes=(\d+)-/i);
if (m) {
const startReq = parseInt(m[1], 10);
if (!isNaN(startReq) && startReq >= tailEntry.start && startReq <= tailEntry.end) {
const offset = startReq - tailEntry.start;
const slice = tailEntry.data.subarray(offset);
const endByte = tailEntry.end;
res.status(206);
const ct = inferContentType(null, targetUrl);
res.setHeader('Content-Type', ct);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Length', slice.length.toString());
res.setHeader('Content-Range', `bytes ${startReq}-${endByte}/${tailEntry.size}`);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
if (debug) console.log('[ts-proxy] tail serve hit', { startReq, cachedStart: tailEntry.start, cachedEnd: tailEntry.end, size: tailEntry.size });
return res.end(slice);
}
}
}
}
// HEAD preflight (only when no explicit Range requested) to get size for synthesized partial response
let contentLength = null;
let upstreamAcceptRanges = null;
if (!effectiveRange) {
try {
const headResp = await fetch(targetUrl, { method: 'HEAD', headers: { 'User-Agent': DEFAULT_UA, ...headers } });
if (headResp.ok) {
contentLength = headResp.headers.get('content-length');
upstreamAcceptRanges = headResp.headers.get('accept-ranges');
if (debug) console.log('[ts-proxy] HEAD ok', { contentLength, upstreamAcceptRanges });
} else if (debug) {
console.log('[ts-proxy] HEAD status', headResp.status);
}
} catch (_e) { if (debug) console.log('[ts-proxy] HEAD failed'); }
}
// Fallback probe: if still no size, attempt a 0-0 range request to derive total size from Content-Range
if (!effectiveRange && !contentLength) {
try {
const probeRange = 'bytes=0-0';
if (debug) console.log('[ts-proxy] probe range');
const probeResp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, 'Range': probeRange } });
if (probeResp.status === 206) {
const cr = probeResp.headers.get('content-range');
if (cr) {
const m = cr.match(/\/(\d+)$/); if (m) contentLength = m[1];
}
try { await probeResp.arrayBuffer(); } catch (readErr) {
if (debug) console.log('[ts-proxy] probe body read failed', readErr.message);
}
if (debug) console.log('[ts-proxy] probe success', { contentLength });
} else if (debug) {
console.log('[ts-proxy] probe status', probeResp.status);
}
} catch (_e) { if (debug) console.log('[ts-proxy] probe failed'); }
}
if (debug && contentLength) console.log('[ts-proxy] size determined', contentLength);
// Initiate tail prefetch asynchronously (once) when size known and feature enabled
if (tailPrefetchEnabled && contentLength && !tailPrefetchMap.has(targetUrl)) {
const total = parseInt(contentLength, 10);
if (!isNaN(total) && total > 0) {
const tailBytes = Math.min(tailPrefetchKB * 1024, total);
const start = Math.max(0, total - tailBytes);
const tailRange = `bytes=${start}-`;
(async () => {
try {
if (debug) console.log('[ts-proxy] tail prefetch start', { tailRange });
const tr = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, Range: tailRange } });
if (tr.status === 206) {
const buf = Buffer.from(await tr.arrayBuffer());
const cr = tr.headers.get('content-range');
if (cr) {
const mm = cr.match(/bytes\s+(\d+)-(\d+)\/(\d+)/i);
if (mm) {
const s = parseInt(mm[1], 10); const e = parseInt(mm[2], 10); const sz = parseInt(mm[3], 10);
if (!isNaN(s) && !isNaN(e) && !isNaN(sz)) {
tailPrefetchMap.set(targetUrl, { data: buf, start: s, end: e, size: sz, ts: Date.now() });
if (debug) console.log('[ts-proxy] tail prefetch success', { start: s, end: e, bytes: buf.length });
}
}
}
} else if (debug) {
console.log('[ts-proxy] tail prefetch status', tr.status);
}
} catch (e) { if (debug) console.log('[ts-proxy] tail prefetch error', e.message); }
})();
}
}
// If no range requested but we now know size (HEAD or probe), synthesize an initial small range fetch to accelerate playback.
if (!force200 && !effectiveRange && contentLength && !noSynth && req.query.progressiveOpen === '0') {
const total = parseInt(contentLength, 10);
const desired = initChunkKB * 1024;
const chunkSize = Math.min(desired, Math.max(0, total - 1));
const syntheticRange = `bytes=0-${chunkSize}`;
const resp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, 'Range': syntheticRange } });
if (resp.status === 206) {
if (debug) console.log('[ts-proxy] synthetic 206', syntheticRange);
const upstreamCT = inferContentType(resp.headers.get('content-type'), targetUrl);
res.status(206);
res.setHeader('Content-Type', upstreamCT);
res.setHeader('Accept-Ranges', upstreamAcceptRanges || 'bytes');
const contentRange = resp.headers.get('content-range');
if (contentRange) res.setHeader('Content-Range', contentRange);
const cl = resp.headers.get('content-length'); if (cl) res.setHeader('Content-Length', cl);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
// Pipe first chunk then leave connection open? Simpler: end after chunk; player will request next range.
return resp.body.pipe(res);
}
// If server ignored range (e.g., returned 200), fall through to normal logic below.
}
const upstreamOptions = { headers: { 'User-Agent': DEFAULT_UA, ...headers } };
// If client supplied a suffix range like bytes=0- we pass it; force200 strips Range
if (force200 && headers.Range) delete headers.Range;
const resp = await fetch(targetUrl, upstreamOptions);
if (!resp.ok && resp.status !== 206) {
if (debug) console.log('[ts-proxy] upstream error', resp.status);
return res.status(resp.status).json({ error: `TS fetch failed: ${resp.status}` });
}
// Forward partial content status when range used
if (effectiveRange && resp.status === 206 && !force200) { res.status(206); if (debug) console.log('[ts-proxy] forwarding 206', { appliedClamp }); }
if (force200 && resp.status === 206) {
// Some origins still return 206 even without Range; normalize to 200 for players expecting full stream
if (debug) console.log('[ts-proxy] normalizing 206 -> 200 due to force200');
}
// Copy key streaming headers
const upstreamCT = inferContentType(resp.headers.get('content-type'), targetUrl);
res.setHeader('Content-Type', upstreamCT);
let cl = resp.headers.get('content-length');
const crHeader = resp.headers.get('content-range');
if (!cl && crHeader) {
// Try to derive length from range span
const m = crHeader.match(/bytes\s+(\d+)-(\d+)\/(\d+|\*)/i);
if (m) {
const start = parseInt(m[1], 10); const end = parseInt(m[2], 10);
if (!isNaN(start) && !isNaN(end)) cl = (end - start + 1).toString();
}
}
if (cl) res.setHeader('Content-Length', cl);
const acceptRanges = resp.headers.get('accept-ranges');
if (acceptRanges) res.setHeader('Accept-Ranges', acceptRanges); else res.setHeader('Accept-Ranges', 'bytes');
const contentRange = resp.headers.get('content-range'); if (contentRange && !force200) res.setHeader('Content-Range', contentRange);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
if (debug) console.log('[ts-proxy] streaming body', { status: resp.status, ct: upstreamCT, cl, contentRange: force200 ? undefined : contentRange });
resp.body.pipe(res);
} catch (e) { if (debug) console.log('[ts-proxy] exception', e.message); res.status(500).json({ error: e.message }); }
});
// subtitle proxy
app.get('/sub-proxy', cors(), async (req, res) => {
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'url parameter required' });
let headers = {}; try { headers = JSON.parse(req.query.headers || '{}'); } catch {
// Ignore URL parsing errors
}
try {
const resp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
if (!resp.ok) return res.status(resp.status).json({ error: `subtitle fetch failed: ${resp.status}` });
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Content-Type', resp.headers.get('content-type') || 'text/vtt');
resp.body.pipe(res);
} catch (e) { res.status(500).json({ error: e.message }); }
});
}
function processStreamsForProxy(streams, serverUrl) {
if (!Array.isArray(streams)) return streams;
return streams.map(s => {
if (!s || !s.url || typeof s.url !== 'string') return s;
const original = extractOriginalUrl(s.url);
const headers = s.headers || {};
const hParam = Object.keys(headers).length ? `&headers=${encodeURIComponent(JSON.stringify(headers))}` : '';
let host = '';
try {
host = new URL(original).hostname.toLowerCase();
} catch {
// Ignore errors
}
// Force specific hosts through ts-proxy (direct file style) even without extension
if (host.includes('pixeldrain.') || host === 'video-downloads.googleusercontent.com') {
return { ...s, url: `${serverUrl}/ts-proxy?url=${encodeURIComponent(original)}${hParam}` };
}
if (/\.(mp4|mkv)(\?|$)/i.test(original)) {
return { ...s, url: `${serverUrl}/ts-proxy?url=${encodeURIComponent(original)}${hParam}` };
}
return { ...s, url: `${serverUrl}/m3u8-proxy?url=${encodeURIComponent(original)}${hParam}` };
});
}
module.exports = { createProxyRoutes, processStreamsForProxy };
+1
View File
@@ -177,6 +177,7 @@
<label class="adv-flag-item"><input type="checkbox" name="adv_disableCache" value="1" /> <span>Disable Cache</span></label> <label class="adv-flag-item"><input type="checkbox" name="adv_disableCache" value="1" /> <span>Disable Cache</span></label>
<label class="adv-flag-item"><input type="checkbox" name="adv_disableUrlValidation" value="1" /> <span>Disable URL Validation</span></label> <label class="adv-flag-item"><input type="checkbox" name="adv_disableUrlValidation" value="1" /> <span>Disable URL Validation</span></label>
<label class="adv-flag-item"><input type="checkbox" name="adv_disable4khdhubValidation" value="1" /> <span>Disable 4KHDHub Validation</span></label> <label class="adv-flag-item"><input type="checkbox" name="adv_disable4khdhubValidation" value="1" /> <span>Disable 4KHDHub Validation</span></label>
<label class="adv-flag-item"><input type="checkbox" name="adv_enableProxy" value="1" /> <span>Enable Stream Proxy</span></label>
</div> </div>
<h1>Paths</h1> <h1>Paths</h1>
<p class="hint">Provider enable/disable & feature flags apply immediately. Click toggles to enable (highlighted) or disable (dim).</p> <p class="hint">Provider enable/disable & feature flags apply immediately. Click toggles to enable (highlighted) or disable (dim).</p>
+22 -12
View File
@@ -160,12 +160,12 @@ function fillForm(data){
tmdbBanner.style.display = (tmdbMissing && tmdbProvidersEnabled)? 'block':'none'; tmdbBanner.style.display = (tmdbMissing && tmdbProvidersEnabled)? 'block':'none';
} }
const advBools = [ const advBools = [
['enableXprimeProvider','adv_enableXprime'],
['enable4khdhubProvider','adv_enable4khdhub'], ['enable4khdhubProvider','adv_enable4khdhub'],
['enableMoviesmodProvider','adv_enableMoviesmod'], ['enableMoviesmodProvider','adv_enableMoviesmod'],
['enableMp4hydraProvider','adv_enableMp4hydra'], ['enableMp4hydraProvider','adv_enableMp4hydra'],
['enableVidzeeProvider','adv_enableVidzee'], ['enableVidzeeProvider','adv_enableVidzee'],
['enableVixsrcProvider','adv_enableVixsrc'], ['enableVixsrcProvider','adv_enableVixsrc'],
['enableProxy','adv_enableProxy'],
['disableCache','adv_disableCache'], ['disableCache','adv_disableCache'],
['enablePStreamApi','adv_enablePStream'], ['enablePStreamApi','adv_enablePStream'],
['disableUrlValidation','adv_disableUrlValidation'], ['disableUrlValidation','adv_disableUrlValidation'],
@@ -244,12 +244,12 @@ async function save(){
payload.febboxCookies = cookiesVal? cookiesVal.split(',').filter(Boolean) : []; payload.febboxCookies = cookiesVal? cookiesVal.split(',').filter(Boolean) : [];
} }
const boolMap = { const boolMap = {
adv_enableXprime:'enableXprimeProvider',
adv_enable4khdhub:'enable4khdhubProvider', adv_enable4khdhub:'enable4khdhubProvider',
adv_enableMoviesmod:'enableMoviesmodProvider', adv_enableMoviesmod:'enableMoviesmodProvider',
adv_enableMp4hydra:'enableMp4hydraProvider', adv_enableMp4hydra:'enableMp4hydraProvider',
adv_enableVidzee:'enableVidzeeProvider', adv_enableVidzee:'enableVidzeeProvider',
adv_enableVixsrc:'enableVixsrcProvider', adv_enableVixsrc:'enableVixsrcProvider',
adv_enableProxy:'enableProxy',
adv_disableCache:'disableCache', adv_disableCache:'disableCache',
adv_enablePStream:'enablePStreamApi', adv_enablePStream:'enablePStreamApi',
adv_disableUrlValidation:'disableUrlValidation', adv_disableUrlValidation:'disableUrlValidation',
@@ -337,12 +337,12 @@ async function clearAll(){
tmdbApiKey: null, tmdbApiKey: null,
// Provider enable flags default to true // Provider enable flags default to true
enableShowboxProvider: true, enableShowboxProvider: true,
enableXprimeProvider: true,
enable4khdhubProvider: true, enable4khdhubProvider: true,
enableMoviesmodProvider: true, enableMoviesmodProvider: true,
enableMp4hydraProvider: true, enableMp4hydraProvider: true,
enableVidzeeProvider: true, enableVidzeeProvider: true,
enableVixsrcProvider: true, enableVixsrcProvider: true,
enableProxy: false,
// Other defaults // Other defaults
disableCache: false, disableCache: false,
enablePStreamApi: true, enablePStreamApi: true,
@@ -596,15 +596,25 @@ async function renderProviderMatrix(merged){
if(!merged){ try { const data = await fetchConfig(); merged = data.merged||{}; } catch { return; } } if(!merged){ try { const data = await fetchConfig(); merged = data.merged||{}; } catch { return; } }
const container = document.getElementById('providerMatrix'); if(!container) return; const container = document.getElementById('providerMatrix'); if(!container) return;
const defaults = (merged.defaultProviders||[]).map(s=>s.toLowerCase()); const defaults = (merged.defaultProviders||[]).map(s=>s.toLowerCase());
const providers = [
['showbox','enableShowboxProvider'], // Fetch available providers from API
['4khdhub','enable4khdhubProvider'], let availableProviders = [];
['moviesmod','enableMoviesmodProvider'], try {
['mp4hydra','enableMp4hydraProvider'], const providersResponse = await fetch('/api/providers');
['vidzee','enableVidzeeProvider'], if (providersResponse.ok) {
['vixsrc','enableVixsrcProvider'], const providersData = await providersResponse.json();
['xprime','enableXprimeProvider'] availableProviders = providersData.providers || [];
]; }
} catch (e) {
console.warn('Failed to fetch providers list:', e.message);
}
// Create provider list with enable flags
const providers = availableProviders.map(p => {
const flag = `enable${p.name.charAt(0).toUpperCase() + p.name.slice(1)}Provider`;
return [p.name, flag];
});
container.innerHTML = providers.map(([name,flag])=>{ container.innerHTML = providers.map(([name,flag])=>{
const enabled = merged[flag] !== false; const enabled = merged[flag] !== false;
const isDefault = defaults.includes(name); const isDefault = defaults.includes(name);
+1 -1
View File
@@ -1 +1 @@
1758105049325 1758225404146
+46 -29
View File
@@ -52,6 +52,19 @@ function writeOverrideFile(obj) {
} }
} }
function getProviderNames() {
try {
const providersDir = path.join(process.cwd(), 'providers');
if (!fs.existsSync(providersDir)) return [];
return fs.readdirSync(providersDir)
.filter(file => file.endsWith('.js') && file !== 'registry.js' && file !== 'vidsrcextractor.js')
.map(file => path.parse(file).name.toLowerCase());
} catch (e) {
console.warn('[config] Failed to scan providers directory:', e.message);
return [];
}
}
function normalizeConfig(base) { function normalizeConfig(base) {
const cfg = { ...base }; const cfg = { ...base };
// Marker: if user explicitly provided an empty array for tmdbApiKeys in override file we should not resurrect legacy key // Marker: if user explicitly provided an empty array for tmdbApiKeys in override file we should not resurrect legacy key
@@ -80,22 +93,24 @@ function normalizeConfig(base) {
cfg.tmdbApiKeys = Array.from(new Set(cfg.tmdbApiKeys.map(k=>String(k).trim()).filter(Boolean))); cfg.tmdbApiKeys = Array.from(new Set(cfg.tmdbApiKeys.map(k=>String(k).trim()).filter(Boolean)));
// Do not expose legacy single-key field in merged config output // Do not expose legacy single-key field in merged config output
delete cfg.tmdbApiKey; delete cfg.tmdbApiKey;
// Booleans normalization
const boolKeys = [ // Dynamic provider enable flags
'enableShowboxProvider','enableXprimeProvider','enable4khdhubProvider','enableMoviesmodProvider','enableMp4hydraProvider','enableVidzeeProvider','enableVixsrcProvider', const providerNames = getProviderNames();
'disableCache','enablePStreamApi','disableUrlValidation','disable4khdhubUrlValidation' const boolKeys = providerNames.map(name => `enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`);
]; boolKeys.push('disableCache', 'enablePStreamApi', 'disableUrlValidation', 'disable4khdhubUrlValidation', 'enableProxy');
boolKeys.forEach(k=>{ if (cfg[k] === 'true') cfg[k] = true; else if (cfg[k] === 'false') cfg[k] = false; }); boolKeys.forEach(k=>{ if (cfg[k] === 'true') cfg[k] = true; else if (cfg[k] === 'false') cfg[k] = false; });
// Default values if undefined
if (cfg.enableShowboxProvider === undefined) cfg.enableShowboxProvider = true; // Set defaults for provider enable flags
if (cfg.enableXprimeProvider === undefined) cfg.enableXprimeProvider = true; providerNames.forEach(name => {
if (cfg.enable4khdhubProvider === undefined) cfg.enable4khdhubProvider = true; const flag = `enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`;
if (cfg.enableMoviesmodProvider === undefined) cfg.enableMoviesmodProvider = true; if (cfg[flag] === undefined) cfg[flag] = true; // Default to enabled
if (cfg.enableMp4hydraProvider === undefined) cfg.enableMp4hydraProvider = true; });
if (cfg.enableVidzeeProvider === undefined) cfg.enableVidzeeProvider = true;
if (cfg.enableVixsrcProvider === undefined) cfg.enableVixsrcProvider = true; // Default values for other flags
if (cfg.disableCache === undefined) cfg.disableCache = false; if (cfg.disableCache === undefined) cfg.disableCache = false;
if (cfg.enablePStreamApi === undefined) cfg.enablePStreamApi = true; if (cfg.enablePStreamApi === undefined) cfg.enablePStreamApi = true;
if (cfg.enableProxy === undefined) cfg.enableProxy = false; // default off
// Proxy features removed; always use direct connections // Proxy features removed; always use direct connections
if (cfg.disableUrlValidation === undefined) cfg.disableUrlValidation = false; if (cfg.disableUrlValidation === undefined) cfg.disableUrlValidation = false;
if (cfg.disable4khdhubUrlValidation === undefined) cfg.disable4khdhubUrlValidation = false; if (cfg.disable4khdhubUrlValidation === undefined) cfg.disable4khdhubUrlValidation = false;
@@ -114,26 +129,27 @@ function applyConfigToEnv(cfg){
if (cfg.febboxCookies && cfg.febboxCookies.length) process.env.FEBBOX_COOKIES = cfg.febboxCookies.join(','); if (cfg.febboxCookies && cfg.febboxCookies.length) process.env.FEBBOX_COOKIES = cfg.febboxCookies.join(',');
else delete process.env.FEBBOX_COOKIES; else delete process.env.FEBBOX_COOKIES;
if (cfg.defaultRegion) process.env.DEFAULT_REGION = cfg.defaultRegion; else delete process.env.DEFAULT_REGION; if (cfg.defaultRegion) process.env.DEFAULT_REGION = cfg.defaultRegion; else delete process.env.DEFAULT_REGION;
// Provider enable flags
process.env.ENABLE_SHOWBOX_PROVIDER = cfg.enableShowboxProvider ? 'true':'false'; // Dynamic provider enable flags
process.env.ENABLE_XPRIME_PROVIDER = cfg.enableXprimeProvider ? 'true':'false'; const providerNames = getProviderNames();
process.env.ENABLE_4KHDHUB_PROVIDER = cfg.enable4khdhubProvider ? 'true':'false'; providerNames.forEach(name => {
process.env.ENABLE_MOVIESMOD_PROVIDER = cfg.enableMoviesmodProvider ? 'true':'false'; const flag = `enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`;
process.env.ENABLE_MP4HYDRA_PROVIDER = cfg.enableMp4hydraProvider ? 'true':'false'; const envName = `ENABLE_${name.toUpperCase()}_PROVIDER`;
process.env.ENABLE_VIDZEE_PROVIDER = cfg.enableVidzeeProvider ? 'true':'false'; process.env[envName] = cfg[flag] ? 'true' : 'false';
process.env.ENABLE_VIXSRC_PROVIDER = cfg.enableVixsrcProvider ? 'true':'false'; });
// Caching / validation / PStream // Caching / validation / PStream
process.env.DISABLE_CACHE = cfg.disableCache ? 'true':'false'; process.env.DISABLE_CACHE = cfg.disableCache ? 'true':'false';
process.env.ENABLE_PSTREAM_API = cfg.enablePStreamApi ? 'true':'false'; process.env.ENABLE_PSTREAM_API = cfg.enablePStreamApi ? 'true':'false';
process.env.DISABLE_URL_VALIDATION = cfg.disableUrlValidation ? 'true':'false'; process.env.DISABLE_URL_VALIDATION = cfg.disableUrlValidation ? 'true':'false';
process.env.DISABLE_4KHDHUB_URL_VALIDATION = cfg.disable4khdhubUrlValidation ? 'true':'false'; process.env.DISABLE_4KHDHUB_URL_VALIDATION = cfg.disable4khdhubUrlValidation ? 'true':'false';
process.env.ENABLE_PROXY = cfg.enableProxy ? 'true':'false';
// Showbox specific // Showbox specific
if (cfg.showboxCacheDir) process.env.SHOWBOX_CACHE_DIR = cfg.showboxCacheDir; else delete process.env.SHOWBOX_CACHE_DIR; if (cfg.showboxCacheDir) process.env.SHOWBOX_CACHE_DIR = cfg.showboxCacheDir; else delete process.env.SHOWBOX_CACHE_DIR;
// Proxy settings removed; ensure legacy envs are cleared // Proxy settings removed; ensure legacy envs are cleared
delete process.env.SHOWBOX_USE_ROTATING_PROXY; delete process.env.SHOWBOX_USE_ROTATING_PROXY;
delete process.env.SHOWBOX_PROXY_URL_VALUE; delete process.env.SHOWBOX_PROXY_URL_VALUE;
delete process.env.SHOWBOX_PROXY_URL_ALTERNATE; delete process.env.SHOWBOX_PROXY_URL_ALTERNATE;
delete process.env.XPRIME_PROXY_URL;
delete process.env.VIDZEE_PROXY_URL; delete process.env.VIDZEE_PROXY_URL;
delete process.env.VIDSRC_PROXY_URL; delete process.env.VIDSRC_PROXY_URL;
delete process.env.MOVIESMOD_PROXY_URL; delete process.env.MOVIESMOD_PROXY_URL;
@@ -152,13 +168,6 @@ function loadConfig() {
tmdbApiKeys: parseJsonMaybe(process.env.TMDB_API_KEYS) || null, tmdbApiKeys: parseJsonMaybe(process.env.TMDB_API_KEYS) || null,
febboxCookies: parseCookies(process.env.FEBBOX_COOKIES), febboxCookies: parseCookies(process.env.FEBBOX_COOKIES),
// Extended advanced config (may not exist in env) // Extended advanced config (may not exist in env)
enableShowboxProvider: process.env.ENABLE_SHOWBOX_PROVIDER,
enableXprimeProvider: process.env.ENABLE_XPRIME_PROVIDER,
enable4khdhubProvider: process.env.ENABLE_4KHDHUB_PROVIDER,
enableMoviesmodProvider: process.env.ENABLE_MOVIESMOD_PROVIDER,
enableMp4hydraProvider: process.env.ENABLE_MP4HYDRA_PROVIDER,
enableVidzeeProvider: process.env.ENABLE_VIDZEE_PROVIDER,
enableVixsrcProvider: process.env.ENABLE_VIXSRC_PROVIDER,
disableCache: process.env.DISABLE_CACHE, disableCache: process.env.DISABLE_CACHE,
enablePStreamApi: process.env.ENABLE_PSTREAM_API, enablePStreamApi: process.env.ENABLE_PSTREAM_API,
showboxCacheDir: process.env.SHOWBOX_CACHE_DIR || null, showboxCacheDir: process.env.SHOWBOX_CACHE_DIR || null,
@@ -166,6 +175,14 @@ function loadConfig() {
disableUrlValidation: process.env.DISABLE_URL_VALIDATION, disableUrlValidation: process.env.DISABLE_URL_VALIDATION,
disable4khdhubUrlValidation: process.env.DISABLE_4KHDHUB_URL_VALIDATION disable4khdhubUrlValidation: process.env.DISABLE_4KHDHUB_URL_VALIDATION
}; };
// Dynamic provider enable flags from env
const providerNames = getProviderNames();
providerNames.forEach(name => {
const envName = `ENABLE_${name.toUpperCase()}_PROVIDER`;
envCfg[`enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`] = process.env[envName];
});
const override = readOverrideFile(); const override = readOverrideFile();
const merged = { ...envCfg, ...override }; const merged = { ...envCfg, ...override };
const normalized = normalizeConfig(merged); const normalized = normalizeConfig(merged);
+117
View File
@@ -0,0 +1,117 @@
class JsUnpacker {
static Unbase = class {
constructor(radix) {
this.ALPHABET_62 =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
this.ALPHABET_95 =
' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
this.alphabet = null;
this.dictionary = {};
this.radix = radix;
if (radix > 36) {
if (radix < 62) {
this.alphabet = this.ALPHABET_62.substring(0, radix);
} else if (radix > 62 && radix < 95) {
this.alphabet = this.ALPHABET_95.substring(0, radix);
} else if (radix === 62) {
this.alphabet = this.ALPHABET_62;
} else if (radix === 95) {
this.alphabet = this.ALPHABET_95;
}
for (let i = 0; i < this.alphabet.length; i++) {
this.dictionary[this.alphabet[i]] = i;
}
}
}
unbase(str) {
let ret = 0;
if (this.alphabet === null) {
ret = parseInt(str, this.radix);
} else {
const tmp = str.split('').reverse().join('');
for (let i = 0; i < tmp.length; i++) {
ret += Math.pow(this.radix, i) * this.dictionary[tmp[i]];
}
}
return ret;
}
};
constructor(packedJS) {
this.packedJS = packedJS;
}
detect() {
const js = this.packedJS.replace(/\s/g, '');
const regex = /eval\(function\(p,a,c,k,e,(?:r|d)/;
return regex.test(js);
}
unpack() {
let js = this.packedJS;
try {
const regex =
/}\s*\('(.*)',\s*(.*?),\s*(\d+),\s*'(.*?)'\.split\('\|'\)/s;
const match = js.match(regex);
if (match && match.length === 5) {
let payload = match[1].replace(/\\'/g, "'");
const radixStr = match[2];
const countStr = match[3];
const symtab = match[4].split('|');
let radix = 36;
let count = 0;
try {
radix = parseInt(radixStr);
} catch {
// Ignore URL parsing errors
}
try {
count = parseInt(countStr);
} catch {
// Ignore URL parsing errors
}
if (symtab.length !== count) {
throw new Error('Unknown p.a.c.k.e.r. encoding');
}
const unbase = new this.constructor.Unbase(radix);
const wordRegex = /\b\w+\b/g;
let decoded = payload;
let replaceOffset = 0;
let wordMatch;
while ((wordMatch = wordRegex.exec(payload)) !== null) {
const word = wordMatch[0];
const x = unbase.unbase(word);
let value = null;
if (x < symtab.length) {
value = symtab[x];
}
if (value && value.length > 0) {
const start = wordMatch.index + replaceOffset;
const end = start + word.length;
decoded =
decoded.substring(0, start) +
value +
decoded.substring(end);
replaceOffset += value.length - word.length;
}
}
return decoded;
}
} catch (e) {
return null;
}
return null;
}
}
// CommonJS exports
module.exports = JsUnpacker;
+1 -1
View File
@@ -4,12 +4,12 @@
"tmdbApiKeys": [], "tmdbApiKeys": [],
"enableShowboxProvider": true, "enableShowboxProvider": true,
"febboxCookies": [], "febboxCookies": [],
"enableXprimeProvider": true,
"enable4khdhubProvider": true, "enable4khdhubProvider": true,
"enableMoviesmodProvider": true, "enableMoviesmodProvider": true,
"enableMp4hydraProvider": true, "enableMp4hydraProvider": true,
"enableVidzeeProvider": true, "enableVidzeeProvider": true,
"enableVixsrcProvider": true, "enableVixsrcProvider": true,
"enableProxy": false,
"disableCache": false, "disableCache": false,
"enablePStreamApi": true, "enablePStreamApi": true,
"disableUrlValidation": false, "disableUrlValidation": false,