mirror of
https://github.com/Inside4ndroid/TMDB-Embed-API.git
synced 2026-10-22 17:45:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aacac098f2 | ||
|
|
2c9a2cabbf | ||
|
|
01f06f807e | ||
|
|
74bf677f83 | ||
|
|
d03b047e89 |
@@ -0,0 +1,5 @@
|
|||||||
|
TMDB_API_KEY=your_tmdb_api_key
|
||||||
|
# Optional: JSON array for multi-key rotation
|
||||||
|
# TMDB_API_KEYS=["key1","key2"]
|
||||||
|
# Optional: server port (defaults to 8787)
|
||||||
|
# API_PORT=8787
|
||||||
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
restart.trigger
|
restart.trigger
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.env
|
||||||
|
/tmp_context
|
||||||
+88
-39
@@ -2,6 +2,43 @@
|
|||||||
|
|
||||||
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.3.0] - 2026-08-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **7 new providers ported from the Infinite-streams v5.0.0 Stremio addon** (CommonJS ports, registered in `providers/registry.js`):
|
||||||
|
- `streamflix` - StreamFlix direct MP4 links (30-min `data.json` cache + Firebase episode lookup).
|
||||||
|
- `vaplayer` - VaPlayer HLS streams resolved by IMDb ID.
|
||||||
|
- `castletv` - CastleTV streams via AES-128-CBC encrypted API (`api.hlowb.com`), per-track + shared fallback, subtitle support.
|
||||||
|
- `hdghartv` - HDGharTV title search with fuzzy match + IMDb ID verification, quality-sorted links.
|
||||||
|
- `netmirror` - Netflix direct (embed-tmdb) plus NewTV platform fallback (Netflix/Prime Video/Hotstar/Disney) with rotating discovery domains.
|
||||||
|
- `onetouchtv` - OneTouchTV streams via AES-256-CBC custom-base64 API (title/season matching + IMDb verification + absolute-episode resolution).
|
||||||
|
- `zxcstreams` - ZXCStreams multi-server backend (icarus/berkas/orion/athena) with dynamic domain discovery and shared-token flow.
|
||||||
|
- **New shared utilities**: `utils/titleMatch.js` (fuzzy title matching port), `utils/tmdbTitleToImdb.js` (TMDB title→IMDb resolution with cache), `utils/cinemetaEpisodes.js` (episode counts per season).
|
||||||
|
- **Configurable provider-check TMDB ID**: the title used by dashboard functional checks is now editable in the Server Status panel (default `278`, env `PROVIDER_CHECK_TMDB_ID`). Persisted via `POST /api/config`, reset via Clear All, and exposed through `/api/status` as `providerCheckTmdbId`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Vidlink provider**: Rewritten for the new API format — parses `stream.qualities` into one stream per quality (best-first), no longer relies on the removed HLS proxy format. Requires a working CDN (some CDNs 429 server-side requests from blocked IPs; browsers play direct URLs fine).
|
||||||
|
- **`utils/tmdb.js`**: Now reads the TMDB API key via `utils/tmdbKey` (normalized `tmdbApiKeys`) instead of the deleted legacy `config.tmdbApiKey` field, so `getDetails`/`resolveImdbId` work with the current config schema.
|
||||||
|
- **castletv quality parsing**: Quality is now parsed from `resolutionDescription` (SD 480P / HD 720P / FHD 1080P); streams are deduped per URL keeping the highest resolution (eliminates the "2024p" year-match bug).
|
||||||
|
- **docker-compose.yml**: Fixed broken YAML indentation on the `BIND_HOST` environment entry that prevented `docker compose up` from parsing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Version bumped to **1.3.0** across `package.json` and `package-lock.json`.
|
||||||
|
- `.env.example` refreshed to the current schema (`API_PORT` instead of legacy `PORT`, placeholder TMDB key, documented multi-key JSON array).
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **README fully rewritten** to match the current project state: 13-provider list, accurate authentication docs (`utils/auth-users.json`, default `admin` / `change-me`), corrected environment variable table, complete endpoint + proxy route reference, plugin example mirroring the real `providerFunctionMap`, and a new "Server Status & Functional Checks" section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-08-19
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **LordFlix provider**: Permanently removed — upstream API `snowhouse.lordflix.club` (and `lordflix.org`) no longer resolves in DNS. `enc-dec.app` is a passthrough signer and returns signed URLs for the dead host; no compatible successor API exists (`lordflix.gd` / `lordflix.app` are unrelated clones using TMDB-ID + iframe embeds, not the snowhouse API).
|
||||||
|
- **NoTorrent provider**: Permanently removed — taken down by its developer; the Stremio addon API bridge (`addon-osvh.onrender.com`) is no longer available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.1.1] - 2026-05-29
|
## [1.1.1] - 2026-05-29
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
@@ -16,7 +53,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [1.1.0] - 2026-05-16
|
## [1.1.0] - 2026-05-16
|
||||||
|
|
||||||
## Dependency Security
|
### Dependency Security
|
||||||
- **npm vulnerabilities fixed**: Upgraded `cheerio` (1.0.0 → 1.2.0) and `axios-cookiejar-support` (6.0.2 → 7.0.0) to eliminate 14 vulnerabilities (including `undici <=6.23.0` high/critical CVEs). Result: 0 vulnerabilities.
|
- **npm vulnerabilities fixed**: Upgraded `cheerio` (1.0.0 → 1.2.0) and `axios-cookiejar-support` (6.0.2 → 7.0.0) to eliminate 14 vulnerabilities (including `undici <=6.23.0` high/critical CVEs). Result: 0 vulnerabilities.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -60,6 +97,8 @@ All notable changes to this project will be documented in this file.
|
|||||||
- **uhdmovies provider**: Fixed domain to use `uhdmovies.rip` instead of outdated `uhdmovies.mov`. Added automatic domain replacement for stale scraped URLs. Created new `utils/linkResolver.js` utility to handle driveseed/driveleech download button extraction (supports Instant Download, Resume Cloud, Resume Worker Bot, Direct Links CF Type 1).
|
- **uhdmovies provider**: Fixed domain to use `uhdmovies.rip` instead of outdated `uhdmovies.mov`. Added automatic domain replacement for stale scraped URLs. Created new `utils/linkResolver.js` utility to handle driveseed/driveleech download button extraction (supports Instant Download, Resume Cloud, Resume Worker Bot, Direct Links CF Type 1).
|
||||||
- **Provider registry**: Fixed `listProviders()` to return all available providers with their enabled status, not just enabled ones. Config panel now shows all 6 providers correctly.
|
- **Provider registry**: Fixed `listProviders()` to return all available providers with their enabled status, not just enabled ones. Config panel now shows all 6 providers correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.8] - 2025-10-03
|
## [1.0.8] - 2025-10-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -78,6 +117,8 @@ All notable changes to this project will be documented in this file.
|
|||||||
### Fixed
|
### 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.
|
- 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
|
## [1.0.7] - 2025-09-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -93,54 +134,52 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Eliminated edge cases where encoded VidZee tokens were leaking through without decoding.
|
- 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).
|
- 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
|
### Notes
|
||||||
- Future proxy tuning items (size meta map, dynamic progressive growth) tracked but not part of this release.
|
- Future proxy tuning items (size meta map, dynamic progressive growth) tracked but not part of this release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.6] - 2025-09-20
|
## [1.0.6] - 2025-09-20
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- MoviesClub provider (multi-server scraping complexities & Turnstile challenge; deprecated permanently)
|
- MoviesClub provider (multi-server scraping complexities & Turnstile challenge; deprecated permanently)
|
||||||
- Xprime provider (upstream Xprime.tv offline due to security changes)
|
- 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)
|
- 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
|
### Added
|
||||||
- Structured multi-server debug instrumentation (session summaries, per-fetch metrics, pattern counters)
|
- Structured multi-server debug instrumentation (session summaries, per-fetch metrics, pattern counters)
|
||||||
- Turnstile challenge detection & bypass attempt scaffold (synthetic `/rcp_verify` token posting)
|
- 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.
|
- 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:
|
- Proxy range management features:
|
||||||
- `clampOpen` (default on) – caps ambiguous open‑ended `bytes=0-` requests to a bounded initial window (`openChunkKB`, default 4096 KB)
|
- `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
|
- `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
|
- `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
|
- `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
|
- `force200` (opt-in) – normalizes upstream 206 responses to 200 for diagnostics
|
||||||
- `noSynth` (opt‑in) – disables synthetic initial partial response generation
|
- `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
|
- 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)
|
- Host routing overrides: `pixeldrain.*` & `video-downloads.googleusercontent.com` are forced through `/ts-proxy` (extensionless or ambiguous content)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Centralized multi-server request headers with realistic `sec-ch-ua*` & `Sec-Fetch-*` values
|
- 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
|
- Added retry, rotating User-Agent, and cookie jar logic to multi-server fetch pipeline
|
||||||
- Showbox provider priority map updated after Xprime removal
|
- Showbox provider priority map updated after Xprime removal
|
||||||
- README/Docs trimmed to reflect current active providers only
|
- 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).
|
- 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)
|
- 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
|
- 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)
|
- Synthetic initial partial response is automatically suppressed when `progressiveOpen` is active (real range growth preferred)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Ensured multi-server fallback attempts (direct rcp player/m3u8 extraction) operate with improved diagnostics
|
- 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)
|
- Eliminated repeated VLC tail probe stalls caused by archive masquerading as video content (root cause was filtered by dropping `.zip` URLs)
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
- Updated README version badge to 1.0.6 and provider list (removed MoviesClub & Xprime, clarified active providers list)
|
- 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
|
- 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
|
- Expanded explanation that per-stream headers are stripped when proxying is enabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.5] - 2025-09-19
|
## [1.0.5] - 2025-09-19
|
||||||
|
|
||||||
@@ -154,17 +193,18 @@ All notable changes to this project will be documented in this file.
|
|||||||
### Notes
|
### Notes
|
||||||
- `r2.dev` links are always removed from final output; no env flag required.
|
- `r2.dev` links are always removed from final output; no env flag required.
|
||||||
|
|
||||||
### Documentation
|
---
|
||||||
|
|
||||||
## [1.0.4] - 2025-09-18
|
## [1.0.4] - 2025-09-18
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Unified stream object schema standardized:
|
||||||
```json
|
```json
|
||||||
{ "title": "…", "url": "…", "quality": "…", "provider": "…", "headers": { } }
|
{ "title": "…", "url": "…", "quality": "…", "provider": "…", "headers": { } }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.3] - 2025-09-17
|
## [1.0.3] - 2025-09-17
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -173,6 +213,8 @@ All notable changes to this project will be documented in this file.
|
|||||||
### Notes
|
### Notes
|
||||||
- If you were seeing `ERR_CONNECTION_REFUSED` on `http://localhost:8787`, pull the latest image or rebuild, then re-run with `-p 8787:8787`.
|
- If you were seeing `ERR_CONNECTION_REFUSED` on `http://localhost:8787`, pull the latest image or rebuild, then re-run with `-p 8787:8787`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.2] - 2025-09-17
|
## [1.0.2] - 2025-09-17
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -181,6 +223,8 @@ All notable changes to this project will be documented in this file.
|
|||||||
### Documentation
|
### Documentation
|
||||||
- Updated README version badge to 1.0.2 and verified Docker commands use `-p 8787:8787`.
|
- Updated README version badge to 1.0.2 and verified Docker commands use `-p 8787:8787`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.0.1] - 2025-09-17
|
## [1.0.1] - 2025-09-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -205,24 +249,15 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Provider matrix re-renders immediately after Clear All (no page refresh needed).
|
- Provider matrix re-renders immediately after Clear All (no page refresh needed).
|
||||||
- Handling of Exclude Codecs "ALL" previously not persisting correctly.
|
- Handling of Exclude Codecs "ALL" previously not persisting correctly.
|
||||||
|
|
||||||
## [1.0.0] - 2025-09-16
|
---
|
||||||
- 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.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.1]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.0...v1.0.1
|
|
||||||
|
|
||||||
## [1.0.0] - 2025-09-16
|
## [1.0.0] - 2025-09-16
|
||||||
|
Initial stable release.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Comprehensive `README.md` (features, endpoints, admin UI overview, screenshots gallery, Docker usage, troubleshooting).
|
- Comprehensive `README.md` (features, endpoints, admin UI overview, screenshots gallery, Docker usage, troubleshooting).
|
||||||
- `LICENSE` (MIT) file.
|
- `LICENSE` (MIT) file.
|
||||||
- Multi‑TMDB key rotation support (array of keys; random selection per request).
|
- Multi-TMDB key rotation support (array of keys; random selection per request).
|
||||||
- Config override system writing to `utils/user-config.json` with live merged view.
|
- Config override system writing to `utils/user-config.json` with live merged view.
|
||||||
- Session-based authentication (login, logout, session check, password change) with brute-force mitigation.
|
- Session-based authentication (login, logout, session check, password change) with brute-force mitigation.
|
||||||
- Rate limiting + exponential lockouts for failed login attempts.
|
- Rate limiting + exponential lockouts for failed login attempts.
|
||||||
@@ -259,9 +294,23 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Heartbeat diagnostic interval to aid investigation of unexpected exits.
|
- Heartbeat diagnostic interval to aid investigation of unexpected exits.
|
||||||
- Intercepted premature `process.exit` calls to avoid silent shutdowns during debugging.
|
- Intercepted premature `process.exit` calls to avoid silent shutdowns during debugging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Historical Context
|
## Historical Context
|
||||||
This 1.0.0 release consolidates modernization work: provider cleanup, configuration clarity, deployment ergonomics (Docker + CI), security hardening, and observability.
|
This 1.0.0 release consolidates modernization work: provider cleanup, configuration clarity, deployment ergonomics (Docker + CI), security hardening, and observability.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[1.0.0]: https://github.com/Inside4ndroid/TMDB-Embed-API/releases/v1.0.0
|
[1.3.0]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.2.0...v1.3.0
|
||||||
|
[1.2.0]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.1.1...v1.2.0
|
||||||
|
[1.1.1]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.1.0...v1.1.1
|
||||||
|
[1.1.0]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.9...v1.1.0
|
||||||
|
[1.0.9]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.8...v1.0.9
|
||||||
|
[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.4]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.3...v1.0.4
|
||||||
|
[1.0.3]: https://github.com/Inside4ndroid/TMDB-Embed-API/compare/v1.0.2...v1.0.3
|
||||||
|
[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
|
||||||
@@ -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.1.1-informational?style=flat" />
|
<img src="https://img.shields.io/badge/Version-1.3.0-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>
|
||||||
|
|
||||||
@@ -33,30 +33,29 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
- **Multi‑TMDB Key Rotation** – Supply multiple API keys; one is chosen randomly per request.
|
- **13 Built-in Providers** – Showbox/FebBox, 4KHDHub, VixSrc, Videasy, Vidlink, DahmerMovies, StreamFlix, VaPlayer, CastleTV, HDGharTV, NetMirror, OneTouchTV, ZXCStreams — with per-provider enable toggles + default selection.
|
||||||
- **Provider Aggregation** – Pluggable providers (Showbox/FebBox, 4KHDHub, VixSrc, Videasy, Vidlink, LordFlix, NoTorrent, DahmerMovies) with per‑provider enable toggles + default selection.
|
- **Multi-TMDB Key Rotation** – Supply multiple API keys; one is chosen randomly per request.
|
||||||
- **🔥 Plugin System** – Drop new provider files in `providers/` and add its exported function to the registry map (`providers/registry.js` → `providerFunctionMap`).
|
- **🔥 Plugin System** – Drop new provider files in `providers/` and add its exported function to the registry map (`providers/registry.js` → `providerFunctionMap`). No core file edits required.
|
||||||
- **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 `/config.html` (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, and password change.
|
||||||
- **Status & Health Panel** – Live metrics, provider status, endpoint list, functional provider checks.
|
- **Status & Health Panel** – Live metrics, provider status, endpoint list, and per-provider functional checks (configurable check title via TMDB ID).
|
||||||
- **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 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.
|
- **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.
|
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):
|
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.
|
- `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.
|
- `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.
|
- `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.
|
- `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).
|
- `noSynth=1` – Disable synthetic initial partial generation (forces pass-through behavior).
|
||||||
- `force200=1` – Normalize upstream 206 responses to 200 (diagnostics / edge player testing).
|
- `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.
|
- `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.
|
- `tailPrefetchKB=256` – Tail window size (64–2048 KB). Cached in memory with TTL cleanup.
|
||||||
Behavior Notes:
|
Behavior Notes:
|
||||||
- Synthetic partials auto‑disable when `progressiveOpen` is active (real progressive ranges preferred).
|
- 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.
|
- 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.
|
- 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.
|
- Host Overrides: `pixeldrain.*` and `video-downloads.googleusercontent.com` URLs are routed through `/ts-proxy` regardless of extension to ensure correct range + MIME handling.
|
||||||
@@ -84,6 +83,8 @@ http://localhost:8787/
|
|||||||
curl http://localhost:8787/api/health
|
curl http://localhost:8787/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Default credentials (first run):** `admin` / `change-me` — **change them immediately** from the dashboard or via `POST /auth/change-password`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐳 Docker Usage
|
## 🐳 Docker Usage
|
||||||
@@ -92,7 +93,7 @@ curl http://localhost:8787/api/health
|
|||||||
If you just want to run it (no building):
|
If you just want to run it (no building):
|
||||||
```bash
|
```bash
|
||||||
docker pull inside4ndroid/tmdb-embed-api:latest
|
docker pull inside4ndroid/tmdb-embed-api:latest
|
||||||
docker run --name tmdb-embed-api -p 8787:8787 \
|
docker run --name tmdb-embed-api -p 8787:8787 \
|
||||||
-e TMDB_API_KEY=YOUR_TMDB_KEY \
|
-e TMDB_API_KEY=YOUR_TMDB_KEY \
|
||||||
inside4ndroid/tmdb-embed-api:latest
|
inside4ndroid/tmdb-embed-api:latest
|
||||||
```
|
```
|
||||||
@@ -123,7 +124,7 @@ docker run --name tmdb-embed -p 8787:8787 \
|
|||||||
After first login + save, the UI writes overrides into the mounted `user-config.json` so they persist across container restarts.
|
After first login + save, the UI writes overrides into the mounted `user-config.json` so they persist across container restarts.
|
||||||
|
|
||||||
### docker-compose
|
### docker-compose
|
||||||
An example `docker-compose.yml` is included. Start with:
|
An example `docker-compose.yml` is included (healthcheck + `restart: unless-stopped`). Start with:
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
@@ -146,38 +147,52 @@ docker run -p 8787:8787 \
|
|||||||
```
|
```
|
||||||
or add / remove keys inside the Admin UI (Keys panel) and save.
|
or add / remove keys inside the Admin UI (Keys panel) and save.
|
||||||
|
|
||||||
|
If both `TMDB_API_KEY` and `TMDB_API_KEYS` are provided, rotation uses the array. Clearing the array in the UI also clears the legacy key.
|
||||||
|
|
||||||
### Key Environment Variables
|
### Key Environment Variables
|
||||||
| Variable | Purpose | Notes |
|
| Variable | Purpose | Notes |
|
||||||
|----------|---------|-------|
|
|----------|---------|-------|
|
||||||
| `API_PORT` | Port the server listens on | Defaults to 8787 |
|
| `API_PORT` | Port the server listens on | Defaults to `8787` |
|
||||||
|
| `BIND_HOST` | Interface to bind | Defaults to `0.0.0.0` |
|
||||||
| `TMDB_API_KEY` | Single TMDB key (legacy) | Use if you only have one key |
|
| `TMDB_API_KEY` | Single TMDB key (legacy) | Use if you only have one key |
|
||||||
| `TMDB_API_KEYS` | JSON array of keys | Overrides single key when present |
|
| `TMDB_API_KEYS` | JSON array of keys | Overrides single key when present |
|
||||||
| `PASSWORD_HASH` | Pre-seed admin password hash | Optional (UI can set) |
|
| `DEFAULT_PROVIDERS` | Comma-separated default providers | Used by the aggregate endpoint |
|
||||||
| `ADMIN_USERNAME` | Override default username | Default: `admin` |
|
| `DEFAULT_REGION` / `FEBBOX_REGION` | Default region | Passed through for legacy compat |
|
||||||
|
| `MIN_QUALITIES` | Min quality setting / JSON | Mirrored to the config model |
|
||||||
If both `TMDB_API_KEY` and `TMDB_API_KEYS` are provided, rotation uses the array. Clearing the array in the UI also clears the legacy key.
|
| `EXCLUDE_CODECS` | Codec exclusion JSON | e.g. `{"excludeDV":true}` |
|
||||||
|
| `FEBBOX_COOKIES` | FebBox JWT cookie(s) | Comma-separated; required for Showbox |
|
||||||
---
|
| `ENABLE_<PROVIDER>_PROVIDER` | Per-provider enable flags | e.g. `ENABLE_4KHDHUB_PROVIDER=true` |
|
||||||
|
| `DISABLE_CACHE` | Disable internal caches | `true`/`false` |
|
||||||
|
| `ENABLE_PSTREAM_API` | PStream API flag | Default `true` |
|
||||||
|
| `DISABLE_URL_VALIDATION` | Skip general URL checks | Default `false` |
|
||||||
|
| `DISABLE_4KHDHUB_URL_VALIDATION` | Skip 4khdhub URL checks | Default `false` |
|
||||||
|
| `ENABLE_PROXY` | Mount proxy routes | Default `false` |
|
||||||
|
| `PROVIDER_CHECK_TMDB_ID` | Title for dashboard functional checks | Default `278` |
|
||||||
|
| `SHOWBOX_CACHE_DIR` | Custom Showbox cache directory | Optional |
|
||||||
|
| `NETMIRROR_API_BASE` | NetMirror embed base URL | Optional override |
|
||||||
|
| `NETMIRROR_STREAM_REFERER` | NetMirror stream Referer header | Optional override |
|
||||||
|
|
||||||
### Updating the Image
|
### Updating the Image
|
||||||
```bash
|
```bash
|
||||||
docker compose pull # if using an external registry (future)
|
docker compose pull # if using an external registry (future)
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
### Restart from Admin UI
|
### Restart from Admin UI
|
||||||
The Admin panel includes a Restart Server control.
|
The Admin panel includes a Restart Server control.
|
||||||
- Local (nodemon): the backend writes a `restart.trigger` file and exits; nodemon detects the change and restarts automatically.
|
- Local (nodemon): the backend writes a `restart.trigger` file and exits; nodemon detects the change and restarts automatically.
|
||||||
- Docker Compose: the container exits and is restarted by `restart: unless-stopped`.
|
- Docker Compose: the container exits and is restarted by `restart: unless-stopped`.
|
||||||
```
|
|
||||||
|
|
||||||
### Healthcheck
|
### Healthcheck
|
||||||
Container health relies on `GET /api/health`. If you disable or modify that route, adjust the Dockerfile / compose healthcheck accordingly.
|
Container health relies on `GET /api/health`. If you disable or modify that route, adjust the Dockerfile / compose healthcheck accordingly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## �🔐 Authentication
|
## 🔐 Authentication
|
||||||
The root (`/`) serves the login page. After successful login a session cookie (`session`) is issued (HttpOnly; 12h lifetime). All admin pages (e.g. `config.html`) require an active session.
|
The root (`/`) serves the login page. After successful login a session cookie (`session`) is issued (HttpOnly; 12h lifetime). All admin pages (e.g. `config.html`) require an active session.
|
||||||
|
|
||||||
|
Credentials are stored in `utils/auth-users.json` (auto-created on first run with `admin` / `change-me`). Passwords are hashed with PBKDF2-SHA512 (100,000 iterations, random salt). There is no `PASSWORD_HASH` / `ADMIN_USERNAME` environment override — manage users directly in this file or via the change-password endpoint.
|
||||||
|
|
||||||
| Endpoint | Method | Purpose |
|
| Endpoint | Method | Purpose |
|
||||||
|----------|--------|---------|
|
|----------|--------|---------|
|
||||||
| `/auth/login` | POST | Authenticate (JSON: `{ username, password }`) |
|
| `/auth/login` | POST | Authenticate (JSON: `{ username, password }`) |
|
||||||
@@ -185,7 +200,7 @@ The root (`/`) serves the login page. After successful login a session cookie (`
|
|||||||
| `/auth/session` | GET | Check session status |
|
| `/auth/session` | GET | Check session status |
|
||||||
| `/auth/change-password` | POST | Update password (requires session) |
|
| `/auth/change-password` | POST | Update password (requires session) |
|
||||||
|
|
||||||
Repeated failed logins trigger exponential lockouts (Retry-After header emitted).
|
Repeated failed logins trigger escalating lockouts (`Retry-After` header emitted; 5 attempts per window).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -201,7 +216,8 @@ Saving in the UI writes only changed keys. Setting a field to empty removes the
|
|||||||
{
|
{
|
||||||
"defaultProviders": ["4khdhub"],
|
"defaultProviders": ["4khdhub"],
|
||||||
"tmdbApiKeys": ["KEY_A","KEY_B"],
|
"tmdbApiKeys": ["KEY_A","KEY_B"],
|
||||||
"enable4khdhubProvider": true
|
"enable4khdhubProvider": true,
|
||||||
|
"providerCheckTmdbId": "278"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -229,11 +245,17 @@ The API supports a plugin system. Drop a new provider file in the `providers/` f
|
|||||||
- `4khdhub` - 4KHDHub streams
|
- `4khdhub` - 4KHDHub streams
|
||||||
- `vixsrc` - VixSrc streams
|
- `vixsrc` - VixSrc streams
|
||||||
- `videasy` - Videasy streams (10 servers via enc-dec.app)
|
- `videasy` - Videasy streams (10 servers via enc-dec.app)
|
||||||
- `vidlink` - Vidlink streams (via enc-dec.app)
|
- `vidlink` - Vidlink streams
|
||||||
- `lordflix` - LordFlix streams (9 servers via enc-dec.app)
|
|
||||||
- `notorrent` - NoTorrent streams (Stremio addon API)
|
|
||||||
- `dahmermovies` - DahmerMovies streams (direct file links)
|
- `dahmermovies` - DahmerMovies streams (direct file links)
|
||||||
|
- `streamflix` - StreamFlix streams (direct MP4 links)
|
||||||
|
- `vaplayer` - VaPlayer streams (HLS via IMDb ID)
|
||||||
|
- `castletv` - CastleTV streams (AES-128 encrypted API)
|
||||||
|
- `hdghartv` - HDGharTV streams (title-matched search + IMDb verification)
|
||||||
|
- `netmirror` - NetMirror streams (Netflix direct + NewTV platform fallback)
|
||||||
|
- `onetouchtv` - OneTouchTV streams (AES-256 encrypted API)
|
||||||
|
- `zxcstreams` - ZXCStreams streams (multi-server backend, dynamic domain discovery)
|
||||||
|
|
||||||
|
Providers that rely on title resolution against TMDB (`castletv`, `hdghartv`, `onetouchtv`, `zxcstreams`, `vaplayer`, `netmirror`) require at least one TMDB API key configured.
|
||||||
|
|
||||||
### Adding a New Provider
|
### Adding a New Provider
|
||||||
1. **Create** `providers/yourprovider.js` with your stream fetching logic
|
1. **Create** `providers/yourprovider.js` with your stream fetching logic
|
||||||
@@ -247,9 +269,14 @@ The API supports a plugin system. Drop a new provider file in the `providers/` f
|
|||||||
'vixsrc.js': 'getVixsrcStreams',
|
'vixsrc.js': 'getVixsrcStreams',
|
||||||
'videasy.js': 'getVideasyStreams',
|
'videasy.js': 'getVideasyStreams',
|
||||||
'vidlink.js': 'getVidlinkStreams',
|
'vidlink.js': 'getVidlinkStreams',
|
||||||
'lordflix.js': 'getLordflixStreams',
|
|
||||||
'notorrent.js': 'getNotorrentStreams',
|
|
||||||
'dahmermovies.js': 'getDahmermoviesStreams',
|
'dahmermovies.js': 'getDahmermoviesStreams',
|
||||||
|
'streamflix.js': 'getStreamflixStreams',
|
||||||
|
'vaplayer.js': 'getVaplayerStreams',
|
||||||
|
'castletv.js': 'getCastletvStreams',
|
||||||
|
'hdghartv.js': 'getHdghartvStreams',
|
||||||
|
'netmirror.js': 'getNetmirrorStreams',
|
||||||
|
'onetouchtv.js': 'getOnetouchtvStreams',
|
||||||
|
'zxcstreams.js': 'getZxcstreamsStreams',
|
||||||
'yourprovider.js': 'getYourproviderStreams'
|
'yourprovider.js': 'getYourproviderStreams'
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -260,6 +287,7 @@ The API supports a plugin system. Drop a new provider file in the `providers/` f
|
|||||||
async function getYourproviderStreams(tmdbId, mediaType, season, episode) {
|
async function getYourproviderStreams(tmdbId, mediaType, season, episode) {
|
||||||
// Your scraping/API logic here
|
// Your scraping/API logic here
|
||||||
return [{
|
return [{
|
||||||
|
name: "YourProvider",
|
||||||
title: "Fight Club - 1080p [YourProvider #1]",
|
title: "Fight Club - 1080p [YourProvider #1]",
|
||||||
url: "https://stream.url/video.mp4",
|
url: "https://stream.url/video.mp4",
|
||||||
quality: "1080p",
|
quality: "1080p",
|
||||||
@@ -287,30 +315,46 @@ The system automatically:
|
|||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `GET /api/health` | Basic heartbeat |
|
| `GET /api/health` | Basic heartbeat |
|
||||||
| `GET /api/metrics` | Runtime counters & summary |
|
| `GET /api/metrics` | Runtime counters & summary |
|
||||||
| `GET /api/status` | Metrics + providers + endpoints list |
|
| `GET /api/status` | Metrics + providers + endpoints + `providerCheckTmdbId` |
|
||||||
| `GET /api/providers` | Enabled providers summary |
|
| `GET /api/providers` | All providers with enabled status |
|
||||||
| `GET /api/providers/:name` | Single provider status |
|
| `GET /api/providers/:name` | Single provider status |
|
||||||
| `GET /api/streams/:type/:tmdbId` | Aggregate streams (`type` = movie|series) |
|
| `GET /api/streams/:type/:tmdbId` | Aggregate streams (`type` = movie\|series; supports `?season=&episode=`) |
|
||||||
| `GET /api/streams/:provider/:type/:tmdbId` | Provider-specific streams |
|
| `GET /api/streams/:provider/:type/:tmdbId` | Provider-specific streams (same query params) |
|
||||||
| `GET /api/config` | `{ merged, override }` |
|
| `GET /api/config` | `{ merged, override, overridePath }` |
|
||||||
| `POST /api/config` | Apply override patch |
|
| `POST /api/config` | Apply override patch (persisted to `utils/user-config.json`) |
|
||||||
|
| `POST /api/restart` | Graceful restart (writes `restart.trigger` + exits) |
|
||||||
|
| `GET /api/debug/env` | Debug environment/config snapshot |
|
||||||
|
|
||||||
Aggregate endpoint auto-resolves IMDb when needed and merges provider output before filtering.
|
Proxy routes (mounted only when `enableProxy` is on):
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /m3u8-proxy?url=...&headers=...` | Rewrites & serves HLS playlists/segments |
|
||||||
|
| `GET /ts-proxy?url=...&headers=...` | Range-aware file/segment proxy |
|
||||||
|
| `GET /sub-proxy?url=...` | Subtitle passthrough proxy |
|
||||||
|
|
||||||
|
The aggregate endpoint auto-resolves IMDb when needed, merges all enabled (or `defaultProviders`) provider output, then applies filtering. Provider timing is returned per provider (`providerTimings`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 Stream Object Schema (Unified)
|
## 🧪 Stream Object Schema (Unified)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "Fight Club - 1080p [MP4Hydra #2]",
|
"name": "ProviderDisplay",
|
||||||
|
"title": "Fight Club - 1080p [YourProvider #1]",
|
||||||
"url": "https://stream.url/video.mp4",
|
"url": "https://stream.url/video.mp4",
|
||||||
"quality": "1080p",
|
"quality": "1080p",
|
||||||
"provider": "mp4hydra",
|
"provider": "yourprovider",
|
||||||
"headers": { "User-Agent": "Mozilla/5.0" }
|
"headers": { "User-Agent": "Mozilla/5.0" },
|
||||||
|
"subtitles": [ { "url": "https://.../en.srt", "lang": "English" } ]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
- `name` / `title` – display strings (often include quality/source hints).
|
||||||
|
- `url` – direct upstream URL (or proxied URL when `enableProxy` is on).
|
||||||
|
- `quality` – e.g. `Auto`, `1080p`, `720p`, `480p`, `4K`. Missing/unknown qualities parse as `0`.
|
||||||
|
- `headers` – optional upstream request headers (stripped when proxying).
|
||||||
|
- `subtitles` – optional embedded subtitle tracks (CastleTV, NetMirror, OneTouchTV, Showbox).
|
||||||
|
|
||||||
Filtering passes through `applyFilters` to enforce min quality + codec exclusions.
|
Filtering passes through `applyFilters` to enforce min quality + codec exclusions (see below).
|
||||||
|
|
||||||
> 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.
|
> 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.
|
||||||
|
|
||||||
@@ -319,9 +363,14 @@ Filtering passes through `applyFilters` to enforce min quality + codec exclusion
|
|||||||
## ⚙️ Configuration Flags (Advanced Panel)
|
## ⚙️ Configuration Flags (Advanced Panel)
|
||||||
| Flag | Default | Purpose |
|
| Flag | Default | Purpose |
|
||||||
|------|---------|---------|
|
|------|---------|---------|
|
||||||
| disableCache | false | Disables internal caches |\n| disableUrlValidation | false | Skip general URL pattern validation checks |
|
| `enable<Name>Provider` | true | Enable/disable a provider (e.g. `enableCastletvProvider`) |
|
||||||
| disable4khdhubUrlValidation | false | Skip 4khdhub-specific URL validation |
|
| `disableCache` | false | Disables internal caches |
|
||||||
| enableProxy | false | Mounts proxy routes and rewrites stream URLs through them |
|
| `enablePStreamApi` | true | PStream API flag |
|
||||||
|
| `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 |
|
||||||
|
| `providerCheckTmdbId` | `278` | TMDB ID used by dashboard functional checks |
|
||||||
|
| `showboxCacheDir` | — | Custom Showbox cache directory |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -329,10 +378,11 @@ Toggle `enableProxy` to activate the internal proxy. This adds lightweight playl
|
|||||||
|
|
||||||
## 🧩 Quality & Codec Filtering
|
## 🧩 Quality & Codec Filtering
|
||||||
- Presets: `all`, `480p`, `720p`, `1080p`, `1440p`, `2160p`.
|
- Presets: `all`, `480p`, `720p`, `1080p`, `1440p`, `2160p`.
|
||||||
- Custom quality JSON example:
|
- Custom quality JSON example (runtime applies the `default` entry; per-provider keys are preserved for future use):
|
||||||
```json
|
```json
|
||||||
{ "default": "900p", "4khdhub": "1080p" }
|
{ "default": "900p" }
|
||||||
```
|
```
|
||||||
|
- Quality strings are normalized numerically — `Auto` ≈ 1080, `HD` ≈ 720, `SD` ≈ 480, plus `4K`/`2160`, `1440`, `1080`, `720`, `576`, `480`, `360`, `240`.
|
||||||
- Codec exclusion JSON example:
|
- Codec exclusion JSON example:
|
||||||
```json
|
```json
|
||||||
{ "excludeDV": true, "excludeHDR": false }
|
{ "excludeDV": true, "excludeHDR": false }
|
||||||
@@ -340,11 +390,20 @@ Toggle `enableProxy` to activate the internal proxy. This adds lightweight playl
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📊 Server Status & Functional Checks
|
||||||
|
The **Server Status** panel shows live metrics, the endpoint list, and a per-provider functional check table:
|
||||||
|
|
||||||
|
- **Run Provider Functional Checks** – hits `/api/streams/:provider/movie/:tmdbId` for each enabled provider and reports pass/fail with stream counts.
|
||||||
|
- **Provider check TMDB ID** – the title used for the checks is configurable in the dashboard (default `278` = *The Shawshank Redemption*). The value is persisted to `utils/user-config.json` and exposed via `/api/status` as `providerCheckTmdbId`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔐 Security Notes
|
## 🔐 Security Notes
|
||||||
- Admin UI requires login; session cookie is HttpOnly.
|
- Admin UI requires login; session cookie is HttpOnly.
|
||||||
- Cache-control headers disable storing sensitive pages.
|
- Cache-control headers disable storing sensitive pages.
|
||||||
- Login is rate limited with escalating lockouts.
|
- Login is rate limited with escalating lockouts (5 attempts per window).
|
||||||
- Password change endpoint enforces minimum length.
|
- Password change endpoint enforces minimum length (8+).
|
||||||
|
- Default credentials (`admin` / `change-me`) are generated on first run — change them immediately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -354,6 +413,7 @@ Toggle `enableProxy` to activate the internal proxy. This adds lightweight playl
|
|||||||
| Node Version | 18+ LTS |
|
| Node Version | 18+ LTS |
|
||||||
| Reverse Proxy | Terminate TLS (e.g., Nginx) and forward to API port |
|
| Reverse Proxy | Terminate TLS (e.g., Nginx) and forward to API port |
|
||||||
| Persistent Config | Mount / persist `utils/user-config.json` |
|
| Persistent Config | Mount / persist `utils/user-config.json` |
|
||||||
|
| Auth Users | Persist `utils/auth-users.json` so credentials survive restarts |
|
||||||
| Logs | Pipe stdout to centralized logger |
|
| Logs | Pipe stdout to centralized logger |
|
||||||
| Scaling | Use a single instance unless providers are CPU bound |
|
| Scaling | Use a single instance unless providers are CPU bound |
|
||||||
|
|
||||||
@@ -368,6 +428,8 @@ For ephemeral platforms (e.g., Vercel) note that some providers use temporary di
|
|||||||
| Provider missing in matrix | Ensure its enable flag exists & UI updated |
|
| Provider missing in matrix | Ensure its enable flag exists & UI updated |
|
||||||
| Empty merged config after restart | `user-config.json` deleted or unreadable |
|
| Empty merged config after restart | `user-config.json` deleted or unreadable |
|
||||||
| Streams low quality | Adjust min quality preset or custom JSON |
|
| Streams low quality | Adjust min quality preset or custom JSON |
|
||||||
|
| Showbox shows nothing | Provide `FEBBOX_COOKIES` (comma-separated) under Keys |
|
||||||
|
| Functional checks fail | Some providers 0-stream legit titles; try changing the *Provider check TMDB ID* |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -393,7 +455,7 @@ Every contribution accelerates feature delivery & sustainability.
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 📜 License
|
## 📜 License
|
||||||
MIT. See `LICENSE`.
|
MIT.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -404,4 +466,4 @@ Inspired by community scraping/stream aggregation efforts. Credits also to the o
|
|||||||
|
|
||||||
> *Happy streaming & hacking!* ✨
|
> *Happy streaming & hacking!* ✨
|
||||||
|
|
||||||
---
|
---
|
||||||
+2
-1
@@ -11,6 +11,7 @@ const { resolveImdbId } = require('./utils/tmdb');
|
|||||||
const { applyFilters } = require('./utils/streamFilters');
|
const { applyFilters } = require('./utils/streamFilters');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Conditionally mount proxy routes early so downstream handlers can use them
|
// Conditionally mount proxy routes early so downstream handlers can use them
|
||||||
if (config.enableProxy) {
|
if (config.enableProxy) {
|
||||||
@@ -280,7 +281,7 @@ app.get('/api/status', (req,res) => {
|
|||||||
const cookieOk = !cookieRequired || (config.febboxCookies && config.febboxCookies.length > 0);
|
const cookieOk = !cookieRequired || (config.febboxCookies && config.febboxCookies.length > 0);
|
||||||
return { name: p.name, enabled: p.enabled, cookieRequired, cookieOk };
|
return { name: p.name, enabled: p.enabled, cookieRequired, cookieOk };
|
||||||
});
|
});
|
||||||
res.json({ success:true, metrics: {
|
res.json({ success:true, providerCheckTmdbId: config.providerCheckTmdbId, metrics: {
|
||||||
uptimeSeconds: Math.round((Date.now()-metrics.startTime)/1000),
|
uptimeSeconds: Math.round((Date.now()-metrics.startTime)/1000),
|
||||||
requestsTotal: metrics.requestsTotal,
|
requestsTotal: metrics.requestsTotal,
|
||||||
streamRequests: metrics.streamRequests,
|
streamRequests: metrics.streamRequests,
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ services:
|
|||||||
# Alternative: supply JSON array
|
# Alternative: supply JSON array
|
||||||
# - TMDB_API_KEYS=["key1","key2"]
|
# - TMDB_API_KEYS=["key1","key2"]
|
||||||
- API_PORT=8787
|
- API_PORT=8787
|
||||||
- BIND_HOST=0.0.0.0
|
- BIND_HOST=0.0.0.0
|
||||||
# FebBox cookies (comma separated) if you want Showbox immediately functional
|
# FebBox cookies (comma separated) if you want Showbox immediately functional
|
||||||
- FEBBOX_COOKIES=${FEBBOX_COOKIES:-}
|
- FEBBOX_COOKIES=${FEBBOX_COOKIES:-}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Generated
+124
-65
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tmdb-embed-api",
|
"name": "tmdb-embed-api",
|
||||||
"version": "1.1.1",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tmdb-embed-api",
|
"name": "tmdb-embed-api",
|
||||||
"version": "1.1.1",
|
"version": "1.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"aromanize": "^0.1.5",
|
"aromanize": "^0.1.5",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
@@ -138,7 +138,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -161,7 +160,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -381,7 +379,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -489,13 +486,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.16.1",
|
"version": "1.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
|
||||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.6",
|
||||||
"https-proxy-agent": "^5.0.1",
|
"https-proxy-agent": "^5.0.1",
|
||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -588,20 +585,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.2.2",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^2.0.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.1",
|
||||||
"iconv-lite": "^0.7.0",
|
"iconv-lite": "^0.7.2",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"qs": "^6.14.1",
|
"qs": "^6.15.2",
|
||||||
"raw-body": "^3.0.1",
|
"raw-body": "^3.0.2",
|
||||||
"type-is": "^2.0.1"
|
"type-is": "^2.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -611,6 +608,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/body-parser/node_modules/content-type": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser/node_modules/debug": {
|
"node_modules/body-parser/node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -628,6 +638,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/body-parser/node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser/node_modules/iconv-lite": {
|
"node_modules/body-parser/node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -645,12 +675,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/media-typer": {
|
"node_modules/body-parser/node_modules/media-typer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
|
||||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/mime-db": {
|
"node_modules/body-parser/node_modules/mime-db": {
|
||||||
@@ -663,15 +697,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/mime-types": {
|
"node_modules/body-parser/node_modules/mime-types": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.54.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/ms": {
|
"node_modules/body-parser/node_modules/ms": {
|
||||||
@@ -680,18 +718,31 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/body-parser/node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser/node_modules/type-is": {
|
"node_modules/body-parser/node_modules/type-is": {
|
||||||
"version": "2.0.1",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^2.0.0",
|
||||||
"media-typer": "^1.1.0",
|
"media-typer": "^1.1.0",
|
||||||
"mime-types": "^3.0.0"
|
"mime-types": "^3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/boolbase": {
|
"node_modules/boolbase": {
|
||||||
@@ -701,9 +752,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.14",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1381,7 +1432,6 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -1620,9 +1670,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express/node_modules/body-parser": {
|
"node_modules/express/node_modules/body-parser": {
|
||||||
"version": "1.20.5",
|
"version": "1.20.6",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
@@ -1834,16 +1884,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -2052,9 +2102,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -2386,10 +2436,20 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -3048,12 +3108,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -3364,14 +3425,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3",
|
"object-inspect": "^1.13.4",
|
||||||
"side-channel-list": "^1.0.0",
|
"side-channel-list": "^1.0.1",
|
||||||
"side-channel-map": "^1.0.1",
|
"side-channel-map": "^1.0.1",
|
||||||
"side-channel-weakmap": "^1.0.2"
|
"side-channel-weakmap": "^1.0.2"
|
||||||
},
|
},
|
||||||
@@ -3583,7 +3644,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tldts": "^6.1.32"
|
"tldts": "^6.1.32"
|
||||||
},
|
},
|
||||||
@@ -3650,11 +3710,10 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.25.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
}
|
}
|
||||||
@@ -3819,9 +3878,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.2",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tmdb-embed-api",
|
"name": "tmdb-embed-api",
|
||||||
"version": "1.1.1",
|
"version": "1.3.0",
|
||||||
"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": {
|
||||||
|
|||||||
+162
-106
@@ -124,43 +124,69 @@ function validateUrl(url) {
|
|||||||
|
|
||||||
function makeRequest(url, options = {}) {
|
function makeRequest(url, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const urlObj = new URL(url);
|
const maxRedirects = options.maxRedirects || 10;
|
||||||
const isHttps = urlObj.protocol === 'https:';
|
let redirectCount = 0;
|
||||||
const httpModule = isHttps ? https : http;
|
|
||||||
|
|
||||||
const requestOptions = {
|
const doRequest = (currentUrl) => {
|
||||||
hostname: urlObj.hostname,
|
const urlObj = new URL(currentUrl);
|
||||||
port: urlObj.port || (isHttps ? 443 : 80),
|
const isHttps = urlObj.protocol === 'https:';
|
||||||
path: urlObj.pathname + urlObj.search,
|
const httpModule = isHttps ? https : http;
|
||||||
method: options.method || 'GET',
|
|
||||||
headers: {
|
const requestOptions = {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
hostname: urlObj.hostname,
|
||||||
...options.headers
|
port: urlObj.port || (isHttps ? 443 : 80),
|
||||||
},
|
path: urlObj.pathname + urlObj.search,
|
||||||
timeout: 30000
|
method: options.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
...options.headers
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = httpModule.request(requestOptions, (res) => {
|
||||||
|
const redirectCodes = [301, 302, 303, 307, 308];
|
||||||
|
const isRedirect = redirectCodes.includes(res.statusCode);
|
||||||
|
const location = res.headers['location'];
|
||||||
|
|
||||||
|
if (options.allowRedirects === false && isRedirect) {
|
||||||
|
resolve({ statusCode: res.statusCode, headers: res.headers, url: currentUrl });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRedirect && location && redirectCount < maxRedirects) {
|
||||||
|
redirectCount++;
|
||||||
|
res.resume(); // Consume and discard the redirect body
|
||||||
|
let nextUrl;
|
||||||
|
try {
|
||||||
|
nextUrl = new URL(location, currentUrl).toString();
|
||||||
|
} catch (error) {
|
||||||
|
resolve({ statusCode: res.statusCode, headers: res.headers, body: '', url: currentUrl });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[4KHDHub] Following redirect (${redirectCount}/${maxRedirects}): ${currentUrl} -> ${nextUrl}`);
|
||||||
|
doRequest(nextUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (options.parseHTML && data) {
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
resolve({ $: $, body: data, statusCode: res.statusCode, headers: res.headers, url: currentUrl });
|
||||||
|
} else {
|
||||||
|
resolve({ body: data, statusCode: res.statusCode, headers: res.headers, url: currentUrl });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => reject(new Error('Request timeout')));
|
||||||
|
req.end();
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = httpModule.request(requestOptions, (res) => {
|
doRequest(url);
|
||||||
if (options.allowRedirects === false && (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303 || res.statusCode === 307 || res.statusCode === 308)) {
|
|
||||||
resolve({ statusCode: res.statusCode, headers: res.headers });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = '';
|
|
||||||
res.on('data', chunk => data += chunk);
|
|
||||||
res.on('end', () => {
|
|
||||||
if (options.parseHTML && data) {
|
|
||||||
const $ = cheerio.load(data);
|
|
||||||
resolve({ $: $, body: data, statusCode: res.statusCode, headers: res.headers });
|
|
||||||
} else {
|
|
||||||
resolve({ body: data, statusCode: res.statusCode, headers: res.headers });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.on('timeout', () => reject(new Error('Request timeout')));
|
|
||||||
req.end();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,8 +697,8 @@ function extractHubCloudLinks(url, referer) {
|
|||||||
const altElement = $(selector).first();
|
const altElement = $(selector).first();
|
||||||
if (altElement.length > 0) {
|
if (altElement.length > 0) {
|
||||||
const rawHref = altElement.attr('href');
|
const rawHref = altElement.attr('href');
|
||||||
if (rawHref) {
|
if (rawHref && /^https?:\/\//i.test(rawHref)) {
|
||||||
href = rawHref.startsWith('http') ? rawHref : `${baseUrl.replace(/\/$/, '')}/${rawHref.replace(/^\//, '')}`;
|
href = rawHref;
|
||||||
console.log(`[4KHDHub] Found download link with selector ${selector}: ${href}`);
|
console.log(`[4KHDHub] Found download link with selector ${selector}: ${href}`);
|
||||||
found = true;
|
found = true;
|
||||||
break;
|
break;
|
||||||
@@ -719,6 +745,22 @@ function extractHubCloudLinks(url, referer) {
|
|||||||
const downloadButtons = $('div.card-body h2 a.btn');
|
const downloadButtons = $('div.card-body h2 a.btn');
|
||||||
console.log(`[4KHDHub] Found ${downloadButtons.length} download buttons`);
|
console.log(`[4KHDHub] Found ${downloadButtons.length} download buttons`);
|
||||||
|
|
||||||
|
// Apply JS-assigned pixel server hrefs (var pxl = "https://...") — the page
|
||||||
|
// overwrites the pixeldrain button href at runtime, so prefer the JS value
|
||||||
|
const pxlMatch = (response.body || '').match(/var\s+pxl\s*=\s*["']([^"']+)["']/);
|
||||||
|
if (pxlMatch && pxlMatch[1]) {
|
||||||
|
const pxlUrl = pxlMatch[1];
|
||||||
|
let applied = false;
|
||||||
|
$('a.btn[href*="pixeldrain"]').each((i, el) => {
|
||||||
|
$(el).attr('href', pxlUrl);
|
||||||
|
applied = true;
|
||||||
|
});
|
||||||
|
if (!applied) {
|
||||||
|
$('div.card-body h2').append(`<a href="${pxlUrl}" class="btn">Download [PixelServer]</a>`);
|
||||||
|
}
|
||||||
|
console.log(`[4KHDHub] Applied JS-assigned pixel server URL: ${pxlUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (downloadButtons.length === 0) {
|
if (downloadButtons.length === 0) {
|
||||||
// Try alternative selectors for download buttons
|
// Try alternative selectors for download buttons
|
||||||
const altSelectors = ['a.btn', '.btn', 'a[href]'];
|
const altSelectors = ['a.btn', '.btn', 'a[href]'];
|
||||||
@@ -904,12 +946,12 @@ function extractHubCloudLinks(url, referer) {
|
|||||||
} else if (link.includes('pixeldra')) {
|
} else if (link.includes('pixeldra')) {
|
||||||
console.log(`[4KHDHub] Button ${index + 1} is Pixeldrain`);
|
console.log(`[4KHDHub] Button ${index + 1} is Pixeldrain`);
|
||||||
|
|
||||||
// Convert pixeldrain.net/u/ID format to pixeldrain.net/api/file/ID format
|
// Convert pixeldrain /u/ID format to /api/file/ID format (any pixeldrain host)
|
||||||
let convertedLink = link;
|
let convertedLink = link;
|
||||||
const pixeldrainMatch = link.match(/pixeldrain\.net\/u\/([a-zA-Z0-9]+)/);
|
const pixeldrainMatch = link.match(/pixeldrain\.(?:dev|net|com)\/u\/([a-zA-Z0-9]+)/);
|
||||||
if (pixeldrainMatch) {
|
if (pixeldrainMatch) {
|
||||||
const fileId = pixeldrainMatch[1];
|
const fileId = pixeldrainMatch[1];
|
||||||
convertedLink = `https://pixeldrain.net/api/file/${fileId}`;
|
convertedLink = link.replace(/\/u\/([a-zA-Z0-9]+)/, `/api/file/${fileId}`);
|
||||||
console.log(`[4KHDHub] Converted Pixeldrain URL from ${link} to ${convertedLink}`);
|
console.log(`[4KHDHub] Converted Pixeldrain URL from ${link} to ${convertedLink}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,74 +1025,76 @@ function extractHubCloudLinks(url, referer) {
|
|||||||
headers: {}
|
headers: {}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if (text.includes('10Gbps')) {
|
} else if (text.includes('10Gbps') || /pixel\.hubcloud/i.test(link) || link.includes('dl.php')) {
|
||||||
console.log(`[4KHDHub] Button ${index + 1} is 10Gbps server, following redirects...`);
|
console.log(`[4KHDHub] Button ${index + 1} is 10Gbps/pixel server, resolving final link...`);
|
||||||
// Handle 10Gbps server with multiple redirects
|
// Follow redirects (pixel.hubcloud.cx -> worker -> gamerxyt.com/dl.php?link=<file>)
|
||||||
let currentLink = link;
|
// and extract the 'link' query parameter from the final effective URL
|
||||||
|
makeRequest(link, { parseHTML: false })
|
||||||
|
.then(response => {
|
||||||
|
const finalUrl = response.url || link;
|
||||||
|
let decodedUrl = '';
|
||||||
|
|
||||||
const followRedirects = () => {
|
try {
|
||||||
return makeRequest(currentLink, {
|
const urlObj = new URL(finalUrl);
|
||||||
parseHTML: false,
|
const linkParam = urlObj.searchParams.get('link');
|
||||||
allowRedirects: false
|
if (linkParam) {
|
||||||
})
|
decodedUrl = linkParam;
|
||||||
.then(response => {
|
if (decodedUrl.includes('%')) {
|
||||||
const redirectUrl = response.headers['location'];
|
try { decodedUrl = decodeURIComponent(decodedUrl); } catch { /* keep as-is */ }
|
||||||
if (!redirectUrl) {
|
|
||||||
throw new Error('No redirect found');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[4KHDHub] 10Gbps redirect: ${redirectUrl}`);
|
|
||||||
|
|
||||||
if (redirectUrl.includes('id=')) {
|
|
||||||
// Final redirect, extract the link parameter
|
|
||||||
const finalLink = redirectUrl.split('link=')[1];
|
|
||||||
if (finalLink) {
|
|
||||||
console.log(`[4KHDHub] 10Gbps final link: ${finalLink}`);
|
|
||||||
const decodedUrl = decodeURIComponent(finalLink);
|
|
||||||
// Get actual filename from HEAD request
|
|
||||||
return getFilenameFromUrl(decodedUrl)
|
|
||||||
.then(actualFilename => {
|
|
||||||
const displayFilename = actualFilename || headerDetails || 'Unknown';
|
|
||||||
const titleParts = [];
|
|
||||||
if (displayFilename) titleParts.push(displayFilename);
|
|
||||||
if (size) titleParts.push(size);
|
|
||||||
const finalTitle = titleParts.join('\n');
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: `4KHDHub - 10Gbps Server${qualityLabel}`,
|
|
||||||
title: finalTitle,
|
|
||||||
url: decodedUrl,
|
|
||||||
quality: quality,
|
|
||||||
provider: '4khdhub',
|
|
||||||
headers: {}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
const displayFilename = headerDetails || 'Unknown';
|
|
||||||
const titleParts = [];
|
|
||||||
if (displayFilename) titleParts.push(displayFilename);
|
|
||||||
if (size) titleParts.push(size);
|
|
||||||
const finalTitle = titleParts.join('\n');
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: `4KHDHub - 10Gbps Server${qualityLabel}`,
|
|
||||||
title: finalTitle,
|
|
||||||
url: decodedUrl,
|
|
||||||
quality: quality,
|
|
||||||
provider: '4khdhub',
|
|
||||||
headers: {}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
throw new Error('Final link not found');
|
|
||||||
} else {
|
|
||||||
currentLink = redirectUrl;
|
|
||||||
return followRedirects();
|
|
||||||
}
|
}
|
||||||
});
|
} catch { /* ignore */ }
|
||||||
};
|
|
||||||
|
|
||||||
followRedirects()
|
if (!decodedUrl) {
|
||||||
|
// Old-style fallback: look for link= in the redirect URL
|
||||||
|
const oldMatch = finalUrl.match(/[?&]link=([^&]+)/);
|
||||||
|
if (oldMatch) {
|
||||||
|
decodedUrl = oldMatch[1];
|
||||||
|
try { decodedUrl = decodeURIComponent(decodedUrl); } catch { /* keep as-is */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decodedUrl) {
|
||||||
|
throw new Error('Final link not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[4KHDHub] 10Gbps final link: ${decodedUrl}`);
|
||||||
|
|
||||||
|
// Get actual filename from HEAD request
|
||||||
|
return getFilenameFromUrl(decodedUrl)
|
||||||
|
.then(actualFilename => {
|
||||||
|
const displayFilename = actualFilename || headerDetails || 'Unknown';
|
||||||
|
const titleParts = [];
|
||||||
|
if (displayFilename) titleParts.push(displayFilename);
|
||||||
|
if (size) titleParts.push(size);
|
||||||
|
const finalTitle = titleParts.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `4KHDHub - 10Gbps Server${qualityLabel}`,
|
||||||
|
title: finalTitle,
|
||||||
|
url: decodedUrl,
|
||||||
|
quality: quality,
|
||||||
|
provider: '4khdhub',
|
||||||
|
headers: {}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
const displayFilename = headerDetails || 'Unknown';
|
||||||
|
const titleParts = [];
|
||||||
|
if (displayFilename) titleParts.push(displayFilename);
|
||||||
|
if (size) titleParts.push(size);
|
||||||
|
const finalTitle = titleParts.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `4KHDHub - 10Gbps Server${qualityLabel}`,
|
||||||
|
title: finalTitle,
|
||||||
|
url: decodedUrl,
|
||||||
|
quality: quality,
|
||||||
|
provider: '4khdhub',
|
||||||
|
headers: {}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
.then(result => {
|
.then(result => {
|
||||||
console.log(`[4KHDHub] 10Gbps processing completed`);
|
console.log(`[4KHDHub] 10Gbps processing completed`);
|
||||||
resolve(result);
|
resolve(result);
|
||||||
@@ -1368,6 +1412,12 @@ function extractHubDriveLinks(url, referer) {
|
|||||||
|
|
||||||
console.log(`[4KHDHub] HubDrive extracted info - Size: ${size}, Header: ${header}, Quality: ${quality}, HeaderDetails: ${headerDetails}`);
|
console.log(`[4KHDHub] HubDrive extracted info - Size: ${size}, Header: ${header}, Quality: ${quality}, HeaderDetails: ${headerDetails}`);
|
||||||
|
|
||||||
|
// Guard against dead/expired HubDrive files ("File not found" page)
|
||||||
|
if (/file not found/i.test(header)) {
|
||||||
|
console.log('[4KHDHub] HubDrive file not found, skipping');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Extract filename from header for title display
|
// Extract filename from header for title display
|
||||||
let filename = headerDetails || header || 'Unknown';
|
let filename = headerDetails || header || 'Unknown';
|
||||||
// Clean up the filename by removing common prefixes and file extensions
|
// Clean up the filename by removing common prefixes and file extensions
|
||||||
@@ -1393,8 +1443,12 @@ function extractHubDriveLinks(url, referer) {
|
|||||||
for (const selector of alternatives) {
|
for (const selector of alternatives) {
|
||||||
foundBtn = $(selector).first();
|
foundBtn = $(selector).first();
|
||||||
if (foundBtn.length > 0) {
|
if (foundBtn.length > 0) {
|
||||||
console.log(`[4KHDHub] Found download button with selector: ${selector}`);
|
const altHref = foundBtn.attr('href');
|
||||||
break;
|
if (altHref && /^https?:\/\//i.test(altHref)) {
|
||||||
|
console.log(`[4KHDHub] Found download button with selector: ${selector}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foundBtn = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1770,7 +1824,9 @@ async function get4KHDHubStreams(tmdbId, type, season = null, episode = null) {
|
|||||||
const suspiciousPatterns = [
|
const suspiciousPatterns = [
|
||||||
'www-google-com.cdn.ampproject.org',
|
'www-google-com.cdn.ampproject.org',
|
||||||
'bloggingvector.shop',
|
'bloggingvector.shop',
|
||||||
'cdn.ampproject.org'
|
'cdn.ampproject.org',
|
||||||
|
'hubcloud.cx/tg', // Telegram share redirects, not playable streams
|
||||||
|
'/tg/go'
|
||||||
];
|
];
|
||||||
|
|
||||||
const isSuspicious = suspiciousPatterns.some(pattern => url.includes(pattern));
|
const isSuspicious = suspiciousPatterns.some(pattern => url.includes(pattern));
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
const { createDecipheriv } = require('crypto');
|
||||||
|
const { getDetails } = require('../utils/tmdb');
|
||||||
|
|
||||||
|
const CASTLE_BASE = 'https://api.hlowb.com';
|
||||||
|
const PKG = 'com.external.castle';
|
||||||
|
const CHANNEL = 'IndiaA';
|
||||||
|
const CLIENT = '1';
|
||||||
|
const LANG = 'en-US';
|
||||||
|
|
||||||
|
const API_HEADERS = {
|
||||||
|
'User-Agent': 'okhttp/4.9.3',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
'Connection': 'Keep-Alive',
|
||||||
|
'Referer': CASTLE_BASE
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAYBACK_HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
'Accept-Encoding': 'identity',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Sec-Fetch-Dest': 'video',
|
||||||
|
'Sec-Fetch-Mode': 'no-cors',
|
||||||
|
'Sec-Fetch-Site': 'cross-site',
|
||||||
|
'DNT': '1'
|
||||||
|
};
|
||||||
|
|
||||||
|
function castleSafeParse(text) {
|
||||||
|
const safe = text.replace(/([:{[,]\s*)(\d{16,})/g, '$1"$2"');
|
||||||
|
return JSON.parse(safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveKey(securityKey) {
|
||||||
|
const keyBytes = Buffer.from(securityKey, 'base64');
|
||||||
|
const suffix = Buffer.from('T!BgJB', 'utf8');
|
||||||
|
const combined = Buffer.concat([keyBytes, suffix]);
|
||||||
|
if (combined.length < 16) {
|
||||||
|
return Buffer.concat([combined, Buffer.alloc(16 - combined.length, 0)]);
|
||||||
|
}
|
||||||
|
return combined.subarray(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptCastle(cipherText, securityKey) {
|
||||||
|
const key = deriveKey(securityKey);
|
||||||
|
const decipher = createDecipheriv('aes-128-cbc', key, key);
|
||||||
|
decipher.setAutoPadding(true);
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(cipherText, 'base64')),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function castleRequest(url, options = {}) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: { ...API_HEADERS, ...(options.headers || {}) },
|
||||||
|
signal: AbortSignal.timeout(12000)
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`[CastleTV] HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractCipher(res) {
|
||||||
|
const text = await res.text();
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) throw new Error('[CastleTV] Empty response body');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed.data && typeof parsed.data === 'string') {
|
||||||
|
return parsed.data.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON — raw cipher text
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap(obj) {
|
||||||
|
if (obj && obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
|
||||||
|
return obj.data;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSecurityKey() {
|
||||||
|
const url = `${CASTLE_BASE}/v0.1/system/getSecurityKey/1?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}`;
|
||||||
|
const res = await castleRequest(url);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 200 || !json.data) {
|
||||||
|
throw new Error(`[CastleTV] Security key error: ${JSON.stringify(json)}`);
|
||||||
|
}
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCastle(secKey, keyword) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
channel: CHANNEL,
|
||||||
|
clientType: CLIENT,
|
||||||
|
keyword,
|
||||||
|
lang: LANG,
|
||||||
|
mode: '1',
|
||||||
|
packageName: PKG,
|
||||||
|
page: '1',
|
||||||
|
size: '30'
|
||||||
|
});
|
||||||
|
const res = await castleRequest(`${CASTLE_BASE}/film-api/v1.1.0/movie/searchByKeyword?${params}`);
|
||||||
|
const cipher = await extractCipher(res);
|
||||||
|
return castleSafeParse(decryptCastle(cipher, secKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCastleDetails(secKey, movieId) {
|
||||||
|
const url = `${CASTLE_BASE}/film-api/v1.9.9/movie?channel=${CHANNEL}&clientType=${CLIENT}&lang=${LANG}&movieId=${movieId}&packageName=${PKG}`;
|
||||||
|
const res = await castleRequest(url);
|
||||||
|
const cipher = await extractCipher(res);
|
||||||
|
return castleSafeParse(decryptCastle(cipher, secKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVideoByLanguage(secKey, movieId, episodeId, languageId, resolution) {
|
||||||
|
const body = {
|
||||||
|
mode: '1',
|
||||||
|
appMarket: 'GuanWang',
|
||||||
|
clientType: CLIENT,
|
||||||
|
woolUser: 'false',
|
||||||
|
apkSignKey: 'ED0955EB04E67A1D9F3305B95454FED485261475',
|
||||||
|
androidVersion: '13',
|
||||||
|
movieId,
|
||||||
|
episodeId,
|
||||||
|
languageId,
|
||||||
|
isNewUser: 'true',
|
||||||
|
resolution: resolution.toString(),
|
||||||
|
packageName: PKG
|
||||||
|
};
|
||||||
|
const url = `${CASTLE_BASE}/film-api/v2.0.1/movie/getVideo2?clientType=${CLIENT}&packageName=${PKG}&channel=${CHANNEL}&lang=${LANG}`;
|
||||||
|
const res = await castleRequest(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const cipher = await extractCipher(res);
|
||||||
|
return castleSafeParse(decryptCastle(cipher, secKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVideoShared(secKey, movieId, episodeId, resolution) {
|
||||||
|
const body = {
|
||||||
|
mode: '1',
|
||||||
|
appMarket: 'GuanWang',
|
||||||
|
clientType: CLIENT,
|
||||||
|
woolUser: 'false',
|
||||||
|
apkSignKey: 'ED0955EB04E67A1D9F3305B95454FED485261475',
|
||||||
|
androidVersion: '13',
|
||||||
|
movieId,
|
||||||
|
episodeId,
|
||||||
|
isNewUser: 'true',
|
||||||
|
resolution: resolution.toString(),
|
||||||
|
packageName: PKG
|
||||||
|
};
|
||||||
|
const url = `${CASTLE_BASE}/film-api/v2.0.1/movie/getVideo2?clientType=${CLIENT}&packageName=${PKG}&channel=${CHANNEL}&lang=${LANG}`;
|
||||||
|
const res = await castleRequest(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const cipher = await extractCipher(res);
|
||||||
|
return castleSafeParse(decryptCastle(cipher, secKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolutionLabel(res) {
|
||||||
|
const map = { 1: '480p', 2: '720p', 3: '1080p' };
|
||||||
|
return map[res] || `${res}p`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_HEIGHTS = new Set([240, 360, 480, 540, 576, 720, 1080, 1440, 2160]);
|
||||||
|
|
||||||
|
function knownHeight(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) && KNOWN_HEIGHTS.has(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolutionNumToLabel(num) {
|
||||||
|
const map = { 1: '480p', 2: '720p', 3: '1080p', 4: '4K' };
|
||||||
|
return map[num] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamQuality(url, description, resolutionNum, defaultQual) {
|
||||||
|
if (description) {
|
||||||
|
const m = /(?:SD|HD|FHD|UHD|4K)?\s*(\d{3,4})\s*p?/i.exec(String(description).trim());
|
||||||
|
if (m && knownHeight(m[1])) {
|
||||||
|
return `${m[1]}p`;
|
||||||
|
}
|
||||||
|
if (/4k|uhd/i.test(String(description))) return '4K';
|
||||||
|
}
|
||||||
|
const numLabel = resolutionNumToLabel(Number(resolutionNum));
|
||||||
|
if (numLabel) return numLabel;
|
||||||
|
if (url) {
|
||||||
|
const tokens = url.match(/[^/a-z](?:(\d{3,4})\s*p?)[^a-z]/gi);
|
||||||
|
if (tokens) {
|
||||||
|
for (const t of tokens) {
|
||||||
|
const m = /(\d{3,4})/i.exec(t);
|
||||||
|
if (m && knownHeight(m[1])) return `${m[1]}p`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultQual;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (typeof bytes !== 'number' || bytes <= 0) return 'Unknown';
|
||||||
|
if (bytes > 1000000000) return `${(bytes / 1000000000).toFixed(2)} GB`;
|
||||||
|
return `${(bytes / 1000000).toFixed(0)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCastleStreams(raw, langLabel, titleLine, resolution) {
|
||||||
|
const data = unwrap(raw);
|
||||||
|
if (!data.videoUrl && !((data.videos || []).length)) return [];
|
||||||
|
|
||||||
|
const defaultQual = resolutionLabel(resolution);
|
||||||
|
|
||||||
|
const subtitles = (data.subtitles || [])
|
||||||
|
.filter((s) => typeof s.url === 'string' && s.url.length > 0)
|
||||||
|
.map((s, i) => ({
|
||||||
|
url: s.url.replace(/ /g, '%20'),
|
||||||
|
lang: s.abbreviate || s.title || 'Unknown',
|
||||||
|
id: `castle-${s.languageId || s.abbreviate || i}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const streams = [];
|
||||||
|
|
||||||
|
if (data.videos && data.videos.length > 0) {
|
||||||
|
const bestByUrl = new Map();
|
||||||
|
for (const v of data.videos) {
|
||||||
|
const videoUrl = v.url || data.videoUrl;
|
||||||
|
if (!videoUrl) continue;
|
||||||
|
const resNum = Number(v.resolution) || 0;
|
||||||
|
const qual = streamQuality(
|
||||||
|
videoUrl,
|
||||||
|
v.resolutionDescription,
|
||||||
|
resNum,
|
||||||
|
defaultQual
|
||||||
|
);
|
||||||
|
const existing = bestByUrl.get(videoUrl);
|
||||||
|
if (existing && existing.resNum >= resNum) continue;
|
||||||
|
const nameTag = langLabel ? `CastleTV ${langLabel}` : 'CastleTV';
|
||||||
|
bestByUrl.set(videoUrl, {
|
||||||
|
resNum,
|
||||||
|
stream: {
|
||||||
|
name: `${nameTag} | ${qual}`,
|
||||||
|
title: `${titleLine}\n${qual} | ${formatSize(v.size)} | Castle`,
|
||||||
|
url: videoUrl,
|
||||||
|
quality: qual,
|
||||||
|
provider: 'CastleTV',
|
||||||
|
headers: PLAYBACK_HEADERS,
|
||||||
|
...(subtitles.length ? { subtitles } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const { stream } of bestByUrl.values()) {
|
||||||
|
streams.push(stream);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const videoUrl = data.videoUrl;
|
||||||
|
if (!videoUrl) return [];
|
||||||
|
const qual = streamQuality(videoUrl, data.resolutionDescription, 0, defaultQual);
|
||||||
|
const nameTag = langLabel ? `CastleTV ${langLabel}` : 'CastleTV';
|
||||||
|
streams.push({
|
||||||
|
name: `${nameTag} | ${qual}`,
|
||||||
|
title: `${titleLine}\n${qual} | ${formatSize(data.size)} | Castle`,
|
||||||
|
url: videoUrl,
|
||||||
|
quality: qual,
|
||||||
|
provider: 'CastleTV',
|
||||||
|
headers: PLAYBACK_HEADERS,
|
||||||
|
...(subtitles.length ? { subtitles } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPreferredTracks(tracks) {
|
||||||
|
if (!tracks || !tracks.length) return tracks || [];
|
||||||
|
const withVideo = tracks.filter((t) => t.existIndividualVideo === true);
|
||||||
|
return withVideo.length > 0 ? withVideo : tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCastletvStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||||
|
console.log(`[CastleTV] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const type = mediaType === 'tv' ? 'series' : 'movie';
|
||||||
|
const tmdbType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||||
|
const details = await getDetails(tmdbType, tmdbId);
|
||||||
|
const title = (details && (details.title || details.name)) || '';
|
||||||
|
const year = (details && (details.release_date || details.first_air_date || '').slice(0, 4)) || null;
|
||||||
|
if (!title) {
|
||||||
|
console.log(`[CastleTV] No TMDB title resolved for ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const season = seasonNum || 1;
|
||||||
|
const episode = episodeNum || 1;
|
||||||
|
const titleLine = type === 'series'
|
||||||
|
? `${title} S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}${year ? ` (${year})` : ''}`
|
||||||
|
: `${title}${year ? ` (${year})` : ''}`;
|
||||||
|
|
||||||
|
const secKey = await getSecurityKey();
|
||||||
|
|
||||||
|
const keyword = year ? `${title} ${year}` : title;
|
||||||
|
const searchResult = await searchCastle(secKey, keyword);
|
||||||
|
const rows = (unwrap(searchResult).rows || []);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log(`[CastleTV] No search results for "${keyword}"`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleLc = title.toLowerCase();
|
||||||
|
const match = rows.find((r) => {
|
||||||
|
const name = (r.title || r.name || '').toLowerCase();
|
||||||
|
return name.includes(titleLc) || titleLc.includes(name);
|
||||||
|
}) || rows[0];
|
||||||
|
|
||||||
|
if (!match) return [];
|
||||||
|
const castleId = (match.id || match.redirectId || match.redirectIdStr || '').toString();
|
||||||
|
if (!castleId) return [];
|
||||||
|
|
||||||
|
let castleDetails = await getCastleDetails(secKey, castleId);
|
||||||
|
let activeId = castleId;
|
||||||
|
|
||||||
|
if (type === 'series') {
|
||||||
|
const seasons = (unwrap(castleDetails).seasons || []);
|
||||||
|
const seasonEntry = seasons.find((s) => s.number === season);
|
||||||
|
if (seasonEntry && seasonEntry.movieId && seasonEntry.movieId.toString() !== castleId) {
|
||||||
|
castleDetails = await getCastleDetails(secKey, seasonEntry.movieId.toString());
|
||||||
|
activeId = seasonEntry.movieId.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodes = (unwrap(castleDetails).episodes || []);
|
||||||
|
let episodeId = null;
|
||||||
|
|
||||||
|
if (type === 'series') {
|
||||||
|
const ep = episodes.find((e) => e.number === episode);
|
||||||
|
episodeId = ep && ep.id ? ep.id.toString() : null;
|
||||||
|
} else {
|
||||||
|
episodeId = episodes[0] && episodes[0].id ? episodes[0].id.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!episodeId) {
|
||||||
|
console.log(`[CastleTV] No episode found (${season}/${episode})`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const epEntry = episodes.find((e) => e.id && e.id.toString() === episodeId);
|
||||||
|
const allTracks = (epEntry && epEntry.tracks) || [];
|
||||||
|
const tracks = pickPreferredTracks(allTracks);
|
||||||
|
|
||||||
|
const streams = [];
|
||||||
|
const seenUrls = new Set();
|
||||||
|
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
const langJobs = tracks.map((track) =>
|
||||||
|
Promise.allSettled(
|
||||||
|
[3, 2, 1].map((resolution) =>
|
||||||
|
getVideoByLanguage(secKey, activeId, episodeId, String(track.languageId), resolution)
|
||||||
|
.then((raw) => ({ raw, resolution, track }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const allResults = await Promise.all(langJobs);
|
||||||
|
|
||||||
|
for (const trackResults of allResults) {
|
||||||
|
for (const r of trackResults) {
|
||||||
|
if (r.status !== 'fulfilled') continue;
|
||||||
|
const { raw, resolution, track } = r.value;
|
||||||
|
const langLabel = `[${track.languageName || track.abbreviate || 'Unknown'}]`;
|
||||||
|
for (const s of buildCastleStreams(raw, langLabel, titleLine, resolution)) {
|
||||||
|
if (!seenUrls.has(s.url)) {
|
||||||
|
seenUrls.add(s.url);
|
||||||
|
streams.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streams.length === 0) {
|
||||||
|
const sharedResults = await Promise.allSettled(
|
||||||
|
[3, 2, 1].map((resolution) =>
|
||||||
|
getVideoShared(secKey, activeId, episodeId, resolution)
|
||||||
|
.then((raw) => ({ raw, resolution }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const r of sharedResults) {
|
||||||
|
if (r.status !== 'fulfilled') continue;
|
||||||
|
const { raw, resolution } = r.value;
|
||||||
|
for (const s of buildCastleStreams(raw, '', titleLine, resolution)) {
|
||||||
|
if (!seenUrls.has(s.url)) {
|
||||||
|
seenUrls.add(s.url);
|
||||||
|
streams.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[CastleTV] Got ${streams.length} stream(s) for "${title}"`);
|
||||||
|
return streams;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[CastleTV] Error: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getCastletvStreams };
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
const { getDetails, resolveImdbId } = require('../utils/tmdb');
|
||||||
|
const { findBestMatchWithRetry } = require('../utils/titleMatch');
|
||||||
|
const { tmdbTitleToImdbId } = require('../utils/tmdbTitleToImdb');
|
||||||
|
|
||||||
|
const HDGHARTV_API = 'https://hdghartv.cc/api';
|
||||||
|
|
||||||
|
const HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json, */*',
|
||||||
|
'Referer': 'https://hdghartv.cc/'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchJson(url, timeoutMs = 10000) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const res = await fetch(url, { headers: HEADERS, signal: controller.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[HDGharTV] fetch failed for ${url}: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchHdghartv(title, kind, variants) {
|
||||||
|
const searchByVariant = async (variantTitle) => {
|
||||||
|
const encoded = encodeURIComponent(variantTitle);
|
||||||
|
const data = await fetchJson(`${HDGHARTV_API}/search?q=${encoded}`);
|
||||||
|
if (!data) return [];
|
||||||
|
const results = kind === 'movie' ? data.movies : data.series;
|
||||||
|
return (results || []).map((r) => ({ title: r.title, type: kind, raw: r }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { best } = await findBestMatchWithRetry(
|
||||||
|
{ title, type: kind },
|
||||||
|
variants,
|
||||||
|
searchByVariant,
|
||||||
|
{ provider: 'HDGharTV' }
|
||||||
|
);
|
||||||
|
if (!best) return null;
|
||||||
|
return { id: best.raw._id, title: best.raw.title };
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUALITY_RANK = { '4K': 0, '1080p': 1, '720p': 2, '480p': 3, '360p': 4 };
|
||||||
|
|
||||||
|
function sortLinks(links) {
|
||||||
|
return [...links].sort((a, b) => (QUALITY_RANK[a.quality] || 99) - (QUALITY_RANK[b.quality] || 99));
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksToStreams(links, labelSuffix = '') {
|
||||||
|
return sortLinks(links.filter((l) => l.isActive && l.url)).map((l) => ({
|
||||||
|
name: 'HDGharTV',
|
||||||
|
title: `HDGharTV${labelSuffix ? ` ${labelSuffix}` : ''} · ${l.quality}`,
|
||||||
|
url: l.url,
|
||||||
|
quality: l.quality,
|
||||||
|
provider: 'HDGharTV',
|
||||||
|
headers: HEADERS
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHdghartvStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||||
|
console.log(`[HDGharTV] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const kind = mediaType === 'tv' ? 'series' : 'movie';
|
||||||
|
const tmdbType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||||
|
const details = await getDetails(tmdbType, tmdbId);
|
||||||
|
const title = (details && (details.title || details.name)) || '';
|
||||||
|
if (!title) {
|
||||||
|
console.log(`[HDGharTV] No TMDB title resolved for ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const variants = [title];
|
||||||
|
const imdbId = await resolveImdbId(tmdbType, tmdbId);
|
||||||
|
|
||||||
|
const match = await searchHdghartv(title, kind, variants);
|
||||||
|
if (!match) {
|
||||||
|
console.log(`[HDGharTV] "${title}" not found`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imdbId && imdbId.startsWith('tt')) {
|
||||||
|
const resolvedId = await tmdbTitleToImdbId(match.title, undefined, kind).catch(() => null);
|
||||||
|
if (resolvedId && resolvedId !== imdbId) {
|
||||||
|
console.log(`[HDGharTV] IMDB ID mismatch — rejecting match: expected ${imdbId}, resolved ${resolvedId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'movie') {
|
||||||
|
const movie = await fetchJson(`${HDGHARTV_API}/movies/public/${match.id}`);
|
||||||
|
if (!movie || !movie.streamingLinks || !movie.streamingLinks.length) {
|
||||||
|
console.log(`[HDGharTV] No streaming links for movie "${match.title}"`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return linksToStreams(movie.streamingLinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
const season = seasonNum || 1;
|
||||||
|
const episode = episodeNum || 1;
|
||||||
|
const series = await fetchJson(`${HDGHARTV_API}/series/public/${match.id}`);
|
||||||
|
if (!series || !series.seasons || !series.seasons.length) {
|
||||||
|
console.log(`[HDGharTV] No seasons for series "${match.title}"`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonData = series.seasons.find((s) => s.seasonNumber === season);
|
||||||
|
if (!seasonData) {
|
||||||
|
console.log(`[HDGharTV] Season ${season} not found`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeData = seasonData.episodes && seasonData.episodes.find((e) => e.episodeNumber === episode);
|
||||||
|
if (!episodeData || !episodeData.streamingLinks || !episodeData.streamingLinks.length) {
|
||||||
|
console.log(`[HDGharTV] Episode ${episode} or links not found`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return linksToStreams(episodeData.streamingLinks, `S${season}E${episode}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[HDGharTV] Error: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getHdghartvStreams };
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
const axios = require('axios');
|
|
||||||
const { getTmdbApiKey } = require('../utils/tmdbKey');
|
|
||||||
|
|
||||||
const LORDFLIX_HEADERS = {
|
|
||||||
'Accept': '*/*',
|
|
||||||
'Origin': 'https://lordflix.org',
|
|
||||||
'Referer': 'https://lordflix.org/',
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'
|
|
||||||
};
|
|
||||||
|
|
||||||
const LORDFLIX_API = 'https://snowhouse.lordflix.club';
|
|
||||||
const MULTI_DECRYPT_API = 'https://enc-dec.app/api';
|
|
||||||
const SERVERS = ['Berlin', 'Marseille', 'Backrooms', 'Phoenix', 'Oslo', 'Luna', 'Sakura', 'Rio', 'Ativa', 'Moscow'];
|
|
||||||
|
|
||||||
// Matches the reference project's encodeQuote: encodeURIComponent with + encoded as %20
|
|
||||||
function encodeQuote(str) {
|
|
||||||
return encodeURIComponent(str).replace(/%20/g, '+').replace(/\+/g, '%20');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLordflixStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
|
||||||
console.log(`[Lordflix] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
|
||||||
|
|
||||||
const tmdbKey = getTmdbApiKey();
|
|
||||||
if (!tmdbKey) {
|
|
||||||
console.error('[Lordflix] No TMDB API key configured.');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: TMDB lookup for title, year, imdbId
|
|
||||||
let info;
|
|
||||||
try {
|
|
||||||
const type = mediaType === 'tv' ? 'tv' : 'movie';
|
|
||||||
const { data } = await axios.get(
|
|
||||||
`https://api.themoviedb.org/3/${type}/${tmdbId}?api_key=${tmdbKey}&append_to_response=external_ids`,
|
|
||||||
{ timeout: 8000 }
|
|
||||||
);
|
|
||||||
info = {
|
|
||||||
title: data.title || data.name || '',
|
|
||||||
year: (data.release_date || data.first_air_date || '').split('-')[0],
|
|
||||||
imdbId: (data.external_ids && data.external_ids.imdb_id) || ''
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Lordflix] TMDB lookup failed: ${err.message}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!info.title || !info.imdbId) {
|
|
||||||
console.error('[Lordflix] Missing title or IMDb ID from TMDB.');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeParam = mediaType === 'tv' ? 'series' : 'movie';
|
|
||||||
const titleEnc = encodeQuote(info.title);
|
|
||||||
const streams = [];
|
|
||||||
|
|
||||||
// Step 2: Query each server in parallel
|
|
||||||
await Promise.all(SERVERS.map(async (server) => {
|
|
||||||
try {
|
|
||||||
let serverUrl = `${LORDFLIX_API}/?title=${titleEnc}&type=${typeParam}&year=${info.year || ''}`
|
|
||||||
+ `&imdb=${info.imdbId}&tmdb=${tmdbId}&server=${server}`;
|
|
||||||
if (mediaType === 'tv') {
|
|
||||||
serverUrl += `&season=${seasonNum}&episode=${episodeNum}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Get signed proxy URL from enc-dec.app
|
|
||||||
const encBridgeRes = await axios.get(
|
|
||||||
`${MULTI_DECRYPT_API}/enc-lordflix?url=${encodeQuote(serverUrl)}`,
|
|
||||||
{ timeout: 8000 }
|
|
||||||
);
|
|
||||||
const encBridgeJson = encBridgeRes.data;
|
|
||||||
if (!encBridgeJson || encBridgeJson.status !== 200 || !encBridgeJson.result) return;
|
|
||||||
|
|
||||||
const { url: proxyEncUrl, sign: signature } = encBridgeJson.result;
|
|
||||||
if (!proxyEncUrl || !signature) return;
|
|
||||||
|
|
||||||
// Step 4: Fetch encrypted stream data from proxy URL
|
|
||||||
const remoteEncRes = await axios.get(proxyEncUrl, {
|
|
||||||
headers: LORDFLIX_HEADERS,
|
|
||||||
timeout: 8000,
|
|
||||||
responseType: 'text'
|
|
||||||
});
|
|
||||||
const remoteEncData = typeof remoteEncRes.data === 'string'
|
|
||||||
? remoteEncRes.data
|
|
||||||
: JSON.stringify(remoteEncRes.data);
|
|
||||||
|
|
||||||
// Step 5: Decrypt via enc-dec.app
|
|
||||||
const decRes = await axios.post(`${MULTI_DECRYPT_API}/dec-lordflix`,
|
|
||||||
{ text: remoteEncData, sign: signature },
|
|
||||||
{ headers: { 'Content-Type': 'application/json' }, timeout: 8000 }
|
|
||||||
);
|
|
||||||
const finalJson = decRes.data;
|
|
||||||
if (!finalJson || finalJson.status !== 200 || !finalJson.result || finalJson.result.error) return;
|
|
||||||
|
|
||||||
const streamList = finalJson.result.stream;
|
|
||||||
if (!Array.isArray(streamList) || streamList.length === 0) return;
|
|
||||||
|
|
||||||
const topStream = streamList[0];
|
|
||||||
if (topStream.type === 'hls' && topStream.playlist) {
|
|
||||||
streams.push({
|
|
||||||
name: `Lordflix[${server}]`,
|
|
||||||
title: `Lordflix[${server}]`,
|
|
||||||
url: topStream.playlist,
|
|
||||||
quality: 'Auto',
|
|
||||||
provider: 'Lordflix',
|
|
||||||
headers: LORDFLIX_HEADERS
|
|
||||||
});
|
|
||||||
console.log(`[Lordflix] Server ${server}: got stream.`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[Lordflix] Server ${server} error: ${err.message}`);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`[Lordflix] Total streams: ${streams.length}`);
|
|
||||||
return streams;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { getLordflixStreams };
|
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
const { getDetails } = require('../utils/tmdb');
|
||||||
|
|
||||||
|
const STREAM_CACHE_TTL = 25 * 60 * 1000;
|
||||||
|
const PLATFORM_ORDER = ['netflix', 'primevideo', 'hotstar', 'disney'];
|
||||||
|
|
||||||
|
const UA_POOL = [
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0',
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15',
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
|
||||||
|
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.82 Mobile Safari/537.36'
|
||||||
|
];
|
||||||
|
const ACCEPT_LANG_POOL = [
|
||||||
|
'en-US,en;q=0.9',
|
||||||
|
'en-GB,en;q=0.9',
|
||||||
|
'en-IN,en;q=0.9,hi;q=0.7',
|
||||||
|
'en-US,en;q=0.8,es;q=0.5'
|
||||||
|
];
|
||||||
|
let uaIndex = 0;
|
||||||
|
let langIndex = 0;
|
||||||
|
|
||||||
|
function nextUA() { return UA_POOL[uaIndex++ % UA_POOL.length]; }
|
||||||
|
function nextLang() { return ACCEPT_LANG_POOL[langIndex++ % ACCEPT_LANG_POOL.length]; }
|
||||||
|
|
||||||
|
const streamCache = new Map();
|
||||||
|
const inflight = new Map();
|
||||||
|
let resolvedApiUrl = '';
|
||||||
|
|
||||||
|
const NEW_TV_DOMAINS = [
|
||||||
|
'aHR0cHM6Ly9tb2JpbGVkZXRlY3RzLmNvbQ==',
|
||||||
|
'aHR0cHM6Ly9tb2JpbGVkZXRlY3QuYXBw',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LmFydA==',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LmNj',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LmNsaWNr',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0Lmluaw==',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LmxpdmU=',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LnBybw==',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LnNob3A=',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LnNpdGU=',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LnNwYWNl',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LnN0b3Jl',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0LnZpcA==',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0Lndpa2k=',
|
||||||
|
'aHR0cHM6Ly9tb2JpZGV0ZWN0Lnh5eg=='
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLATFORM_MAP = { netflix: { ott: 'nf' }, primevideo: { ott: 'pv' }, hotstar: { ott: 'hs' }, disney: { ott: 'hs' } };
|
||||||
|
|
||||||
|
function apiBase() {
|
||||||
|
return (process.env.NETMIRROR_API_BASE || 'https://net27.cc').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamReferer() {
|
||||||
|
return process.env.NETMIRROR_STREAM_REFERER || 'https://videodownloader.site/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function newTvHeaders(ott, extra = {}) {
|
||||||
|
return {
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0',
|
||||||
|
'X-Requested-With': 'NetmirrorNewTV v1.0',
|
||||||
|
'Accept': 'application/json, text/plain, */*',
|
||||||
|
'Ott': ott,
|
||||||
|
'User-Agent': nextUA(),
|
||||||
|
'Accept-Language': nextLang(),
|
||||||
|
...extra
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveNewTvApi() {
|
||||||
|
if (resolvedApiUrl) return resolvedApiUrl;
|
||||||
|
const custom = (process.env.NEWTV_DOMAINS || '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim().replace(/\/$/, ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
const domains = [
|
||||||
|
...custom,
|
||||||
|
...NEW_TV_DOMAINS.map((value) => Buffer.from(value, 'base64').toString('utf8'))
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const domain of domains) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${domain}/checknewtv.php`, {
|
||||||
|
headers: { ...newTvHeaders('nf') },
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.token_hash) {
|
||||||
|
resolvedApiUrl = Buffer.from(data.token_hash, 'base64').toString('utf8').replace(/\/$/, '');
|
||||||
|
return resolvedApiUrl;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next rotating discovery domain.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('NetMirror NewTV API discovery failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNetflix(tmdbId, type, title, season, episode) {
|
||||||
|
try {
|
||||||
|
const base = apiBase();
|
||||||
|
const url = type === 'series'
|
||||||
|
? `${base}/api/embed-tmdb/${tmdbId}?type=tv&se=${season}&ep=${episode}`
|
||||||
|
: `${base}/api/embed-tmdb/${tmdbId}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
Referer: `${base}/`,
|
||||||
|
'User-Agent': nextUA(),
|
||||||
|
'Accept-Language': nextLang()
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(20000)
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.ok !== true) return [];
|
||||||
|
const subtitles = (data.captions || []).flatMap((caption, index) => {
|
||||||
|
if (!caption.url) return [];
|
||||||
|
return [{
|
||||||
|
id: `netflix-${index}-${caption.lang || 'en'}`,
|
||||||
|
url: caption.url.startsWith('/') ? `${base}${caption.url}` : caption.url,
|
||||||
|
lang: caption.lang || 'en',
|
||||||
|
label: caption.name || 'English'
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
const headers = { Referer: streamReferer(), 'User-Agent': nextUA() };
|
||||||
|
const streams = (data.streams || []).filter((stream) => stream.url).map((stream) => ({
|
||||||
|
name: 'NetMirror | Netflix',
|
||||||
|
title: `${title}\n${stream.resolution ? `${stream.resolution}p` : 'Auto'}`,
|
||||||
|
url: stream.url,
|
||||||
|
quality: stream.resolution ? `${stream.resolution}p` : 'Auto',
|
||||||
|
provider: 'NetMirror',
|
||||||
|
headers,
|
||||||
|
...(subtitles.length ? { subtitles } : {})
|
||||||
|
}));
|
||||||
|
if (streams.length || !data.mp4) return streams;
|
||||||
|
return [{
|
||||||
|
name: 'NetMirror | Netflix',
|
||||||
|
title: `${title}\nAuto`,
|
||||||
|
url: data.mp4,
|
||||||
|
quality: 'Auto',
|
||||||
|
provider: 'NetMirror',
|
||||||
|
headers,
|
||||||
|
...(subtitles.length ? { subtitles } : {})
|
||||||
|
}];
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[NetMirror] Netflix direct failed: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = parseInt(String(value).replace(/[^\d]/g, ''), 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEpisodes(api, showId, postData, config) {
|
||||||
|
const result = [];
|
||||||
|
const selectedSeason = Array.isArray(postData.season) ? postData.season.findIndex((item) => item.selected) : -1;
|
||||||
|
const selectedSeasonId = selectedSeason >= 0 ? postData.season[selectedSeason].id : postData.nextPageSeason;
|
||||||
|
const add = (episode, seasonNumber) => {
|
||||||
|
result.push({
|
||||||
|
id: episode.id,
|
||||||
|
s: seasonNumber || parseNumber(episode.sNum),
|
||||||
|
ep: parseNumber(episode.ep) || parseNumber(episode.epNum)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const episode of postData.episodes || []) {
|
||||||
|
if (episode) add(episode, selectedSeason >= 0 ? selectedSeason + 1 : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postData.nextPageShow === 1 && selectedSeasonId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${api}/newtv/episodes.php?id=${encodeURIComponent(selectedSeasonId)}&page=2`,
|
||||||
|
{ headers: newTvHeaders(config.ott), signal: AbortSignal.timeout(15000) }
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
for (const episode of data.episodes || []) {
|
||||||
|
if (episode) add(episode, selectedSeason >= 0 ? selectedSeason + 1 : null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The first page is still useful when pagination is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlatform(platform, title, type, season, episode) {
|
||||||
|
try {
|
||||||
|
const config = PLATFORM_MAP[platform];
|
||||||
|
const api = await resolveNewTvApi();
|
||||||
|
const searchResponse = await fetch(
|
||||||
|
`${api}/newtv/search.php?s=${encodeURIComponent(title)}`,
|
||||||
|
{ headers: newTvHeaders(config.ott), signal: AbortSignal.timeout(15000) }
|
||||||
|
);
|
||||||
|
const searchData = await searchResponse.json();
|
||||||
|
const first = (searchData.searchResult || [])[0];
|
||||||
|
if (!first || !first.id) return [];
|
||||||
|
|
||||||
|
const postResponse = await fetch(
|
||||||
|
`${api}/newtv/post.php?id=${encodeURIComponent(first.id)}`,
|
||||||
|
{ headers: newTvHeaders(config.ott, { Lastep: '', Usertoken: '' }), signal: AbortSignal.timeout(15000) }
|
||||||
|
);
|
||||||
|
const postData = await postResponse.json();
|
||||||
|
|
||||||
|
let targetId = first.id;
|
||||||
|
if (type === 'series') {
|
||||||
|
const episodes = await getEpisodes(api, first.id, postData, config);
|
||||||
|
const target = episodes.find((item) => item.s === season && item.ep === episode);
|
||||||
|
if (!target) return [];
|
||||||
|
targetId = target.id;
|
||||||
|
} else if (postData.type === 't' || ((postData.episodes || []).filter(Boolean).length > 0)) {
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
targetId = postData.main_id || first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerResponse = await fetch(
|
||||||
|
`${api}/newtv/player.php?id=${encodeURIComponent(targetId)}`,
|
||||||
|
{ headers: newTvHeaders(config.ott, { Usertoken: '' }), signal: AbortSignal.timeout(15000) }
|
||||||
|
);
|
||||||
|
const player = await playerResponse.json();
|
||||||
|
if (player.status && player.status !== 'ok') {
|
||||||
|
console.log(`[NetMirror] ${platform} player status: ${player.status} — accepting if link present`);
|
||||||
|
}
|
||||||
|
if (!player.video_link) return [];
|
||||||
|
return [{
|
||||||
|
name: `NetMirror | ${platform === 'primevideo' ? 'Prime Video' : platform.charAt(0).toUpperCase() + platform.slice(1)}`,
|
||||||
|
title: `${title}\nAuto`,
|
||||||
|
url: player.video_link,
|
||||||
|
quality: 'Auto',
|
||||||
|
provider: 'NetMirror',
|
||||||
|
headers: {
|
||||||
|
Referer: player.referer || api,
|
||||||
|
'User-Agent': nextUA()
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[NetMirror] ${platform} failed: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hdOnly(streams, forceHd) {
|
||||||
|
if (!forceHd) return streams;
|
||||||
|
const hd = streams.filter((stream) => {
|
||||||
|
const match = (stream.quality || '').match(/(\d+)p/i);
|
||||||
|
return !match || Number(match[1]) >= 720;
|
||||||
|
});
|
||||||
|
return hd.length ? hd : streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNetmirrorStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||||
|
console.log(`[NetMirror] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||||
|
|
||||||
|
const type = mediaType === 'tv' ? 'series' : 'movie';
|
||||||
|
const season = mediaType === 'tv' ? (seasonNum || null) : null;
|
||||||
|
const episode = mediaType === 'tv' ? (episodeNum || null) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const details = await getDetails(mediaType === 'tv' ? 'tv' : 'movie', tmdbId);
|
||||||
|
const title = (details && (details.title || details.name)) || '';
|
||||||
|
if (!title) {
|
||||||
|
console.log(`[NetMirror] No TMDB title resolved for ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${tmdbId}:${type}:${season || 0}:${episode || 0}`;
|
||||||
|
const cached = streamCache.get(key);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return hdOnly(cached.streams, true);
|
||||||
|
}
|
||||||
|
const running = inflight.get(key);
|
||||||
|
if (running) return running;
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
const direct = await fetchNetflix(tmdbId, type, title, season, episode);
|
||||||
|
const fallback = await Promise.all(
|
||||||
|
PLATFORM_ORDER
|
||||||
|
.filter((platform) => platform !== 'netflix' || direct.length === 0)
|
||||||
|
.map((platform) => fetchPlatform(platform, title, type, season, episode))
|
||||||
|
);
|
||||||
|
const streams = hdOnly([...direct, ...fallback.flat()], true);
|
||||||
|
streamCache.set(key, { streams, expiresAt: Date.now() + STREAM_CACHE_TTL });
|
||||||
|
console.log(`[NetMirror] ${tmdbId} streams ready: ${streams.length}`);
|
||||||
|
return streams;
|
||||||
|
})().finally(() => inflight.delete(key));
|
||||||
|
|
||||||
|
inflight.set(key, request);
|
||||||
|
return request;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[NetMirror] Error: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getNetmirrorStreams };
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
const axios = require('axios');
|
|
||||||
const { getTmdbApiKey } = require('../utils/tmdbKey');
|
|
||||||
|
|
||||||
const NOTORRENT_API = 'https://addon-osvh.onrender.com';
|
|
||||||
|
|
||||||
function cleanText(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return str.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]/gu, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractQuality(titleText) {
|
|
||||||
const raw = titleText || '';
|
|
||||||
const match = raw.match(/(\d{3,4}p)/);
|
|
||||||
if (match) return match[0];
|
|
||||||
if (raw.toUpperCase().includes('FREE')) return 'Auto';
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getNotorrentStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
|
||||||
console.log(`[NoTorrent] Searching for ${mediaType} ${tmdbId}`);
|
|
||||||
|
|
||||||
const tmdbKey = getTmdbApiKey();
|
|
||||||
if (!tmdbKey) {
|
|
||||||
console.error('[NoTorrent] No TMDB API key configured.');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: TMDB → IMDB ID
|
|
||||||
let imdbId;
|
|
||||||
try {
|
|
||||||
const type = mediaType === 'tv' ? 'tv' : 'movie';
|
|
||||||
const { data } = await axios.get(
|
|
||||||
`https://api.themoviedb.org/3/${type}/${tmdbId}?api_key=${tmdbKey}&append_to_response=external_ids`,
|
|
||||||
{ timeout: 8000 }
|
|
||||||
);
|
|
||||||
imdbId = (data.external_ids && data.external_ids.imdb_id) || null;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[NoTorrent] TMDB lookup failed: ${err.message}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!imdbId) {
|
|
||||||
console.warn('[NoTorrent] Failed to map IMDB ID from TMDB.');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Fetch streams from NoTorrent Stremio addon API
|
|
||||||
const apiUrl = (mediaType === 'tv' && seasonNum != null)
|
|
||||||
? `${NOTORRENT_API}/stream/series/${imdbId}:${seasonNum}:${episodeNum}.json`
|
|
||||||
: `${NOTORRENT_API}/stream/movie/${imdbId}.json`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get(apiUrl, { timeout: 20000 });
|
|
||||||
const rawList = data.streams || [];
|
|
||||||
const streams = [];
|
|
||||||
|
|
||||||
for (const item of rawList) {
|
|
||||||
if (item.externalUrl || !item.url) continue;
|
|
||||||
if (item.url.includes('github.com') || item.url.includes('googleusercontent')) continue;
|
|
||||||
|
|
||||||
const cleanTitleStr = cleanText(item.title || '');
|
|
||||||
const quality = extractQuality(cleanTitleStr);
|
|
||||||
|
|
||||||
let language = 'Default';
|
|
||||||
const langMatch = cleanTitleStr.match(/\(([^)]+)\)/);
|
|
||||||
if (langMatch) {
|
|
||||||
language = langMatch[1].charAt(0).toUpperCase() + langMatch[1].slice(1).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyHeaders = (item.behaviorHints?.proxyHeaders?.request) || {};
|
|
||||||
const headers = { ...(item.behaviorHints?.headers || {}), ...proxyHeaders };
|
|
||||||
|
|
||||||
const nameParts = ['NoTorrent', language !== 'Default' ? language : ''].filter(p => p.trim() !== '');
|
|
||||||
streams.push({
|
|
||||||
name: nameParts.join(' \u2022 '),
|
|
||||||
title: quality,
|
|
||||||
url: item.url,
|
|
||||||
quality,
|
|
||||||
provider: 'NoTorrent',
|
|
||||||
headers: Object.keys(headers).length > 0 ? headers : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[NoTorrent] Total results found: ${streams.length}`);
|
|
||||||
return streams;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[NoTorrent] Fetch failed: ${err.message}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { getNotorrentStreams };
|
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
const { createDecipheriv } = require('crypto');
|
||||||
|
const { getDetails, resolveImdbId } = require('../utils/tmdb');
|
||||||
|
const { getEpisodesPerSeason } = require('../utils/cinemetaEpisodes');
|
||||||
|
const { findBestMatch } = require('../utils/titleMatch');
|
||||||
|
const { tmdbTitleToImdbId } = require('../utils/tmdbTitleToImdb');
|
||||||
|
|
||||||
|
const MAIN_URL = 'https://api3.devcorp.me';
|
||||||
|
const AES_KEY = Buffer.from('im72charPasswordofdInitVectorStm', 'utf8');
|
||||||
|
const AES_IV = Buffer.from('im72charPassword', 'utf8');
|
||||||
|
const FETCH_HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'Referer': 'https://onetouchtv.xyz/'
|
||||||
|
};
|
||||||
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
|
const FETCH_RETRIES = 2;
|
||||||
|
const FETCH_RETRY_DELAY_MS = 400;
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
const cache = new Map();
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
||||||
|
|
||||||
|
function fromCache(key) {
|
||||||
|
const entry = cache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() - entry.ts > CACHE_TTL_MS) {
|
||||||
|
cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCache(key, data) {
|
||||||
|
cache.set(key, { data, ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(encoded) {
|
||||||
|
let s = encoded
|
||||||
|
.replace(/-_\./g, '/')
|
||||||
|
.replace(/@/g, '+')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
const pad = s.length % 4;
|
||||||
|
if (pad !== 0) s += '='.repeat(4 - pad);
|
||||||
|
|
||||||
|
const buf = Buffer.from(s, 'base64');
|
||||||
|
const decipher = createDecipheriv('aes-256-cbc', AES_KEY, AES_IV);
|
||||||
|
const dec = Buffer.concat([decipher.update(buf), decipher.final()]);
|
||||||
|
return JSON.parse(dec.toString('utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEncryptedOnce(url) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
|
||||||
|
const text = await res.text();
|
||||||
|
const parsed = decrypt(text);
|
||||||
|
return parsed.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEncrypted(path, cacheTtlMs = CACHE_TTL_MS) {
|
||||||
|
const url = path.startsWith('https://') ? path : `${MAIN_URL}${path}`;
|
||||||
|
|
||||||
|
if (cacheTtlMs > 0) {
|
||||||
|
const cached = fromCache(url);
|
||||||
|
if (cached !== null) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastErr;
|
||||||
|
for (let attempt = 0; attempt <= FETCH_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const data = await fetchEncryptedOnce(url);
|
||||||
|
if (cacheTtlMs > 0) toCache(url, data);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
if (attempt < FETCH_RETRIES) {
|
||||||
|
console.warn(`[OneTouchTV] fetch failed (attempt ${attempt + 1}): ${err.message}`);
|
||||||
|
await sleep(FETCH_RETRY_DELAY_MS * (attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_MAP = {
|
||||||
|
movie: new Set(['movie']),
|
||||||
|
series: new Set(['drama', 'variety', 'anime'])
|
||||||
|
};
|
||||||
|
|
||||||
|
function isTypeCompatible(stremioType, onetouchType) {
|
||||||
|
const allowed = TYPE_MAP[stremioType];
|
||||||
|
if (!allowed) return true;
|
||||||
|
return allowed.has(onetouchType.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestOttMatch(results, title, year, stremioType, targetSeason) {
|
||||||
|
if (results.length === 0) return null;
|
||||||
|
|
||||||
|
const filtered = results.filter((r) => isTypeCompatible(stremioType, r.type));
|
||||||
|
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
const candidates = filtered.map((r) => ({
|
||||||
|
title: r.title,
|
||||||
|
year: parseInt(r.year, 10) || undefined,
|
||||||
|
type: stremioType,
|
||||||
|
season: targetSeason ?? undefined,
|
||||||
|
raw: r
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { best } = findBestMatch(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
year: year ?? undefined,
|
||||||
|
type: stremioType,
|
||||||
|
season: targetSeason ?? undefined
|
||||||
|
},
|
||||||
|
candidates,
|
||||||
|
{ provider: 'OneTouchTV' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return best ? best.raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchContent(keyword) {
|
||||||
|
const path = `/vod/search?keyword=${encodeURIComponent(keyword)}`;
|
||||||
|
try {
|
||||||
|
const results = await fetchEncrypted(path);
|
||||||
|
return Array.isArray(results) ? results : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[OneTouchTV] search failed for "${keyword}": ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDetail(id) {
|
||||||
|
try {
|
||||||
|
const result = await fetchEncrypted(`/vod/${id}/detail`);
|
||||||
|
return result ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[OneTouchTV] detail failed for ${id}: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEpisodeStreams(id, playId) {
|
||||||
|
try {
|
||||||
|
const result = await fetchEncrypted(`/vod/${id}/episode/${playId}`, 0);
|
||||||
|
return result ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[OneTouchTV] episode fetch failed ${id}/${playId}: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuality(quality) {
|
||||||
|
if (!quality) return 'Unknown';
|
||||||
|
const q = quality.toLowerCase();
|
||||||
|
if (q === 'auto') return 'Auto';
|
||||||
|
if (q.includes('1080')) return '1080p';
|
||||||
|
if (q.includes('720')) return '720p';
|
||||||
|
if (q.includes('480')) return '480p';
|
||||||
|
if (q.includes('360')) return '360p';
|
||||||
|
return quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAbsoluteEpisode(imdbId, season, episode) {
|
||||||
|
if (season <= 1) return episode;
|
||||||
|
|
||||||
|
const episodesPerSeason = await getEpisodesPerSeason(imdbId);
|
||||||
|
if (episodesPerSeason.length < season - 1) return null;
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
for (let s = 0; s < season - 1; s++) {
|
||||||
|
const count = episodesPerSeason[s] ?? 0;
|
||||||
|
if (count === 0) return null;
|
||||||
|
offset += count;
|
||||||
|
}
|
||||||
|
return offset + episode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOnetouchtvStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||||
|
console.log(`[OneTouchTV] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const type = mediaType === 'tv' ? 'series' : 'movie';
|
||||||
|
const tmdbType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||||
|
const details = await getDetails(tmdbType, tmdbId);
|
||||||
|
const title = (details && (details.title || details.name)) || '';
|
||||||
|
const year = (details && (details.release_date || details.first_air_date || '').slice(0, 4)) || null;
|
||||||
|
if (!title) {
|
||||||
|
console.log(`[OneTouchTV] No TMDB title resolved for ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const imdbId = await resolveImdbId(tmdbType, tmdbId);
|
||||||
|
|
||||||
|
const season = seasonNum || 1;
|
||||||
|
const episode = episodeNum || 1;
|
||||||
|
|
||||||
|
const searchKeywords = [title];
|
||||||
|
if (type === 'series' && season > 1) {
|
||||||
|
searchKeywords.unshift(`${title} Season ${season}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchResult = null;
|
||||||
|
for (const keyword of searchKeywords) {
|
||||||
|
const results = await searchContent(keyword);
|
||||||
|
const searchYear = keyword !== title && season > 1 ? null : year;
|
||||||
|
const match = findBestOttMatch(results, keyword, searchYear, type, season);
|
||||||
|
if (match) {
|
||||||
|
matchResult = match;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchResult) {
|
||||||
|
console.log(`[OneTouchTV] "${title}" not found`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imdbId && imdbId.startsWith('tt')) {
|
||||||
|
const matchedYear = matchResult.year ? parseInt(matchResult.year, 10) : undefined;
|
||||||
|
const resolvedId = await tmdbTitleToImdbId(
|
||||||
|
matchResult.title,
|
||||||
|
Number.isFinite(matchedYear) ? matchedYear : undefined,
|
||||||
|
type
|
||||||
|
).catch(() => null);
|
||||||
|
if (resolvedId && resolvedId !== imdbId) {
|
||||||
|
console.log(`[OneTouchTV] IMDB ID mismatch — rejecting match: expected ${imdbId}, resolved ${resolvedId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = await getDetail(matchResult.id);
|
||||||
|
if (!detail || !detail.episodes || detail.episodes.length === 0) {
|
||||||
|
console.log(`[OneTouchTV] no episodes in detail for "${matchResult.title}"`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetEpisode = null;
|
||||||
|
|
||||||
|
if (type === 'movie') {
|
||||||
|
targetEpisode = detail.episodes[0] ?? null;
|
||||||
|
} else {
|
||||||
|
const targetSeasonInTitle = new RegExp(`\\bseason\\s+${season}\\b`, 'i');
|
||||||
|
const anySeasonInTitle = /\bseason\s+(\d+)\b/i;
|
||||||
|
const matchedSeasonNum = anySeasonInTitle.exec(matchResult.title);
|
||||||
|
const entryIsForTargetSeason =
|
||||||
|
season === 1 || targetSeasonInTitle.test(matchResult.title);
|
||||||
|
|
||||||
|
if (
|
||||||
|
season > 1 &&
|
||||||
|
matchedSeasonNum !== null &&
|
||||||
|
parseInt(matchedSeasonNum[1], 10) !== season
|
||||||
|
) {
|
||||||
|
console.warn(`[OneTouchTV] matched title names a different season — aborting`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryIsForTargetSeason) {
|
||||||
|
targetEpisode = detail.episodes.find((e) => parseInt(e.episode, 10) === episode) ?? null;
|
||||||
|
} else {
|
||||||
|
let absEp = null;
|
||||||
|
if (imdbId) {
|
||||||
|
absEp = await resolveAbsoluteEpisode(imdbId, season, episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absEp !== null) {
|
||||||
|
targetEpisode = detail.episodes.find((e) => parseInt(e.episode, 10) === absEp) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetEpisode && season === 1) {
|
||||||
|
targetEpisode = detail.episodes.find((e) => parseInt(e.episode, 10) === episode) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetEpisode) {
|
||||||
|
console.log(`[OneTouchTV] episode not found (S${season}E${episode})`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamData = await getEpisodeStreams(matchResult.id, targetEpisode.playId);
|
||||||
|
if (!streamData) return [];
|
||||||
|
|
||||||
|
const sources = streamData.sources ?? [];
|
||||||
|
if (sources.length === 0) {
|
||||||
|
console.log(`[OneTouchTV] no sources in episode for "${matchResult.title}"`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitles = [];
|
||||||
|
for (const track of streamData.track ?? []) {
|
||||||
|
if (track.file && track.name) {
|
||||||
|
subtitles.push({ url: track.file, lang: track.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const src of sources) {
|
||||||
|
if (!src.url) continue;
|
||||||
|
const quality = resolveQuality(src.quality);
|
||||||
|
const sourceName = src.name ? ` | ${src.name}` : '';
|
||||||
|
out.push({
|
||||||
|
name: `OneTouchTV${sourceName}`,
|
||||||
|
title: `${matchResult.title}\n${quality} · ${(src.type ?? 'HLS').toUpperCase()}`,
|
||||||
|
url: src.url,
|
||||||
|
quality,
|
||||||
|
provider: 'OneTouchTV',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
'Referer': `${MAIN_URL}/`
|
||||||
|
},
|
||||||
|
...(subtitles.length ? { subtitles } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const streams = out.filter((s) => {
|
||||||
|
if (seen.has(s.url)) return false;
|
||||||
|
seen.add(s.url);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
console.log(`[OneTouchTV] Got ${streams.length} stream(s) for "${matchResult.title}"`);
|
||||||
|
return streams;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[OneTouchTV] Error: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getOnetouchtvStreams };
|
||||||
@@ -12,9 +12,14 @@ const providerFunctionMap = {
|
|||||||
'vixsrc.js': 'getVixsrcStreams',
|
'vixsrc.js': 'getVixsrcStreams',
|
||||||
'videasy.js': 'getVideasyStreams',
|
'videasy.js': 'getVideasyStreams',
|
||||||
'vidlink.js': 'getVidlinkStreams',
|
'vidlink.js': 'getVidlinkStreams',
|
||||||
'lordflix.js': 'getLordflixStreams',
|
|
||||||
'notorrent.js': 'getNotorrentStreams',
|
|
||||||
'dahmermovies.js': 'getDahmermoviesStreams',
|
'dahmermovies.js': 'getDahmermoviesStreams',
|
||||||
|
'streamflix.js': 'getStreamflixStreams',
|
||||||
|
'vaplayer.js': 'getVaplayerStreams',
|
||||||
|
'castletv.js': 'getCastletvStreams',
|
||||||
|
'hdghartv.js': 'getHdghartvStreams',
|
||||||
|
'netmirror.js': 'getNetmirrorStreams',
|
||||||
|
'onetouchtv.js': 'getOnetouchtvStreams',
|
||||||
|
'zxcstreams.js': 'getZxcstreamsStreams',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stats for debug endpoint
|
// Stats for debug endpoint
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const API_BASE = 'https://api.streamflix.app';
|
||||||
|
const FIREBASE_BASE = 'https://chilflix-410be-default-rtdb.asia-southeast1.firebasedatabase.app';
|
||||||
|
|
||||||
|
const DATA_TTL = 30 * 60 * 1000;
|
||||||
|
const CONFIG_TTL = 5 * 60 * 1000;
|
||||||
|
const EPISODES_TTL = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
let dataCache = null;
|
||||||
|
let configCache = null;
|
||||||
|
const episodesCache = new Map();
|
||||||
|
|
||||||
|
const REQUEST_HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json, */*',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getData() {
|
||||||
|
if (dataCache && Date.now() - dataCache.ts < DATA_TTL) return dataCache.items;
|
||||||
|
console.log('[StreamFlix] fetching data.json');
|
||||||
|
const res = await axios.get(`${API_BASE}/data.json`, { headers: REQUEST_HEADERS, timeout: 20000 });
|
||||||
|
const items = (res.data && res.data.data) || [];
|
||||||
|
dataCache = { items, ts: Date.now() };
|
||||||
|
console.log(`[StreamFlix] data.json cached (${items.length} items)`);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConfig() {
|
||||||
|
if (configCache && Date.now() - configCache.ts < CONFIG_TTL) return configCache.config;
|
||||||
|
const res = await axios.get(`${API_BASE}/config/config-streamflixapp.json`, { headers: REQUEST_HEADERS, timeout: 8000 });
|
||||||
|
configCache = { config: res.data, ts: Date.now() };
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEpisodes(movieKey, season) {
|
||||||
|
const cacheKey = `${movieKey}:${season}`;
|
||||||
|
const cached = episodesCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.ts < EPISODES_TTL) return cached.episodes;
|
||||||
|
|
||||||
|
const url = `${FIREBASE_BASE}/Data/${movieKey}/seasons/${season}/episodes.json`;
|
||||||
|
const res = await axios.get(url, { headers: REQUEST_HEADERS, timeout: 10000 });
|
||||||
|
|
||||||
|
const raw = res.data || {};
|
||||||
|
const episodes = {};
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
episodes[parseInt(k, 10)] = v;
|
||||||
|
}
|
||||||
|
episodesCache.set(cacheKey, { episodes, ts: Date.now() });
|
||||||
|
return episodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBases(config) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const url of config.download || []) {
|
||||||
|
if (url && !seen.has(url)) {
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtitleHint(filename) {
|
||||||
|
const f = (filename || '').toLowerCase();
|
||||||
|
if (f.includes('esub') || f.includes('.srt') || f.includes('.ass') || f.includes('sub')) {
|
||||||
|
return ' [Embedded Subs]';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStreamflixStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||||
|
console.log(`[StreamFlix] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [items, config] = await Promise.all([getData(), getConfig()]);
|
||||||
|
|
||||||
|
const match = items.find((item) => item.tmdb === String(tmdbId));
|
||||||
|
if (!match) {
|
||||||
|
console.log(`[StreamFlix] No match found for TMDB ID ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bases = downloadBases(config);
|
||||||
|
if (bases.length === 0) {
|
||||||
|
console.log(`[StreamFlix] No download CDN bases in config`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaType === 'movie') {
|
||||||
|
if (!match.movielink) return [];
|
||||||
|
const subs = subtitleHint(match.movielink);
|
||||||
|
return bases.map((base, i) => ({
|
||||||
|
name: 'StreamFlix',
|
||||||
|
title: `StreamFlix${i > 0 ? ` Mirror ${i}` : ''}${subs} | ${match.moviename}`,
|
||||||
|
url: `${base}${match.movielink}`,
|
||||||
|
quality: 'Auto',
|
||||||
|
provider: 'StreamFlix',
|
||||||
|
headers: { 'User-Agent': REQUEST_HEADERS['User-Agent'] }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonNum === null || episodeNum === null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const episodes = await getEpisodes(match.moviekey, seasonNum);
|
||||||
|
const ep = episodes[episodeNum - 1] || episodes[episodeNum];
|
||||||
|
|
||||||
|
if (ep && ep.link) {
|
||||||
|
const subs = subtitleHint(ep.link);
|
||||||
|
return bases.map((base, i) => ({
|
||||||
|
name: 'StreamFlix',
|
||||||
|
title: `StreamFlix${i > 0 ? ` Mirror ${i}` : ''}${subs} | ${match.moviename} S${seasonNum}E${episodeNum}${ep.name ? ` • ${ep.name}` : ''}`,
|
||||||
|
url: `${base}${ep.link}`,
|
||||||
|
quality: 'Auto',
|
||||||
|
provider: 'StreamFlix',
|
||||||
|
headers: { 'User-Agent': REQUEST_HEADERS['User-Agent'] }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[StreamFlix] Firebase fetch failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[StreamFlix] Error: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStreamflixStreams };
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const { resolveImdbId } = require('../utils/tmdb');
|
||||||
|
|
||||||
|
const STREAM_API = 'https://streamdata.vaplayer.ru/api.php';
|
||||||
|
const PLAYER_ORIGIN = 'https://nextgencloudfabric.com';
|
||||||
|
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
async function fetchStreamUrls(imdb, type, season, episode) {
|
||||||
|
const params = new URLSearchParams({ imdb, type });
|
||||||
|
if (type === 'tv' && season != null && episode != null) {
|
||||||
|
params.set('season', String(season));
|
||||||
|
params.set('episode', String(episode));
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${STREAM_API}?${params.toString()}`;
|
||||||
|
const referer = type === 'tv'
|
||||||
|
? `${PLAYER_ORIGIN}/embed/tv/${imdb}/${season}/${episode}`
|
||||||
|
: `${PLAYER_ORIGIN}/embed/movie/${imdb}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 12000);
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'Referer': referer,
|
||||||
|
'Origin': PLAYER_ORIGIN
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`VaPlayer API HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await resp.json();
|
||||||
|
if (json.status_code !== '200' && json.status_code !== 200) {
|
||||||
|
throw new Error(`VaPlayer API error: ${json.error || `status_code=${json.status_code}`}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (json.data && json.data.stream_urls) || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[VaPlayer] fetch failed for ${imdb}: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStreams(streamUrls, labelSuffix = '') {
|
||||||
|
return streamUrls.map((url, i) => ({
|
||||||
|
name: 'VaPlayer',
|
||||||
|
title: `VaPlayer${labelSuffix ? ` ${labelSuffix}` : ''} · HLS ${i + 1}`,
|
||||||
|
url,
|
||||||
|
quality: 'Auto',
|
||||||
|
provider: 'VaPlayer',
|
||||||
|
headers: {
|
||||||
|
'Referer': `${PLAYER_ORIGIN}/`,
|
||||||
|
'Origin': PLAYER_ORIGIN,
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVaplayerStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||||
|
console.log(`[VaPlayer] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imdbId = await resolveImdbId(mediaType === 'tv' ? 'tv' : 'movie', tmdbId);
|
||||||
|
if (!imdbId || !imdbId.startsWith('tt')) {
|
||||||
|
console.log(`[VaPlayer] No IMDB ID resolved for TMDB ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = mediaType === 'tv' && seasonNum !== null && episodeNum !== null
|
||||||
|
? await fetchStreamUrls(imdbId, 'tv', seasonNum, episodeNum)
|
||||||
|
: await fetchStreamUrls(imdbId, 'movie');
|
||||||
|
|
||||||
|
const streams = toStreams(urls, mediaType === 'tv' ? `S${seasonNum}E${episodeNum}` : '');
|
||||||
|
console.log(`[VaPlayer] Got ${streams.length} stream(s) for ${imdbId}`);
|
||||||
|
return streams;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[VaPlayer] Error: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getVaplayerStreams };
|
||||||
+154
-54
@@ -8,21 +8,91 @@ const VIDEASY_HEADERS = {
|
|||||||
'Referer': 'https://player.videasy.net/'
|
'Referer': 'https://player.videasy.net/'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Each server maps a name to its API URL. moviesOnly:true skips TV requests.
|
const VIDEASY_API = 'https://api.speedracelight.com';
|
||||||
|
|
||||||
|
// Each server maps a name to its API route. moviesOnly:true skips TV requests.
|
||||||
const SERVERS = {
|
const SERVERS = {
|
||||||
'Neon': { url: 'https://api.videasy.net/myflixerzupcloud/sources-with-title' },
|
'CDN': { url: `${VIDEASY_API}/cdn/sources-with-title` },
|
||||||
'Yoru': { url: 'https://api.videasy.net/cdn/sources-with-title', moviesOnly: true },
|
'LaMovie': { url: `${VIDEASY_API}/lamovie/sources-with-title` },
|
||||||
'Cypher': { url: 'https://api.videasy.net/moviebox/sources-with-title' },
|
'Meine': { url: `${VIDEASY_API}/meine/sources-with-title`, moviesOnly: true }
|
||||||
'Reyna': { url: 'https://api.videasy.net/primewire/sources-with-title' },
|
|
||||||
'Omen': { url: 'https://api.videasy.net/onionplay/sources-with-title' },
|
|
||||||
'Breach': { url: 'https://api.videasy.net/m4uhd/sources-with-title' },
|
|
||||||
'Ghost': { url: 'https://api.videasy.net/primesrcme/sources-with-title' },
|
|
||||||
'Sage': { url: 'https://api.videasy.net/1movies/sources-with-title' },
|
|
||||||
'Vyse': { url: 'https://api.videasy.net/hdmovie/sources-with-title' },
|
|
||||||
'Raze': { url: 'https://api.videasy.net/superflix/sources-with-title' }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DECRYPT_URL = 'https://enc-dec.app/api/dec-videasy';
|
// --- Seed-based response decryption (mirrors player.videasy.net) ---
|
||||||
|
const MAGIC = [109, 118, 109, 49]; // "mvm1"
|
||||||
|
const HASH_TABLE = [1116352408, 1899447441, 3049323471, 3921009573, 961987163, 1508970993, 2453635748, 2870763221, 3624381080, 310598401, 607225278, 1426881987, 1925078388, 2162078206, 2614888103, 3248222580];
|
||||||
|
|
||||||
|
function u32x(x) { return x >>> 0; }
|
||||||
|
function mul32(a, b) { return Math.imul(a, b) >>> 0; }
|
||||||
|
function rotl32(x, n) {
|
||||||
|
x >>>= 0;
|
||||||
|
n &= 31;
|
||||||
|
return n === 0 ? x : ((x << n) | (x >>> (32 - n))) >>> 0;
|
||||||
|
}
|
||||||
|
function hash32(x) {
|
||||||
|
x = u32x(x);
|
||||||
|
x ^= x >>> 16;
|
||||||
|
x = mul32(x, 2246822507);
|
||||||
|
x ^= x >>> 13;
|
||||||
|
x = mul32(x, 3266489909);
|
||||||
|
x ^= x >>> 16;
|
||||||
|
return u32x(x);
|
||||||
|
}
|
||||||
|
function fnv1a(str) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < str.length; i++) h = mul32(h ^ str.charCodeAt(i), 16777619);
|
||||||
|
return hash32(h);
|
||||||
|
}
|
||||||
|
function initStream(seed, secondKey) {
|
||||||
|
const S = new Array(61);
|
||||||
|
let a = u32x(hash32(fnv1a(seed) ^ hash32(u32x((secondKey >>> 0) ^ 2654435769))));
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if ((i * (i + 1) & 1) === 0) {
|
||||||
|
const idx = a % 61;
|
||||||
|
a = rotl32(a + u32x(2654435769), 7 + (7 & i));
|
||||||
|
S[idx] = u32x(a ^ hash32(a));
|
||||||
|
a = hash32(u32x(a + idx));
|
||||||
|
} else {
|
||||||
|
S[i] = HASH_TABLE[15 & i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { S, acc: u32x(hash32(2779096485 ^ a)) };
|
||||||
|
}
|
||||||
|
function nextByte(st, ctr) {
|
||||||
|
const r = st.S;
|
||||||
|
const o = st.acc;
|
||||||
|
const n = o % 61;
|
||||||
|
const inSet = 0 - Number(n in r);
|
||||||
|
const d = r[n] >>> 0;
|
||||||
|
const x = u32x(d ^ mul32(2654435769, ctr + 1));
|
||||||
|
const y = u32x((o ^ x) | (o & x & inSet));
|
||||||
|
const no = hash32(u32x(rotl32(u32x(y + o), 31 & n) ^ rotl32(o, 31 & Math.imul(n, 7))) + 2654435769);
|
||||||
|
r[n] = no >>> 0;
|
||||||
|
st.acc = no;
|
||||||
|
return no >>> 0;
|
||||||
|
}
|
||||||
|
function keystream(seed, secondKey, len) {
|
||||||
|
const st = initStream(seed, secondKey);
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
let ctr = 0;
|
||||||
|
for (let i = 0; i < len;) {
|
||||||
|
const b = nextByte(st, ctr++);
|
||||||
|
out[i++] = 255 & b;
|
||||||
|
if (i < len) out[i++] = (b >>> 8) & 255;
|
||||||
|
if (i < len) out[i++] = (b >>> 16) & 255;
|
||||||
|
if (i < len) out[i++] = (b >>> 24) & 255;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function decryptPayload(payload, seed, secondKey) {
|
||||||
|
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(4 * Math.ceil(payload.length / 4), '=');
|
||||||
|
const data = Buffer.from(b64, 'base64');
|
||||||
|
const ks = keystream(seed, secondKey, data.length);
|
||||||
|
for (let i = 0; i < data.length; i++) data[i] ^= ks[i];
|
||||||
|
for (let i = 0; i < MAGIC.length; i++) {
|
||||||
|
if (data[i] !== MAGIC[i]) throw new Error('Invalid encrypted payload');
|
||||||
|
}
|
||||||
|
return data.subarray(MAGIC.length).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
async function getVideasyStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
async function getVideasyStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||||
console.log(`[Videasy] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
console.log(`[Videasy] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||||
@@ -60,57 +130,87 @@ async function getVideasyStreams(tmdbId, mediaType = 'movie', seasonNum = null,
|
|||||||
const allStreams = [];
|
const allStreams = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
||||||
// Step 2: Query each server in parallel
|
// Step 2: Fetch a decryption seed (cached server-side per mediaId, short TTL)
|
||||||
await Promise.all(Object.entries(SERVERS).map(async ([name, server]) => {
|
let seed;
|
||||||
if (server.moviesOnly && mediaType === 'tv') return;
|
try {
|
||||||
|
const seedRes = await axios.get(`${VIDEASY_API}/seed?mediaId=${tmdbId}`, {
|
||||||
|
headers: VIDEASY_HEADERS,
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
seed = seedRes.data && seedRes.data.seed;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Videasy] Seed fetch failed: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!seed) {
|
||||||
|
console.error('[Videasy] No seed returned.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
let apiUrl = `${server.url}?title=${encodeURIComponent(details.title)}`
|
const queryServers = async (activeSeed) => {
|
||||||
+ `&mediaType=${details.type}&year=${details.year}`
|
await Promise.all(Object.entries(SERVERS).map(async ([name, server]) => {
|
||||||
+ `&tmdbId=${tmdbId}&imdbId=${details.imdbId || ''}`;
|
if (server.moviesOnly && mediaType === 'tv') return;
|
||||||
if (mediaType === 'tv') apiUrl += `&seasonId=${seasonNum}&episodeId=${episodeNum}`;
|
|
||||||
|
|
||||||
try {
|
let apiUrl = `${server.url}?title=${encodeURIComponent(details.title)}`
|
||||||
const encRes = await axios.get(apiUrl, {
|
+ `&mediaType=${details.type === 'tv' ? 'TV Series' : 'Movie'}&year=${details.year}`
|
||||||
headers: VIDEASY_HEADERS,
|
+ `&tmdbId=${tmdbId}&imdbId=${details.imdbId || ''}`;
|
||||||
timeout: 8000,
|
if (mediaType === 'tv') apiUrl += `&seasonId=${seasonNum}&episodeId=${episodeNum}`;
|
||||||
responseType: 'text'
|
apiUrl += `&enc=2&seed=${activeSeed}`;
|
||||||
});
|
|
||||||
|
|
||||||
const encryptedText = typeof encRes.data === 'string' ? encRes.data : JSON.stringify(encRes.data);
|
try {
|
||||||
if (!encryptedText || encryptedText.length < 20 || encryptedText.startsWith('<')) return;
|
const encRes = await axios.get(apiUrl, {
|
||||||
|
headers: VIDEASY_HEADERS,
|
||||||
// Step 3: Decrypt via enc-dec.app
|
timeout: 8000,
|
||||||
const decRes = await axios.post(DECRYPT_URL,
|
responseType: 'text'
|
||||||
{ text: encryptedText, id: String(tmdbId) },
|
|
||||||
{ headers: { 'Content-Type': 'application/json' }, timeout: 8000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const resData = (decRes.data && decRes.data.result) || decRes.data;
|
|
||||||
if (!resData || !Array.isArray(resData.sources)) return;
|
|
||||||
|
|
||||||
for (const s of resData.sources) {
|
|
||||||
if (!s.url || seen.has(s.url)) continue;
|
|
||||||
seen.add(s.url);
|
|
||||||
allStreams.push({
|
|
||||||
name: `Videasy ${name}`,
|
|
||||||
title: `Videasy ${name} - ${s.quality || 'Auto'}`,
|
|
||||||
url: s.url,
|
|
||||||
quality: s.quality || 'Auto',
|
|
||||||
provider: 'Videasy',
|
|
||||||
headers: {
|
|
||||||
'Referer': 'https://player.videasy.net/',
|
|
||||||
'Origin': 'https://player.videasy.net'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const encryptedText = typeof encRes.data === 'string' ? encRes.data : JSON.stringify(encRes.data);
|
||||||
|
if (!encryptedText || encryptedText.length < 20 || encryptedText.startsWith('<')) return;
|
||||||
|
|
||||||
|
// Step 3: Decrypt payload locally with the seed cipher
|
||||||
|
const plain = decryptPayload(encryptedText, activeSeed, String(tmdbId));
|
||||||
|
const resData = JSON.parse(plain);
|
||||||
|
if (!resData || !Array.isArray(resData.sources)) return;
|
||||||
|
|
||||||
|
for (const s of resData.sources) {
|
||||||
|
if (!s.url || seen.has(s.url)) continue;
|
||||||
|
seen.add(s.url);
|
||||||
|
allStreams.push({
|
||||||
|
name: `Videasy ${name}`,
|
||||||
|
title: `Videasy ${name} - ${s.quality || 'Auto'}`,
|
||||||
|
url: s.url,
|
||||||
|
quality: s.quality || 'Auto',
|
||||||
|
provider: 'Videasy',
|
||||||
|
headers: {
|
||||||
|
'Referer': 'https://player.videasy.net/',
|
||||||
|
'Origin': 'https://player.videasy.net'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`[Videasy] Server ${name}: ${resData.sources.length} source(s)`);
|
||||||
|
} catch {
|
||||||
|
// server unreachable or returned no data — skip silently
|
||||||
}
|
}
|
||||||
console.log(`[Videasy] Server ${name}: ${resData.sources.length} source(s)`);
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 4: Query each server in parallel; retry once with a fresh seed if nothing matched
|
||||||
|
await queryServers(seed);
|
||||||
|
if (allStreams.length === 0) {
|
||||||
|
try {
|
||||||
|
const seedRes = await axios.get(`${VIDEASY_API}/seed?mediaId=${tmdbId}`, {
|
||||||
|
headers: VIDEASY_HEADERS,
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
const freshSeed = seedRes.data && seedRes.data.seed;
|
||||||
|
if (freshSeed && freshSeed !== seed) await queryServers(freshSeed);
|
||||||
} catch {
|
} catch {
|
||||||
// server unreachable or returned no data — skip silently
|
// ignore retry failure
|
||||||
}
|
}
|
||||||
}));
|
}
|
||||||
|
|
||||||
console.log(`[Videasy] Total streams: ${allStreams.length}`);
|
console.log(`[Videasy] Total streams: ${allStreams.length}`);
|
||||||
return allStreams;
|
return allStreams;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getVideasyStreams };
|
module.exports = { getVideasyStreams };
|
||||||
+37
-14
@@ -5,6 +5,19 @@ const VIDLINK_HEADERS = {
|
|||||||
'Referer': 'https://vidlink.pro'
|
'Referer': 'https://vidlink.pro'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function qualityToNumber(quality) {
|
||||||
|
if (quality === '4k') return 2160;
|
||||||
|
if (quality === 'Auto' || quality === 'unknown') return 0;
|
||||||
|
const parsed = parseInt(quality, 10);
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualityLabel(quality) {
|
||||||
|
if (quality === '4k') return '4K';
|
||||||
|
if (quality === 'Auto' || quality === 'unknown') return 'Auto';
|
||||||
|
return `${quality}p`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getVidlinkStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
async function getVidlinkStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||||
console.log(`[Vidlink] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
console.log(`[Vidlink] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||||
|
|
||||||
@@ -20,32 +33,42 @@ async function getVidlinkStreams(tmdbId, mediaType = 'movie', seasonNum = null,
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Fetch stream playlist from Vidlink API
|
// Step 2: Fetch file-quality streams from Vidlink API
|
||||||
const apiUrl = mediaType === 'tv'
|
const apiUrl = mediaType === 'tv'
|
||||||
? `https://vidlink.pro/api/b/tv/${encodedTmdb}/${seasonNum}/${episodeNum}?multiLang=0`
|
? `https://vidlink.pro/api/b/tv/${encodedTmdb}/${seasonNum}/${episodeNum}?multiLang=0`
|
||||||
: `https://vidlink.pro/api/b/movie/${encodedTmdb}?multiLang=0`;
|
: `https://vidlink.pro/api/b/movie/${encodedTmdb}?multiLang=0`;
|
||||||
|
|
||||||
const apiRes = await axios.get(apiUrl, { headers: VIDLINK_HEADERS, timeout: 8000 });
|
const apiRes = await axios.get(apiUrl, { headers: VIDLINK_HEADERS, timeout: 8000 });
|
||||||
|
|
||||||
const playlist = apiRes.data && apiRes.data.stream && apiRes.data.stream.playlist;
|
const streamData = apiRes.data && apiRes.data.stream;
|
||||||
if (!playlist) {
|
if (!streamData || !streamData.qualities) {
|
||||||
console.log('[Vidlink] No playlist URL in response.');
|
console.log('[Vidlink] No quality streams in response.');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Vidlink] Got stream.`);
|
const streams = Object.entries(streamData.qualities)
|
||||||
return [{
|
.filter(([, entry]) => entry && entry.url)
|
||||||
name: 'Vidlink',
|
.sort((a, b) => qualityToNumber(b[0]) - qualityToNumber(a[0]))
|
||||||
title: 'Vidlink',
|
.map(([qualityKey, entry]) => ({
|
||||||
url: playlist,
|
name: 'Vidlink',
|
||||||
quality: 'Auto',
|
title: `Vidlink - ${qualityLabel(qualityKey)}`,
|
||||||
provider: 'Vidlink',
|
url: entry.url,
|
||||||
headers: { 'Referer': 'https://vidlink.pro' }
|
quality: qualityKey,
|
||||||
}];
|
provider: 'Vidlink',
|
||||||
|
headers: { 'Referer': 'https://vidlink.pro' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (streams.length === 0) {
|
||||||
|
console.log('[Vidlink] No usable stream URLs in response.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Vidlink] Got ${streams.length} stream(s).`);
|
||||||
|
return streams;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[Vidlink] Error: ${err.message}`);
|
console.error(`[Vidlink] Error: ${err.message}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getVidlinkStreams };
|
module.exports = { getVidlinkStreams };
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
const { createHash } = require('crypto');
|
||||||
|
const { getDetails, resolveImdbId } = require('../utils/tmdb');
|
||||||
|
|
||||||
|
const PORTALS = ['https://zxcstream.xyz', 'https://zxcprime.xyz'];
|
||||||
|
const INITIAL_BASE = 'https://r1.zxcstream.xyz';
|
||||||
|
const SALT = '3435443433';
|
||||||
|
const SERVERS = ['icarus', 'berkas', 'orion', 'athena'];
|
||||||
|
const BASE_TTL = 10 * 60 * 1000;
|
||||||
|
const PROBE_SUBDOMAINS = ['r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'v4', 'cdn', 'api', 'stream'];
|
||||||
|
|
||||||
|
const F = {
|
||||||
|
id: 'rgrwsdsdfgwrwrwwr',
|
||||||
|
fToken: 'xfgdfgdsffgrwgrwyjhkjt',
|
||||||
|
ts: 'rdghhdghhfssft',
|
||||||
|
token: 'ZDDVHJFGHYRHG',
|
||||||
|
title: 'TUKTHFSSFGDGHJS',
|
||||||
|
year: '53653TRFG647GF',
|
||||||
|
season: 'adkljfhdahfladhfjahfjlahfhfljkadfdf',
|
||||||
|
episode: '546745ygy46ytfgty',
|
||||||
|
imdbId: '564745ygtuy5yi75yuy'
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMON_HEADERS = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36',
|
||||||
|
'Accept': 'application/json, text/plain, */*'
|
||||||
|
};
|
||||||
|
|
||||||
|
let _base = INITIAL_BASE;
|
||||||
|
let _baseValidatedAt = 0;
|
||||||
|
let discoveryPromise = null;
|
||||||
|
|
||||||
|
function sha512Hex(data) {
|
||||||
|
return createHash('sha512').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyBase(base) {
|
||||||
|
const rt = Date.now();
|
||||||
|
const xt = sha512Hex(`${rt}:${SALT}:550`).slice(0, 64);
|
||||||
|
const r = await fetch(`${base}/backend/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...COMMON_HEADERS,
|
||||||
|
Origin: base,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Referer: `${base}/player/movie/550`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ [F.id]: '550', [F.fToken]: xt, [F.ts]: rt }),
|
||||||
|
signal: AbortSignal.timeout(6000)
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json();
|
||||||
|
if (d[F.token]) return base;
|
||||||
|
}
|
||||||
|
throw new Error(`verify failed: ${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryPortal(portal) {
|
||||||
|
const r = await fetch(portal, {
|
||||||
|
headers: { 'User-Agent': COMMON_HEADERS['User-Agent'] },
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
});
|
||||||
|
const redirectedBase = new URL(r.url).origin;
|
||||||
|
if (redirectedBase === new URL(portal).origin) {
|
||||||
|
throw new Error(`portal ${portal} did not redirect`);
|
||||||
|
}
|
||||||
|
return await verifyBase(redirectedBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeSubdomain(sub) {
|
||||||
|
return verifyBase(`https://${sub}.zxcstream.xyz`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverBase() {
|
||||||
|
const portalResults = await Promise.allSettled(PORTALS.map((portal) => tryPortal(portal)));
|
||||||
|
for (const r of portalResults) {
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
console.log(`[ZXCStreams] discovered base via portal redirect: ${r.value}`);
|
||||||
|
return r.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[ZXCStreams] both portals failed, falling back to subdomain probing');
|
||||||
|
const settled = await Promise.allSettled(PROBE_SUBDOMAINS.map((sub) => probeSubdomain(sub)));
|
||||||
|
for (const r of settled) {
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
console.log(`[ZXCStreams] discovered base via subdomain probe: ${r.value}`);
|
||||||
|
return r.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[ZXCStreams] all discovery methods failed, keeping last known base:', _base);
|
||||||
|
return _base;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBase() {
|
||||||
|
if (Date.now() - _baseValidatedAt <= BASE_TTL) {
|
||||||
|
return _base;
|
||||||
|
}
|
||||||
|
if (!discoveryPromise) {
|
||||||
|
discoveryPromise = discoverBase()
|
||||||
|
.then((base) => {
|
||||||
|
_base = base;
|
||||||
|
_baseValidatedAt = Date.now();
|
||||||
|
return base;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
discoveryPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return discoveryPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateBase() {
|
||||||
|
_baseValidatedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFrontendToken(tmdbId) {
|
||||||
|
const rt = Date.now();
|
||||||
|
const xt = sha512Hex(`${rt}:${SALT}:${tmdbId}`).slice(0, 64);
|
||||||
|
return { xt, rt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestServerToken(base, tmdbId, referer) {
|
||||||
|
const { xt, rt } = generateFrontendToken(tmdbId);
|
||||||
|
const body = JSON.stringify({
|
||||||
|
[F.id]: tmdbId,
|
||||||
|
[F.fToken]: xt,
|
||||||
|
[F.ts]: rt
|
||||||
|
});
|
||||||
|
const res = await fetch(`${base}/backend/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...COMMON_HEADERS,
|
||||||
|
Origin: base,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Referer: referer
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`token failed ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return { serverToken: data[F.token], serverTs: data[F.ts], xt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchServer(server, meta, type, season, episode) {
|
||||||
|
let base = await getBase();
|
||||||
|
|
||||||
|
const buildReferer = (b) =>
|
||||||
|
`${b}/player/${type}/${meta.tmdbId}${season != null ? `/${season}/${episode}` : ''}`;
|
||||||
|
|
||||||
|
let referer = buildReferer(base);
|
||||||
|
let tokenData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
tokenData = await requestServerToken(base, meta.tmdbId, referer);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ZXCStreams] token request failed on ${base}, re-discovering...`, err.message);
|
||||||
|
invalidateBase();
|
||||||
|
base = await getBase();
|
||||||
|
referer = buildReferer(base);
|
||||||
|
tokenData = await requestServerToken(base, meta.tmdbId, referer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serverToken, serverTs, xt } = tokenData;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
[F.id]: meta.tmdbId,
|
||||||
|
b: type,
|
||||||
|
[F.ts]: String(serverTs),
|
||||||
|
[F.token]: serverToken,
|
||||||
|
[F.fToken]: xt,
|
||||||
|
[F.title]: meta.title,
|
||||||
|
[F.year]: meta.year,
|
||||||
|
date: meta.releaseDate,
|
||||||
|
[F.imdbId]: meta.imdbId
|
||||||
|
};
|
||||||
|
if (season != null && episode != null) {
|
||||||
|
params[F.season] = String(season);
|
||||||
|
params[F.episode] = String(episode);
|
||||||
|
}
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
|
||||||
|
const res = await fetch(`${base}/backend_/servers/${server}?${qs}`, {
|
||||||
|
headers: { ...COMMON_HEADERS, Origin: base, Referer: referer }
|
||||||
|
});
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success || !Array.isArray(data.links)) return [];
|
||||||
|
|
||||||
|
const requestHeaders = {
|
||||||
|
Referer: referer,
|
||||||
|
Origin: base,
|
||||||
|
'User-Agent': COMMON_HEADERS['User-Agent']
|
||||||
|
};
|
||||||
|
|
||||||
|
return data.links
|
||||||
|
.filter((l) => l.link)
|
||||||
|
.map((l) => ({
|
||||||
|
server,
|
||||||
|
type: l.type || (l.link.includes('.m3u8') ? 'hls' : 'mp4'),
|
||||||
|
resolution: l.resolution ?? (l.source && l.source !== 'default' ? l.source : undefined) ?? '?',
|
||||||
|
size: l.size,
|
||||||
|
url: l.link,
|
||||||
|
requestHeaders
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllStreams(type, meta, season, episode) {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
SERVERS.map((s) => fetchServer(s, meta, type, season, episode))
|
||||||
|
);
|
||||||
|
const streams = [];
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === 'fulfilled') streams.push(...r.value);
|
||||||
|
}
|
||||||
|
return streams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolutionLabel(server, res) {
|
||||||
|
if (typeof res === 'number' && res <= 4) {
|
||||||
|
return ['360p', '480p', '720p', '1080p', '4K'][res] ?? `q${res}`;
|
||||||
|
}
|
||||||
|
return typeof res === 'number' ? `${res}p` : String(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes) return '';
|
||||||
|
const n = Number(bytes);
|
||||||
|
if (!Number.isFinite(n)) return '';
|
||||||
|
if (n > 1e9) return `${(n / 1e9).toFixed(2)} GB`;
|
||||||
|
if (n > 1e6) return `${(n / 1e6).toFixed(0)} MB`;
|
||||||
|
return `${(n / 1e3).toFixed(0)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getZxcstreamsStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
|
||||||
|
console.log(`[ZXCStreams] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const type = mediaType === 'tv' ? 'tv' : 'movie';
|
||||||
|
const tmdbType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||||
|
const details = await getDetails(tmdbType, tmdbId);
|
||||||
|
const title = (details && (details.title || details.name)) || '';
|
||||||
|
if (!title) {
|
||||||
|
console.log(`[ZXCStreams] No TMDB title resolved for ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const releaseDate = (details && (details.release_date || details.first_air_date || '').slice(0, 10)) || '';
|
||||||
|
const year = (details && (details.release_date || details.first_air_date || '').slice(0, 4)) || '';
|
||||||
|
const imdbId = await resolveImdbId(tmdbType, tmdbId);
|
||||||
|
if (!imdbId || !imdbId.startsWith('tt')) {
|
||||||
|
console.log(`[ZXCStreams] No IMDB ID resolved for TMDB ${tmdbId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
tmdbId: String(tmdbId),
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
releaseDate,
|
||||||
|
imdbId
|
||||||
|
};
|
||||||
|
|
||||||
|
const links = await getAllStreams(type, meta, seasonNum, episodeNum);
|
||||||
|
if (!links.length) {
|
||||||
|
console.log(`[ZXCStreams] No streams found for "${title}"`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const scored = links.map((l) => {
|
||||||
|
const r = typeof l.resolution === 'number' ? l.resolution : 0;
|
||||||
|
const height = r > 4 ? r : ([240, 480, 720, 1080, 2160][r] ?? 0);
|
||||||
|
return { l, score: (l.type === 'mp4' ? 10000 : 0) + height };
|
||||||
|
});
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
const streams = [];
|
||||||
|
for (const { l } of scored) {
|
||||||
|
const label = resolutionLabel(l.server, l.resolution);
|
||||||
|
const size = formatSize(l.size);
|
||||||
|
const kind = l.type === 'hls' ? 'HLS' : 'MP4';
|
||||||
|
const serverName =
|
||||||
|
l.server === 'icarus' ? 'Icarus' :
|
||||||
|
l.server === 'orion' ? 'Orion' :
|
||||||
|
l.server === 'athena' ? 'Athena' : 'Berkas';
|
||||||
|
const titleLine = [`${serverName} • ${label} • ${kind}`, size].filter(Boolean).join('\n');
|
||||||
|
streams.push({
|
||||||
|
name: `ZXCStreams ${serverName} ${label}`,
|
||||||
|
title: titleLine,
|
||||||
|
url: l.url,
|
||||||
|
quality: label,
|
||||||
|
provider: 'ZXCStreams',
|
||||||
|
headers: {
|
||||||
|
'Referer': l.requestHeaders.Referer,
|
||||||
|
'Origin': l.requestHeaders.Origin,
|
||||||
|
'User-Agent': l.requestHeaders['User-Agent']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ZXCStreams] Got ${streams.length} stream(s) for "${title}"`);
|
||||||
|
return streams;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ZXCStreams] Error: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getZxcstreamsStreams };
|
||||||
@@ -95,7 +95,9 @@ function extractOriginalUrl(proxyUrl) {
|
|||||||
while (decoded.includes('%2F')) {
|
while (decoded.includes('%2F')) {
|
||||||
try { decoded = decodeURIComponent(decoded); } catch { break; }
|
try { decoded = decodeURIComponent(decoded); } catch { break; }
|
||||||
}
|
}
|
||||||
return decoded;
|
if (decoded.startsWith('http://') || decoded.startsWith('https://')) return decoded;
|
||||||
|
// If it's just a path (like Vidlink /proxy/wiwii/...), keep the full original URL
|
||||||
|
return proxyUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (url.searchParams.has('url')) return decodeURIComponent(url.searchParams.get('url'));
|
if (url.searchParams.has('url')) return decodeURIComponent(url.searchParams.get('url'));
|
||||||
|
|||||||
@@ -48,6 +48,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="status-block">
|
<div class="status-block">
|
||||||
<h2>Providers</h2>
|
<h2>Providers</h2>
|
||||||
|
<div class="field" style="margin-bottom:.75rem;">
|
||||||
|
<label>Provider check TMDB ID<p class="hint">Title used by "Run Provider Functional Checks" (e.g. 278 = Shawshank Redemption).</p></label>
|
||||||
|
<div style="display:flex; align-items:center; gap:.5rem;">
|
||||||
|
<input type="text" id="providerCheckTmdbId" placeholder="278" style="width:7rem;" />
|
||||||
|
<button id="saveProviderCheckTmdbBtn" type="button">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button style="margin-bottom: 1rem;" id="runProviderChecksBtn" type="button">Run Provider Functional Checks</button>
|
<button style="margin-bottom: 1rem;" id="runProviderChecksBtn" type="button">Run Provider Functional Checks</button>
|
||||||
<span style="margin-left: .5rem; margin-bottom: 1rem;" id="providerCheckSummary" class="hint"></span>
|
<span style="margin-left: .5rem; margin-bottom: 1rem;" id="providerCheckSummary" class="hint"></span>
|
||||||
<div id="statusProvidersEnhanced" class="providers-table" aria-live="polite"></div>
|
<div id="statusProvidersEnhanced" class="providers-table" aria-live="polite"></div>
|
||||||
|
|||||||
+20
-2
@@ -337,7 +337,8 @@ async function clearAll(){
|
|||||||
enablePStreamApi: true,
|
enablePStreamApi: true,
|
||||||
disableUrlValidation: false,
|
disableUrlValidation: false,
|
||||||
disable4khdhubUrlValidation: false,
|
disable4khdhubUrlValidation: false,
|
||||||
showboxCacheDir: null
|
showboxCacheDir: null,
|
||||||
|
providerCheckTmdbId: null
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
const r = await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||||
@@ -411,6 +412,8 @@ async function loadServerStatus(){
|
|||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if(!j.success) throw new Error('Status error');
|
if(!j.success) throw new Error('Status error');
|
||||||
metricsEl.textContent = JSON.stringify(j.metrics, null, 2);
|
metricsEl.textContent = JSON.stringify(j.metrics, null, 2);
|
||||||
|
const checkTmdbEl = document.getElementById('providerCheckTmdbId');
|
||||||
|
if(checkTmdbEl) checkTmdbEl.value = j.providerCheckTmdbId || '278';
|
||||||
if(endpointsEl) endpointsEl.textContent = (j.endpoints||[]).join('\n');
|
if(endpointsEl) endpointsEl.textContent = (j.endpoints||[]).join('\n');
|
||||||
if(provEnhanced){
|
if(provEnhanced){
|
||||||
provEnhanced.innerHTML = '';
|
provEnhanced.innerHTML = '';
|
||||||
@@ -438,7 +441,8 @@ async function loadServerStatus(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function runProviderFunctionalChecks(){
|
async function runProviderFunctionalChecks(){
|
||||||
const targetTmdb = '550'; // TMDB ID provided
|
const checkTmdbEl = document.getElementById('providerCheckTmdbId');
|
||||||
|
const targetTmdb = (checkTmdbEl && checkTmdbEl.value.trim()) || '278'; // TMDB ID (editable on status page)
|
||||||
const provEnhanced = document.getElementById('statusProvidersEnhanced');
|
const provEnhanced = document.getElementById('statusProvidersEnhanced');
|
||||||
const summary = document.getElementById('providerCheckSummary');
|
const summary = document.getElementById('providerCheckSummary');
|
||||||
if(!provEnhanced) return;
|
if(!provEnhanced) return;
|
||||||
@@ -478,6 +482,18 @@ async function runProviderFunctionalChecks(){
|
|||||||
}
|
}
|
||||||
summary.textContent = `Checks complete: ${passed}/${rows.length} passed`;
|
summary.textContent = `Checks complete: ${passed}/${rows.length} passed`;
|
||||||
}
|
}
|
||||||
|
async function saveProviderCheckTmdbId(){
|
||||||
|
const input = document.getElementById('providerCheckTmdbId');
|
||||||
|
const val = (input ? input.value : '').trim();
|
||||||
|
if(!val){ setStatus('Enter a TMDB ID to save', true); return; }
|
||||||
|
if(!/^\d+$/.test(val)){ setStatus('TMDB ID must be numeric', true); return; }
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ providerCheckTmdbId: val })});
|
||||||
|
const js = await r.json();
|
||||||
|
if(!js.success) throw new Error(js.error||'Save failed');
|
||||||
|
setStatus('Provider check TMDB ID saved: '+val);
|
||||||
|
} catch(e){ setStatus(e.message, true); }
|
||||||
|
}
|
||||||
document.addEventListener('DOMContentLoaded', ()=> {
|
document.addEventListener('DOMContentLoaded', ()=> {
|
||||||
initTheme();
|
initTheme();
|
||||||
attachCoreListeners();
|
attachCoreListeners();
|
||||||
@@ -533,6 +549,8 @@ document.addEventListener('DOMContentLoaded', ()=> {
|
|||||||
if(rsb) rsb.addEventListener('click', loadServerStatus);
|
if(rsb) rsb.addEventListener('click', loadServerStatus);
|
||||||
const rpc = document.getElementById('runProviderChecksBtn');
|
const rpc = document.getElementById('runProviderChecksBtn');
|
||||||
if(rpc) rpc.addEventListener('click', runProviderFunctionalChecks);
|
if(rpc) rpc.addEventListener('click', runProviderFunctionalChecks);
|
||||||
|
const spc = document.getElementById('saveProviderCheckTmdbBtn');
|
||||||
|
if(spc) spc.addEventListener('click', saveProviderCheckTmdbId);
|
||||||
});
|
});
|
||||||
function toggleSidebar(force){
|
function toggleSidebar(force){
|
||||||
const sb = document.getElementById('sidebar');
|
const sb = document.getElementById('sidebar');
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1778930133625
|
1787175846578
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Episode count per season from Cinemeta.
|
||||||
|
// Ported from Infinite-streams (src/lib/cinemeta.ts getEpisodesPerSeason).
|
||||||
|
|
||||||
|
const CINEMETA_BASE = 'https://v3-cinemeta.strem.io';
|
||||||
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
const FETCH_TIMEOUT_MS = 12000;
|
||||||
|
|
||||||
|
const episodeCountCache = new Map();
|
||||||
|
|
||||||
|
async function getEpisodesPerSeason(imdbId) {
|
||||||
|
const cacheKey = `eps:${imdbId}`;
|
||||||
|
const cached = episodeCountCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${CINEMETA_BASE}/meta/series/${imdbId}.json`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'Stremio Addon' },
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
episodeCountCache.set(cacheKey, { data: [], ts: Date.now() });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
const videos = (json.meta && json.meta.videos) || [];
|
||||||
|
if (videos.length === 0) {
|
||||||
|
episodeCountCache.set(cacheKey, { data: [], ts: Date.now() });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonMaxEp = new Map();
|
||||||
|
for (const v of videos) {
|
||||||
|
if (!v.season || v.season <= 0) continue;
|
||||||
|
const ep = v.number || v.episode || 0;
|
||||||
|
if (ep > 0) {
|
||||||
|
const prev = seasonMaxEp.get(v.season) || 0;
|
||||||
|
seasonMaxEp.set(v.season, Math.max(prev, ep));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonMaxEp.size === 0) {
|
||||||
|
episodeCountCache.set(cacheKey, { data: [], ts: Date.now() });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSeason = Math.max(...seasonMaxEp.keys());
|
||||||
|
const result = [];
|
||||||
|
for (let s = 1; s <= maxSeason; s++) {
|
||||||
|
result.push(seasonMaxEp.get(s) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
episodeCountCache.set(cacheKey, { data: result, ts: Date.now() });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Cinemeta] getEpisodesPerSeason error: ${err.message}`);
|
||||||
|
episodeCountCache.set(cacheKey, { data: [], ts: Date.now() });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getEpisodesPerSeason };
|
||||||
+7
-1
@@ -114,6 +114,8 @@ function normalizeConfig(base) {
|
|||||||
// 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;
|
||||||
|
// TMDB ID used by the dashboard's "Run Provider Functional Checks" feature
|
||||||
|
if (!cfg.providerCheckTmdbId) cfg.providerCheckTmdbId = '278';
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +146,8 @@ function applyConfigToEnv(cfg){
|
|||||||
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';
|
process.env.ENABLE_PROXY = cfg.enableProxy ? 'true':'false';
|
||||||
|
if (cfg.providerCheckTmdbId) process.env.PROVIDER_CHECK_TMDB_ID = String(cfg.providerCheckTmdbId);
|
||||||
|
else delete process.env.PROVIDER_CHECK_TMDB_ID;
|
||||||
// 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
|
||||||
@@ -173,7 +177,9 @@ function loadConfig() {
|
|||||||
showboxCacheDir: process.env.SHOWBOX_CACHE_DIR || null,
|
showboxCacheDir: process.env.SHOWBOX_CACHE_DIR || null,
|
||||||
// Proxy/env paths removed; always direct connections
|
// Proxy/env paths removed; always direct connections
|
||||||
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,
|
||||||
|
enableProxy: process.env.ENABLE_PROXY,
|
||||||
|
providerCheckTmdbId: process.env.PROVIDER_CHECK_TMDB_ID || null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dynamic provider enable flags from env
|
// Dynamic provider enable flags from env
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// Universal search-result matching system.
|
||||||
|
// Ported from Infinite-streams (src/utils/match.ts) for title-search providers.
|
||||||
|
|
||||||
|
const QUALITY_TAGS =
|
||||||
|
/\b(4k|2160p|1080p|720p|480p|360p|hd|fhd|uhd|hdr|web[-\s]?dl|webrip|web|bluray|blu-ray|brrip|bdrip|dvdrip|hdrip|hdtv|hdcam|cam|dual audio|multi audio|dual[-\s]?audio|multi[-\s]?audio|x264|x265|hevc|h264|h265|10bit|esub|esubs|msubs|amzn|nf|hin|eng|hindi|english|dubbed|dub|subbed|sub|season\s*\d+|s\d{1,2}|complete|full)\b/gi;
|
||||||
|
|
||||||
|
const STOP_WORDS = new Set(['the', 'a', 'an', 'of', 'in', 'at', 'to', 'with', 'and', 'or', 'for', 'on']);
|
||||||
|
|
||||||
|
function baseNormalize(s) {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/([a-z])\./g, '$1')
|
||||||
|
.replace(/[^\w\s]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStripQuality(s) {
|
||||||
|
return baseNormalize(String(s).replace(QUALITY_TAGS, ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(s) {
|
||||||
|
return baseNormalize(s).split(' ').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function significantTokens(s) {
|
||||||
|
return tokenize(s).filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bigrams(s) {
|
||||||
|
const clean = baseNormalize(s).replace(/\s+/g, '');
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < clean.length - 1; i++) out.push(clean.slice(i, i + 2));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diceCoefficient(a, b) {
|
||||||
|
const ba = bigrams(a);
|
||||||
|
const bb = bigrams(b);
|
||||||
|
if (ba.length === 0 || bb.length === 0) return ba.length === bb.length ? 1 : 0;
|
||||||
|
const counts = new Map();
|
||||||
|
for (const g of ba) counts.set(g, (counts.get(g) || 0) + 1);
|
||||||
|
let matches = 0;
|
||||||
|
for (const g of bb) {
|
||||||
|
const c = counts.get(g) || 0;
|
||||||
|
if (c > 0) {
|
||||||
|
matches++;
|
||||||
|
counts.set(g, c - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (2 * matches) / (ba.length + bb.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreTitleText(query, candidate) {
|
||||||
|
if (!query || !candidate) {
|
||||||
|
return { exact: 0, normalized: 0, fuzzy: 0, wholeWord: 0, startsWith: 0, score: 0 };
|
||||||
|
}
|
||||||
|
const exact = String(query).trim().toLowerCase() === String(candidate).trim().toLowerCase() ? 1 : 0;
|
||||||
|
|
||||||
|
const nq = normalizeStripQuality(query);
|
||||||
|
const nc = normalizeStripQuality(candidate);
|
||||||
|
let normalized = nq === nc ? 1 : 0;
|
||||||
|
if (!normalized && nq.replace(/\s+/g, '') === nc.replace(/\s+/g, '')) normalized = 0.92;
|
||||||
|
|
||||||
|
const tokenJaccardOf = () => {
|
||||||
|
const qa = new Set(significantTokens(nq));
|
||||||
|
const ca = new Set(significantTokens(nc));
|
||||||
|
if (qa.size === 0 || ca.size === 0) return 0;
|
||||||
|
let intersection = 0;
|
||||||
|
for (const t of qa) if (ca.has(t)) intersection++;
|
||||||
|
const union = qa.size + ca.size - intersection;
|
||||||
|
return union === 0 ? 0 : intersection / union;
|
||||||
|
};
|
||||||
|
const fuzzy = 0.5 * tokenJaccardOf() + 0.5 * diceCoefficient(nq, nc);
|
||||||
|
|
||||||
|
const qSig = significantTokens(nq);
|
||||||
|
const cSigSet = new Set(significantTokens(nc));
|
||||||
|
const wholeWord = qSig.length === 0 ? 0 : qSig.filter((w) => cSigSet.has(w)).length / qSig.length;
|
||||||
|
|
||||||
|
let startsWith = 0;
|
||||||
|
if (nc.startsWith(nq) || nq.startsWith(nc)) startsWith = 1;
|
||||||
|
else {
|
||||||
|
const qFirst = nq.split(' ')[0];
|
||||||
|
const cFirst = nc.split(' ')[0];
|
||||||
|
if (qFirst && cFirst && qFirst === cFirst) startsWith = 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = exact * 0.35 + normalized * 0.25 + fuzzy * 0.2 + wholeWord * 0.1 + startsWith * 0.1;
|
||||||
|
return { exact, normalized, fuzzy, wholeWord, startsWith, score: Math.min(1, score) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestTitleScore(query, candidate) {
|
||||||
|
const attempts = [{ label: 'title', text: candidate.title }];
|
||||||
|
if (candidate.originalTitle) attempts.push({ label: 'originalTitle', text: candidate.originalTitle });
|
||||||
|
for (const alias of candidate.aliases || []) attempts.push({ label: `alias:${alias}`, text: alias });
|
||||||
|
|
||||||
|
const queryTitles = [query.title, query.originalTitle, ...(query.aliases || [])].filter((t) => !!t);
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
for (const q of queryTitles) {
|
||||||
|
for (const a of attempts) {
|
||||||
|
const breakdown = scoreTitleText(q, a.text);
|
||||||
|
if (!best || breakdown.score > best.score) {
|
||||||
|
best = { score: breakdown.score, breakdown, matchedOn: a.label };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best || { score: 0, breakdown: scoreTitleText('', ''), matchedOn: 'title' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreYear(query, candidate) {
|
||||||
|
if (!query || !candidate) return 0.5;
|
||||||
|
const diff = Math.abs(query - candidate);
|
||||||
|
if (diff === 0) return 1;
|
||||||
|
if (diff === 1) return 0.6;
|
||||||
|
if (diff === 2) return 0.3;
|
||||||
|
return 0.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreType(query, candidate) {
|
||||||
|
if (!query || !candidate) return 0.5;
|
||||||
|
return query === candidate ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreSeasonEpisode(query, candidate) {
|
||||||
|
if (query.type !== 'series') return 1;
|
||||||
|
if (query.season == null || candidate.season == null) return 0.7;
|
||||||
|
if (query.season !== candidate.season) return 0.15;
|
||||||
|
if (query.episode == null || candidate.episode == null) return 1;
|
||||||
|
return query.episode === candidate.episode ? 1 : 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(n) {
|
||||||
|
return Math.round(n * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCandidate(query, candidate) {
|
||||||
|
const text = bestTitleScore(query, candidate);
|
||||||
|
const year = scoreYear(query.year, candidate.year);
|
||||||
|
const type = scoreType(query.type, candidate.type);
|
||||||
|
const seasonEpisode = scoreSeasonEpisode(query, candidate);
|
||||||
|
const score = text.score * 0.6 + year * 0.1 + type * 0.2 + seasonEpisode * 0.1;
|
||||||
|
return {
|
||||||
|
score: Math.min(1, Math.max(0, score)),
|
||||||
|
matchedOn: text.matchedOn,
|
||||||
|
breakdown: {
|
||||||
|
textScore: round(text.score),
|
||||||
|
exact: round(text.breakdown.exact),
|
||||||
|
normalized: round(text.breakdown.normalized),
|
||||||
|
fuzzy: round(text.breakdown.fuzzy),
|
||||||
|
wholeWord: round(text.breakdown.wholeWord),
|
||||||
|
startsWith: round(text.breakdown.startsWith),
|
||||||
|
year: round(year),
|
||||||
|
type: round(type),
|
||||||
|
seasonEpisode: round(seasonEpisode),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_THRESHOLD = 0.45;
|
||||||
|
|
||||||
|
function findBestMatch(query, candidates, options) {
|
||||||
|
const threshold = options.threshold || DEFAULT_THRESHOLD;
|
||||||
|
const ranked = candidates
|
||||||
|
.map((candidate) => {
|
||||||
|
const { score, breakdown, matchedOn } = scoreCandidate(query, candidate);
|
||||||
|
return { candidate, score, breakdown, matchedOn };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
if (!options.quiet) {
|
||||||
|
console.log(`[Match:${options.provider}] scored candidates`, {
|
||||||
|
provider: options.provider,
|
||||||
|
query: options.query || query.title,
|
||||||
|
resultCount: candidates.length,
|
||||||
|
candidates: ranked.map((r) => ({
|
||||||
|
title: r.candidate.title,
|
||||||
|
year: r.candidate.year,
|
||||||
|
type: r.candidate.type,
|
||||||
|
score: round(r.score),
|
||||||
|
matchedOn: r.matchedOn,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = ranked[0];
|
||||||
|
if (!top) {
|
||||||
|
console.log(`[Match:${options.provider}] no candidates returned by search`);
|
||||||
|
return { best: null, score: 0, breakdown: {}, matchedOn: '', reason: 'no candidates returned by search', ranked };
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = top.score >= threshold;
|
||||||
|
const reason = passed
|
||||||
|
? `best candidate "${top.candidate.title}" scored ${round(top.score)} (>= threshold ${threshold}) via ${top.matchedOn}`
|
||||||
|
: `best candidate "${top.candidate.title}" scored ${round(top.score)} which is below threshold ${threshold} — rejected as a likely mismatch`;
|
||||||
|
|
||||||
|
console.log(`[Match:${options.provider}] ${passed ? 'selected result' : 'rejected — no match above threshold'}`, {
|
||||||
|
provider: options.provider,
|
||||||
|
query: options.query || query.title,
|
||||||
|
resultCount: candidates.length,
|
||||||
|
selected: passed ? top.candidate.title : null,
|
||||||
|
score: round(top.score),
|
||||||
|
threshold,
|
||||||
|
breakdown: top.breakdown,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!passed) {
|
||||||
|
return { best: null, score: top.score, breakdown: top.breakdown, matchedOn: top.matchedOn, reason, ranked };
|
||||||
|
}
|
||||||
|
return { best: top.candidate, score: top.score, breakdown: top.breakdown, matchedOn: top.matchedOn, reason, ranked };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findBestMatchWithRetry(query, variantTitles, search, options) {
|
||||||
|
const threshold = options.threshold || DEFAULT_THRESHOLD;
|
||||||
|
const tried = new Set();
|
||||||
|
let bestSoFar = null;
|
||||||
|
|
||||||
|
for (const variant of variantTitles) {
|
||||||
|
const key = String(variant).trim().toLowerCase();
|
||||||
|
if (!key || tried.has(key)) continue;
|
||||||
|
tried.add(key);
|
||||||
|
|
||||||
|
let candidates;
|
||||||
|
try {
|
||||||
|
candidates = await search(variant);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Match:${options.provider}] retry search failed:`, err.message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!candidates || !candidates.length) continue;
|
||||||
|
|
||||||
|
const result = findBestMatch({ ...query, title: variant }, candidates, { ...options, query: variant });
|
||||||
|
if (!bestSoFar || result.score > bestSoFar.score) bestSoFar = result;
|
||||||
|
if (result.best && result.score >= threshold) {
|
||||||
|
console.log(`[Match:${options.provider}] retry succeeded with variant "${variant}"`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestSoFar && bestSoFar.score > 0) {
|
||||||
|
console.log(`[Match:${options.provider}] no variant cleared threshold — treating as not found (near-miss logged, not returned)`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Match:${options.provider}] no results found across any title variant`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
best: null,
|
||||||
|
score: bestSoFar ? bestSoFar.score : 0,
|
||||||
|
breakdown: bestSoFar ? bestSoFar.breakdown : {},
|
||||||
|
matchedOn: bestSoFar ? bestSoFar.matchedOn : '',
|
||||||
|
reason: bestSoFar
|
||||||
|
? `best candidate across all variants scored ${round(bestSoFar.score)}, below threshold ${threshold} — rejected`
|
||||||
|
: 'no results found across any title variant',
|
||||||
|
ranked: bestSoFar ? bestSoFar.ranked : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRetryTitleVariants(opts) {
|
||||||
|
const variants = [opts.title, opts.originalTitle, ...(opts.aliases || [])].filter((t) => !!t && String(t).trim().length > 0);
|
||||||
|
return [...new Set(variants)];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { findBestMatch, findBestMatchWithRetry, buildRetryTitleVariants, scoreCandidate, normalizeStripQuality };
|
||||||
+4
-3
@@ -1,5 +1,5 @@
|
|||||||
const fetch = (...args) => import('node-fetch').then(m => m.default(...args));
|
const fetch = (...args) => import('node-fetch').then(m => m.default(...args));
|
||||||
const { config } = require('./config');
|
const { getTmdbApiKey } = require('./tmdbKey');
|
||||||
|
|
||||||
const cache = {
|
const cache = {
|
||||||
imdbByTmdb: new Map(),
|
imdbByTmdb: new Map(),
|
||||||
@@ -9,9 +9,10 @@ const TTL_MS = 6 * 60 * 60 * 1000;
|
|||||||
function isFresh(entry) { return entry && (Date.now() - entry.ts) < TTL_MS; }
|
function isFresh(entry) { return entry && (Date.now() - entry.ts) < TTL_MS; }
|
||||||
|
|
||||||
async function tmdbFetchJson(url) {
|
async function tmdbFetchJson(url) {
|
||||||
if (!config.tmdbApiKey) throw new Error('TMDB_API_KEY missing');
|
const apiKey = getTmdbApiKey();
|
||||||
|
if (!apiKey) throw new Error('TMDB_API_KEY missing');
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
const sep = url.includes('?') ? '&' : '?';
|
||||||
const full = `${url}${sep}api_key=${config.tmdbApiKey}`;
|
const full = `${url}${sep}api_key=${apiKey}`;
|
||||||
const res = await fetch(full, { timeout: 15000 });
|
const res = await fetch(full, { timeout: 15000 });
|
||||||
if (!res.ok) throw new Error(`TMDB request failed ${res.status}`);
|
if (!res.ok) throw new Error(`TMDB request failed ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Resolve the IMDB ID TMDB associates with a given matched title + year.
|
||||||
|
// Ported from Infinite-streams (src/lib/tmdb-verify.ts). Uses the repo's
|
||||||
|
// configured TMDB key. Returns null when inconclusive — callers must treat
|
||||||
|
// null as "pass through", never reject on it.
|
||||||
|
|
||||||
|
const { getTmdbApiKey } = require('./tmdbKey');
|
||||||
|
const { findBestMatch } = require('./titleMatch');
|
||||||
|
|
||||||
|
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
||||||
|
|
||||||
|
const cache = new Map();
|
||||||
|
const TTL = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
async function tmdbTitleToImdbId(title, year, type) {
|
||||||
|
const key = `${title.toLowerCase()}|${year || ''}|${type}`;
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit && Date.now() - hit.ts < TTL) return hit.imdbId;
|
||||||
|
|
||||||
|
const set = (v) => {
|
||||||
|
cache.set(key, { imdbId: v, ts: Date.now() });
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiKey = getTmdbApiKey();
|
||||||
|
if (!apiKey) return set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmdbType = type === 'series' ? 'tv' : 'movie';
|
||||||
|
const yearParam = year ? `&${type === 'series' ? 'first_air_date_year' : 'year'}=${year}` : '';
|
||||||
|
const searchUrl =
|
||||||
|
`${TMDB_BASE}/search/${tmdbType}` +
|
||||||
|
`?api_key=${apiKey}&query=${encodeURIComponent(title)}${yearParam}&page=1`;
|
||||||
|
|
||||||
|
const searchRes = await fetch(searchUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!searchRes.ok) return set(null);
|
||||||
|
|
||||||
|
const searchData = await searchRes.json();
|
||||||
|
const results = searchData.results || [];
|
||||||
|
if (results.length === 0) return set(null);
|
||||||
|
|
||||||
|
const candidates = results.slice(0, 10).map((r) => {
|
||||||
|
const dateStr = r.first_air_date || r.release_date;
|
||||||
|
const candidateYear = dateStr ? parseInt(dateStr.slice(0, 4), 10) : undefined;
|
||||||
|
return {
|
||||||
|
title: r.name || r.title || '',
|
||||||
|
year: Number.isFinite(candidateYear) ? candidateYear : undefined,
|
||||||
|
type,
|
||||||
|
raw: r,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { best } = findBestMatch(
|
||||||
|
{ title, year, type },
|
||||||
|
candidates,
|
||||||
|
{ provider: 'tmdb-verify', query: title, quiet: true },
|
||||||
|
);
|
||||||
|
if (!best) return set(null);
|
||||||
|
const topId = best.raw.id;
|
||||||
|
|
||||||
|
const extUrl = `${TMDB_BASE}/${tmdbType}/${topId}/external_ids?api_key=${apiKey}`;
|
||||||
|
const extRes = await fetch(extUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!extRes.ok) return set(null);
|
||||||
|
|
||||||
|
const extData = await extRes.json();
|
||||||
|
return set(extData.imdb_id || null);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[tmdb-verify] lookup failed: ${err.message}`);
|
||||||
|
return set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { tmdbTitleToImdbId };
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
"enableUhdmoviesProvider": false,
|
"enableUhdmoviesProvider": false,
|
||||||
"enableVidlinkProvider": true,
|
"enableVidlinkProvider": true,
|
||||||
"enableVideasyProvider": true,
|
"enableVideasyProvider": true,
|
||||||
"enableLordflixProvider": true,
|
"enableLordflixProvider": false,
|
||||||
"enableNotorrentProvider": true
|
"enableNotorrentProvider": false,
|
||||||
}
|
"enableDahmermoviesProvider": true,
|
||||||
|
"port": 8787
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user