14 Commits
Author SHA1 Message Date
Inside4ndroid aacac098f2 Update CHANGELOG for version 1.3.0: add new providers, utilities, and documentation improvements; fix various bugs and enhance configuration options. 2026-08-19 23:39:49 +01:00
Inside4ndroid 2c9a2cabbf Add new providers for streaming services and utility functions
- Implement StreamFlix provider for fetching movie and episode streams.
- Add VaPlayer provider to retrieve streaming URLs for movies and TV shows.
- Introduce ZXCStreams provider for accessing various streaming links.
- Create utility functions for fetching episode counts from Cinemeta.
- Develop a title matching system for improved search results.
- Implement TMDB title to IMDB ID resolution for better integration with TMDB API.
2026-08-19 23:35:13 +01:00
Inside4ndroid 01f06f807e Merge pull request #57 from venxm14/main
Fix bugs related to ENABLE_PROXY and Proxy URL handling
2026-06-21 20:20:31 +01:00
venxm14 74bf677f83 Fixed Proxy URL stripper bug and HTTP protocol bug 2026-06-21 21:13:45 +05:30
venxm14 d03b047e89 Fixed ENABLE_PROXY environment variable bug 2026-06-21 21:08:43 +05:30
Inside4ndroid 671bbb6159 Clear user config 2026-05-29 20:31:50 +01:00
Inside4ndroid 47d2886fbe chore: bump to 1.1.1, remove MoviesMod & VidZee providers, fix LordFlix domain, fix Docker proxy crash 2026-05-29 20:29:01 +01:00
Inside4ndroid d0419321a4 Huge update see changelog for details. 2026-05-16 12:29:45 +01:00
Inside4ndroid 35b95809de Release v1.0.9: Remove Showbox provider, cleanup dependencies 2025-11-19 13:11:30 +00:00
Inside4ndroid 6ebb0d32d5 chore(release): 1.0.8 2025-10-03 16:27:54 +01:00
Inside4ndroid c8ef89398f chore(release): bump to 1.0.7 (VidZee AES decoder, tail prefetch instrumentation) 2025-09-21 14:53:13 +01:00
Inside4ndroid 9714ad51c8 docs: update README and CHANGELOG for v1.0.6 proxy features 2025-09-20 15:31:55 +01:00
Inside4ndroid 17079e1646 chore: provider updates (4khdhub permanent r2.dev block + zip strip, moviesclub vidora headers) 2025-09-19 10:51:38 +01:00
Inside4ndroid 25cc59659d docs: update README (unified stream schema, MoviesClub, registry mapping, docker run)\nchore(release): 1.0.4 tag prep\nchore: ignore restart.trigger and .vscode 2025-09-18 21:00:21 +01:00
41 changed files with 5100 additions and 5985 deletions
+5
View File
@@ -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
+4
View File
@@ -1 +1,5 @@
/node_modules /node_modules
restart.trigger
.vscode/
.env
/tmp_context
+225 -8
View File
@@ -2,6 +2,209 @@
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
### Removed
- **MoviesMod provider**: Permanently removed — `moviesmod.farm` returns HTTP 403 with Cloudflare managed challenge (`cf-mitigated: challenge`). No server-side workaround exists without a browser runtime.
- **VidZee provider**: Permanently removed — hardcoded AES-256-GCM salt is no longer valid against VidZee's current API key blob, causing all decryption to fail.
### Fixed
- **LordFlix provider**: Updated upstream domain (`network.hasta-la-vista.site` → `snowhouse.lordflix.club`) and refreshed server list (10 servers).
- **Docker startup crash**: Added missing `COPY proxy ./proxy` to both build and runtime stages in `Dockerfile` — `apiServer.js` unconditionally requires `./proxy/proxyServer` at startup.
---
## [1.1.0] - 2026-05-16
### 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.
### Added
- **Videasy provider**: 10-server parallel scraper via enc-dec.app decrypt relay.
- **Vidlink provider**: Single-stream HLS source via enc-dec.app encode relay.
- **LordFlix provider**: 9-server scraper via enc-dec.app (replaces defunct Vidsync).
- **NoTorrent provider**: Stremio addon API bridge (`addon-osvh.onrender.com`).
- **DahmerMovies provider**: Open-directory direct file link scraper with proxy rewrite.
- **GitHub Sponsors button**: Official iframe embed in admin sidebar and README badge.
### Removed
- **Vidsync provider**: All 6 servers returned HTTP 401 — API locked. Replaced by LordFlix.
- **MP4Hydra provider**: Dead upstream, removed entirely.
- **UHDMovies provider**: Dead upstream, removed entirely.
- **vidsrcextractor.js**: Unused legacy file removed.
### Fixed
- **Provider enable/disable not working at runtime**: Removed the startup-time enabled/disabled filter. All providers are now always loaded into the registry array. `listProviders()` and `getProvider()` now call `isProviderEnabled(name)` which reads the live `config` object on every call. Changes take effect immediately without a server restart.
- **Showbox/FebBox provider**: Restored with correct `responseType:'text'`, AJAX headers, and double `ui=` cookie bug fix.
- **MoviesMod provider**: Updated class-based selectors and added support for `links.modpro.blog`, `cloud.unblockedgames.world`, `tech.examdegree.site`, and `driveleech.net` domains.
- **VixSrc provider**: Corrected to proper 2-step API flow.
---
## [1.0.9] - 2025-11-19
### Removed
- **Showbox/FebBox/PStream provider**: Permanently removed due to PStream API being protected by Cloudflare bot detection, making it inaccessible without complex proxy infrastructure
- Deleted `providers/Showbox.js` and backup files
- Removed FebBox cookie management from configuration panel
- Removed `FEBBOX_COOKIES`, `SHOWBOX_CACHE_DIR`, and related environment variables
- Cleaned up all Showbox-specific logic from provider registry
- Removed FebBox/PStream configuration UI panel and related functions
- **Unused dependencies**: Removed 121 packages including puppeteer, patchright, puppeteer-extra, puppeteer-extra-plugin-stealth, and vm2 (~200MB+ saved)
- **Unused utility files**: Removed `cloudflareBypasser.js` and `jsunpack.js`
### Fixed
- **VidZee provider**: Updated to support two-stage AES decryption with dynamic API keys. The provider now fetches an encrypted key from `https://core.vidzee.wtf/api-key`, decrypts it using AES-256-GCM with a hardcoded key, then uses the result to decrypt video URLs with AES-256-CBC. Includes 1-hour API key caching for performance.
- **4khdhub provider**: Added support for `links.modpro.blog` domain (site migrated from `modrefer.in`).
- **moviesmod provider**: Added support for `links.modpro.blog` domain alongside existing `modrefer.in`.
- **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.
---
## [1.0.8] - 2025-10-03
### Added
- Added `processStreamsForProxy` so the aggregate and provider-specific endpoints automatically rewrite stream URLs through the internal `/m3u8-proxy`/`/ts-proxy` layer whenever `enableProxy` is turned on, stripping provider headers safely.
### Changed
- Showbox provider overhaul: filesystem-only caching (Redis removed), smarter FebBox cookie selection with region fallbacks, TMDB title/image validation that recognises romanized names, plus cached HEAD size lookups for faster listings.
- Vixsrc provider rewritten to parse the `window.masterPlaylist` payload (token + expiry), returning a single master playlist with English subtitle lookup and correct Referer headers.
- Config loader now prefers `utils/user-config.json`, dedupes TMDB keys, defaults provider enable flags to `true`, and scrubs legacy proxy env values when mirroring overrides back to `process.env`.
- 4KHDHub provider permanently drops `.zip` archive links instead of trimming extensions and improves host distribution logging.
- Provider registry dynamically enumerates available modules (removing MoviesClub/Xprime remnants) while keeping per-request cookie stats for the admin debug panel.
### Removed
- Deleted legacy `providers/moviesclub.js`, `providers/xprime.js`, and other unused proxy/auth remnants that were no longer referenced.
### Fixed
- Aggregated responses now obey the proxy flag without client changes—streams returned from `/api/streams/...` are proxy-wrapped and omit upstream header hints when `enableProxy` is active.
---
## [1.0.7] - 2025-09-21
### Added
- VidZee provider: definitive AES-256-CBC decoder (replaced heuristic token attempts). Decodes newly obfuscated `atob(token) => ivBase64:cipherBase64` structure using padded key `qrincywincyspider` and PKCS7.
- Tail segment acceleration: internal tail prefetch map (`tailPrefetchMap`) with TTL cleanup and configurable `tailPrefetchKB` (default 256 KB) now documented and instrumented.
### Changed
- Proxy range negotiation: clarified interaction between `progressiveOpen` and synthetic initial partial (auto-suppression when progressive active). Additional debug logging around tail cache hits and forced host overrides.
- VidZee streams now always return direct decrypted URLs (per-stream `originalToken` retained for debugging when `VIDZEE_DEBUG=1`).
- Updated README version badge to 1.0.7.
### Fixed
- Eliminated edge cases where encoded VidZee tokens were leaking through without decoding.
- Reduced VLC initial loop behavior via tail prefetch serving last-byte probes from cache faster (combined with earlier 1.0.6 range logic).
### Notes
- Future proxy tuning items (size meta map, dynamic progressive growth) tracked but not part of this release.
---
## [1.0.6] - 2025-09-20
### Removed
- MoviesClub provider (multi-server scraping complexities & Turnstile challenge; deprecated permanently)
- Xprime provider (upstream Xprime.tv offline due to security changes)
- 4khdhub provider: all `.zip` archive links are now omitted entirely (previous releases experimented with stripping the extension which produced non-playable pseudo-MKV links)
### Added
- Structured multi-server debug instrumentation (session summaries, per-fetch metrics, pattern counters)
- Turnstile challenge detection & bypass attempt scaffold (synthetic `/rcp_verify` token posting)
- Optional internal stream proxy (`enableProxy` flag): mounts `/m3u8-proxy`, `/ts-proxy`, `/sub-proxy` with playlist + segment + subtitle handling and segment prefetch cache.
- Proxy range management features:
- `clampOpen` (default on) – caps ambiguous open-ended `bytes=0-` requests to a bounded initial window (`openChunkKB`, default 4096 KB)
- `progressiveOpen` (default on) – incremental expansion of the head range on successive `bytes=0-` requests instead of a single huge span
- `initChunkKB` (default 512 KB) – size of the synthetic initial 206 response when neither clamp/progressive produce a range and `noSynth` is not set
- `tailPrefetch` (default on) + `tailPrefetchKB` (default 256 KB) – asynchronous fetch & in-memory cache of the file tail to satisfy rapid VLC tail probes
- `force200` (opt-in) – normalizes upstream 206 responses to 200 for diagnostics
- `noSynth` (opt-in) – disables synthetic initial partial response generation
- Tail prefetch TTL cleanup task (30 min window) and in-memory maps for: segment cache, open range clamp, progressive growth, and tail buffers
- Host routing overrides: `pixeldrain.*` & `video-downloads.googleusercontent.com` are forced through `/ts-proxy` (extensionless or ambiguous content)
### Changed
- Centralized multi-server request headers with realistic `sec-ch-ua*` & `Sec-Fetch-*` values
- Added retry, rotating User-Agent, and cookie jar logic to multi-server fetch pipeline
- Showbox provider priority map updated after Xprime removal
- README/Docs trimmed to reflect current active providers only
- When `enableProxy` is active, stream response objects have their original `headers` field removed (proxy handles all required headers internally)
- 4khdhub provider now filters out archive endpoints instead of attempting extension normalization (prevents feeding ZIP files to players)
- Open-ended range handling improved to reduce VLC negotiation loops by throttling first-pass read size and growing progressively
- Synthetic initial partial response is automatically suppressed when `progressiveOpen` is active (real range growth preferred)
### Fixed
- Ensured multi-server fallback attempts (direct rcp player/m3u8 extraction) operate with improved diagnostics
- Eliminated repeated VLC tail probe stalls caused by archive masquerading as video content (root cause was filtered by dropping `.zip` URLs)
### Documentation
- Updated README version badge to 1.0.6 and provider list (removed MoviesClub & Xprime, clarified active providers list)
- Added proxy tuning parameter reference (clamp/progressive/tail prefetch, synthetic partial, force200) and host override notes
- Expanded explanation that per-stream headers are stripped when proxying is enabled
---
## [1.0.5] - 2025-09-19
### Improved
- 4khdhub provider: Permanently block `r2.dev` FSL links (previous optional flag removed).
- Added Referer/Origin headers automatically for FSL Server links during validation (prior to block enforcement ensured proper behavior).
- Tightened URL validation: removed unconditional trust for `r2.dev`; validation logic now consistent across hosts.
- Host distribution instrumentation logs final hostname counts for easier diagnostics.
- Preserved HubCloud worker `.zip` links by stripping the `.zip` extension instead of discarding them (enables direct playback attempts).
### Notes
- `r2.dev` links are always removed from final output; no env flag required.
---
## [1.0.4] - 2025-09-18
### Changed
- Unified stream object schema standardized:
```json
{ "title": "…", "url": "…", "quality": "…", "provider": "…", "headers": { } }
```
---
## [1.0.3] - 2025-09-17 ## [1.0.3] - 2025-09-17
### Fixed ### Fixed
@@ -10,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
@@ -18,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
@@ -42,19 +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.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
## [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.
@@ -91,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
+2
View File
@@ -12,6 +12,7 @@ RUN npm install --omit=dev
# Copy only required source (avoid sending screenshots, node_modules already installed) # Copy only required source (avoid sending screenshots, node_modules already installed)
COPY apiServer.js ./ COPY apiServer.js ./
COPY providers ./providers COPY providers ./providers
COPY proxy ./proxy
COPY public ./public COPY public ./public
COPY utils ./utils COPY utils ./utils
COPY README.md ./ COPY README.md ./
@@ -33,6 +34,7 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apiServer.js ./ COPY --from=build /app/apiServer.js ./
COPY --from=build /app/public ./public COPY --from=build /app/public ./public
COPY --from=build /app/providers ./providers COPY --from=build /app/providers ./providers
COPY --from=build /app/proxy ./proxy
COPY --from=build /app/utils ./utils COPY --from=build /app/utils ./utils
COPY --from=build /app/package.json ./ COPY --from=build /app/package.json ./
COPY --from=build /app/README.md ./ COPY --from=build /app/README.md ./
+197 -62
View File
@@ -6,7 +6,7 @@
<img src="https://img.shields.io/badge/Node.js-18%2B-brightgreen?style=flat" /> <img src="https://img.shields.io/badge/Node.js-18%2B-brightgreen?style=flat" />
<img src="https://img.shields.io/badge/Status-Active-success?style=flat" /> <img src="https://img.shields.io/badge/Status-Active-success?style=flat" />
<img src="https://img.shields.io/badge/License-MIT-blue?style=flat" /> <img src="https://img.shields.io/badge/License-MIT-blue?style=flat" />
<img src="https://img.shields.io/badge/Version-1.0.3-informational?style=flat" /> <img src="https://img.shields.io/badge/Version-1.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,15 +33,32 @@
--- ---
## ✨ 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, 4khdhub, MoviesMod, MP4Hydra, VidZee, Vixsrc, Xprime) 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`). 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 provider registry pattern. - **Optional Stream Proxy Layer** – When enabled, rewrites returned stream URLs so HLS playlists, TS segments, and subtitles are served through internal endpoints (`/m3u8-proxy`, `/ts-proxy`, `/sub-proxy`) allowing uniform headers, origin shielding, and optional segment caching.
When active the API omits per-stream `headers` objects from responses (they're no longer needed by clients) to avoid leaking upstream header requirements.
Proxy Tuning Parameters (query flags accepted by `/ts-proxy` – defaults shown):
- `clampOpen` (on) – If a client sends an ambiguous `Range: bytes=0-`, constrain it to an initial window of `openChunkKB` (default 4096 KB) to avoid huge first reads.
- `openChunkKB=4096` – Size (KB) used for both clamp window and each progressive expansion increment.
- `progressiveOpen` (on) – Grow successive ambiguous head requests (`bytes=0-`) incrementally instead of one large span. Maintains a per-URL expansion map.
- `initChunkKB=512` – Size used for a synthetic initial partial (206) when no client range is provided and progressive growth is disabled. Capped 64–2048 KB.
- `noSynth=1` – Disable synthetic initial partial generation (forces pass-through behavior).
- `force200=1` – Normalize upstream 206 responses to 200 (diagnostics / edge player testing).
- `tailPrefetch` (on) – Enable asynchronous tail fetch of the file's last bytes to satisfy rapid player tail probes.
- `tailPrefetchKB=256` – Tail window size (64–2048 KB). Cached in memory with TTL cleanup.
Behavior Notes:
- Synthetic partials auto-disable when `progressiveOpen` is active (real progressive ranges preferred).
- Player tail probes (e.g., VLC metadata scans) are accelerated by the cached tail window.
- Forced 200 mode strips `Content-Range` to emulate full responses for troubleshooting.
- Host Overrides: `pixeldrain.*` and `video-downloads.googleusercontent.com` URLs are routed through `/ts-proxy` regardless of extension to ensure correct range + MIME handling.
--- ---
@@ -66,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
@@ -79,6 +98,11 @@ docker run --name tmdb-embed-api -p 8787:8787 \
inside4ndroid/tmdb-embed-api:latest inside4ndroid/tmdb-embed-api:latest
``` ```
Or the minimal quick-test run:
```bash
docker run -it -p 8787:8787 inside4ndroid/tmdb-embed-api:latest
```
Persist overrides (Windows PowerShell example) by mounting a local file: Persist overrides (Windows PowerShell example) by mounting a local file:
```powershell ```powershell
New-Item -ItemType File -Path .\utils\user-config.json -Force | Out-Null New-Item -ItemType File -Path .\utils\user-config.json -Force | Out-Null
@@ -100,14 +124,13 @@ 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
``` ```
Environment variables can be supplied via a `.env` file in the same directory (Compose automatically loads it). Example `.env`: Environment variables can be supplied via a `.env` file in the same directory (Compose automatically loads it). Example `.env`:
``` ```
TMDB_API_KEY=first_key TMDB_API_KEY=first_key
FEBBOX_COOKIES=cookie1,cookie2
``` ```
To stop & remove: To stop & remove:
@@ -124,39 +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 |
| `FEBBOX_COOKIES` | Comma separated FebBox cookies | Enables Showbox provider immediately | | `DEFAULT_PROVIDERS` | Comma-separated default providers | Used by the aggregate endpoint |
| `PASSWORD_HASH` | Pre-seed admin password hash | Optional (UI can set) | | `DEFAULT_REGION` / `FEBBOX_REGION` | Default region | Passed through for legacy compat |
| `ADMIN_USERNAME` | Override default username | Default: `admin` | | `MIN_QUALITIES` | Min quality setting / JSON | Mirrored to the config model |
| `EXCLUDE_CODECS` | Codec exclusion JSON | e.g. `{"excludeDV":true}` |
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. | `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 }`) |
@@ -164,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).
--- ---
@@ -178,9 +214,10 @@ Saving in the UI writes only changed keys. Setting a field to empty removes the
**Override File:** `utils/user-config.json` **Override File:** `utils/user-config.json`
```json ```json
{ {
"defaultProviders": ["showbox","4khdhub"], "defaultProviders": ["4khdhub"],
"tmdbApiKeys": ["KEY_A","KEY_B"], "tmdbApiKeys": ["KEY_A","KEY_B"],
"enableShowboxProvider": true "enable4khdhubProvider": true,
"providerCheckTmdbId": "278"
} }
``` ```
@@ -192,7 +229,6 @@ Saving in the UI writes only changed keys. Setting a field to empty removes the
| Core | Port, default providers, default region | | Core | Port, default providers, default region |
| Quality / Filters | Min quality presets & codec exclusion JSON | | Quality / Filters | Min quality presets & codec exclusion JSON |
| Keys | Add/remove TMDB API keys (rotated randomly) | | Keys | Add/remove TMDB API keys (rotated randomly) |
| FebBox / PStream | Manage FebBox cookies powering Showbox provider |
| Advanced | Provider toggles, cache & validation flags | | Advanced | Provider toggles, cache & validation flags |
| Server Status | Live metrics, provider functional checks | | Server Status | Live metrics, provider functional checks |
| Live Config | View merged + override JSON snapshots | | Live Config | View merged + override JSON snapshots |
@@ -202,22 +238,75 @@ Session is revalidated on visibility and back/forward navigation to prevent stal
--- ---
## 🔌 Providers ## 🔌 Providers
Current built‑in providers: The API supports a plugin system. Drop a new provider file in the `providers/` folder and register its exported function in `providers/registry.js` under `providerFunctionMap`.
- `showbox`
- `4khdhub`
- `moviesmod`
- `mp4hydra`
- `vidzee`
- `vixsrc`
- `xprime`
You can enable/disable and mark defaults via the Provider Matrix in the UI. If **no defaults** are selected, all enabled providers participate in aggregation. ### Current Built-in Providers
- `showbox` - Showbox/FebBox streams (requires FebBox JWT cookie)
- `4khdhub` - 4KHDHub streams
- `vixsrc` - VixSrc streams
- `videasy` - Videasy streams (10 servers via enc-dec.app)
- `vidlink` - Vidlink streams
- `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)
### Adding a New Provider (Outline) Providers that rely on title resolution against TMDB (`castletv`, `hdghartv`, `onetouchtv`, `zxcstreams`, `vaplayer`, `netmirror`) require at least one TMDB API key configured.
1. Create `providers/myprovider.js` exporting an async fetch function (see existing files for patterns).
2. Lazy‑load it in `providers/registry.js` similar to others. ### Adding a New Provider
3. Add a config flag (`enableMyproviderProvider`) if you want toggle support. 1. **Create** `providers/yourprovider.js` with your stream fetching logic
4. Update the provider matrix arrays in `public/index.js`. 2. **Export** a function like `getYourproviderStreams(tmdbId, mediaType, season, episode)`
3. **Register** it in `providers/registry.js` → `providerFunctionMap`:
```js
// providers/registry.js
const providerFunctionMap = {
'Showbox.js': 'getStreamsFromTmdbId',
'4khdhub.js': 'get4KHDHubStreams',
'vixsrc.js': 'getVixsrcStreams',
'videasy.js': 'getVideasyStreams',
'vidlink.js': 'getVidlinkStreams',
'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'
};
```
4. The provider will appear in the admin UI with an enable/disable toggle.
**Example Provider (Unified Output):**
```javascript
async function getYourproviderStreams(tmdbId, mediaType, season, episode) {
// Your scraping/API logic here
return [{
name: "YourProvider",
title: "Fight Club - 1080p [YourProvider #1]",
url: "https://stream.url/video.mp4",
quality: "1080p",
provider: "yourprovider",
headers: { "User-Agent": "Mozilla/5.0" }
}];
}
module.exports = { getYourproviderStreams };
```
> **⚠️ Important**: All providers must return streams in the unified JSON format to ensure compatibility with filtering and aggregation.
The system automatically:
- ✅ Detects new provider files
- ✅ Adds enable/disable toggles in the admin UI
- ✅ Includes them in stream aggregation
- ✅ Applies filtering and quality controls
- ✅ No core file edits required!
--- ---
@@ -226,43 +315,74 @@ You can enable/disable and mark defaults via the Provider Matrix in the UI. If *
|----------|-------------| |----------|-------------|
| `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 Shape (Typical) ## 🧪 Stream Object Schema (Unified)
```json ```json
{ {
"title": "Example Title", "name": "ProviderDisplay",
"url": "https://...", "title": "Fight Club - 1080p [YourProvider #1]",
"url": "https://stream.url/video.mp4",
"quality": "1080p", "quality": "1080p",
"provider": "showbox", "provider": "yourprovider",
"size": null, "headers": { "User-Agent": "Mozilla/5.0" },
"languages": ["en"], "subtitles": [ { "url": "https://.../en.srt", "lang": "English" } ]
"subtitles": [],
"codecs": ["H264"],
"headers": { "Referer": "..." }
} }
``` ```
- `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.
---
## ⚙️ Configuration Flags (Advanced Panel)
| Flag | Default | Purpose |
|------|---------|---------|
| `enable<Name>Provider` | true | Enable/disable a provider (e.g. `enableCastletvProvider`) |
| `disableCache` | false | Disables internal caches |
| `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.
--- ---
## 🧩 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", "showbox": "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 }
@@ -270,11 +390,20 @@ Filtering passes through `applyFilters` to enforce min quality + codec exclusion
--- ---
## 📊 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.
--- ---
@@ -284,6 +413,7 @@ Filtering passes through `applyFilters` to enforce min quality + codec exclusion
| 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 |
@@ -294,11 +424,12 @@ For ephemeral platforms (e.g., Vercel) note that some providers use temporary di
## 💡 Troubleshooting ## 💡 Troubleshooting
| Symptom | Cause / Fix | | Symptom | Cause / Fix |
|---------|------------| |---------|------------|
| No streams from Showbox | Missing FebBox cookies |
| TMDB quota issues | Add more keys under Keys panel | | TMDB quota issues | Add more keys under Keys panel |
| 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* |
--- ---
@@ -313,14 +444,18 @@ PRs welcome. Keep changes focused and avoid unrelated formatting churn. For new
## ❤️ Sponsorship ## ❤️ Sponsorship
If this project helps you, consider sponsoring to support continued development & maintenance: If this project helps you, consider sponsoring to support continued development & maintenance:
➡️ **GitHub Sponsors:** https://github.com/sponsors/Inside4ndroid <p align="center">
<a href="https://github.com/sponsors/Inside4ndroid">
<img src="https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-ea4aaa?style=for-the-badge&logo=github-sponsors&logoColor=white" alt="Sponsor on GitHub" />
</a>
</p>
Every contribution accelerates feature delivery & sustainability. Every contribution accelerates feature delivery & sustainability.
--- ---
## 📜 License ## 📜 License
MIT. See `LICENSE`. MIT.
--- ---
+23 -2
View File
@@ -3,13 +3,23 @@ const express = require('express');
const cors = require('cors'); const cors = require('cors');
const os = require('os'); const os = require('os');
const { config, saveConfigPatch, OVERRIDE_PATH } = require('./utils/config'); const { config, saveConfigPatch, OVERRIDE_PATH } = require('./utils/config');
const { authenticate, issueSession, requireAuth, getSession, updatePassword, AUTH_FILE } = require('./utils/auth'); const { authenticate, issueSession, requireAuth, getSession, updatePassword } = require('./utils/auth');
const path = require('path'); const path = require('path');
const { listProviders, getProvider, getCookieStats } = require('./providers/registry'); const { listProviders, getProvider, getCookieStats } = require('./providers/registry');
const { createProxyRoutes, processStreamsForProxy } = require('./proxy/proxyServer');
const { resolveImdbId } = require('./utils/tmdb'); const { resolveImdbId } = require('./utils/tmdb');
const { applyFilters } = require('./utils/streamFilters'); const { applyFilters } = require('./utils/streamFilters');
const app = express(); const app = express();
app.set('trust proxy', 1);
// Conditionally mount proxy routes early so downstream handlers can use them
if (config.enableProxy) {
console.log('[startup] enableProxy flag active: mounting proxy routes');
createProxyRoutes(app);
} else {
console.log('[startup] enableProxy flag disabled: proxy routes not mounted');
}
// --- Simple In-Memory Rate Limiting for /auth/login --- // --- Simple In-Memory Rate Limiting for /auth/login ---
const loginAttempts = new Map(); // key: ip, value: { count, first, last, lockedUntil } const loginAttempts = new Map(); // key: ip, value: { count, first, last, lockedUntil }
@@ -271,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,
@@ -340,6 +350,12 @@ app.get('/api/streams/:type/:tmdbId', async (req,res) => {
let streams = results.flat(); let streams = results.flat();
streams = applyFilters(streams, 'aggregate', config.minQualities, config.excludeCodecs); streams = applyFilters(streams, 'aggregate', config.minQualities, config.excludeCodecs);
metrics.streamsReturned += streams.length; metrics.streamsReturned += streams.length;
if (config.enableProxy) {
const serverUrl = `${req.protocol}://${req.get('host')}`;
streams = processStreamsForProxy(streams, serverUrl);
// Omit original headers when proxying to avoid leaking upstream requirements
streams = streams.map(s => { if (s && typeof s === 'object') { const { headers, ...rest } = s; return rest; } return s; });
}
res.json({ success:true, tmdbId, imdbId, count: streams.length, providerTimings, streams }); res.json({ success:true, tmdbId, imdbId, count: streams.length, providerTimings, streams });
} catch (e) { } catch (e) {
metrics.lastError = e.message; metrics.lastError = e.message;
@@ -366,6 +382,11 @@ app.get('/api/streams/:provider/:type/:tmdbId', async (req,res) => {
const providerTimings = { [prov.name]: Date.now()-t0 }; const providerTimings = { [prov.name]: Date.now()-t0 };
streams = applyFilters(streams, prov.name, config.minQualities, config.excludeCodecs); streams = applyFilters(streams, prov.name, config.minQualities, config.excludeCodecs);
metrics.streamsReturned += streams.length; metrics.streamsReturned += streams.length;
if (config.enableProxy) {
const serverUrl = `${req.protocol}://${req.get('host')}`;
streams = processStreamsForProxy(streams, serverUrl);
streams = streams.map(s => { if (s && typeof s === 'object') { const { headers, ...rest } = s; return rest; } return s; });
}
res.json({ success:true, provider: prov.name, tmdbId, imdbId, count: streams.length, providerTimings, streams }); res.json({ success:true, provider: prov.name, tmdbId, imdbId, count: streams.length, providerTimings, streams });
} catch (e) { } catch (e) {
metrics.lastError = e.message; metrics.lastError = e.message;
+402 -1093
View File
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -1,19 +1,19 @@
{ {
"name": "tmdb-embed-api", "name": "tmdb-embed-api",
"version": "1.0.3", "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": {
"start": "nodemon --watch restart.trigger apiServer.js", "start": "nodemon --watch restart.trigger apiServer.js",
"start:dev": "nodemon --watch restart.trigger --ignore providers/.cache --ignore **/tmdb-embed-showbox-cache apiServer.js", "start:dev": "nodemon --watch restart.trigger --ignore providers/.cache --ignore **/cache apiServer.js",
"lint": "eslint . --ext .js" "lint": "eslint . --ext .js"
}, },
"dependencies": { "dependencies": {
"aromanize": "^0.1.5", "aromanize": "^0.1.5",
"axios": "^1.9.0", "axios": "^1.9.0",
"axios-cookiejar-support": "^6.0.2", "axios-cookiejar-support": "^7.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"cheerio": "^1.0.0", "cheerio": "^1.2.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
@@ -23,18 +23,16 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"puppeteer": "^24.11.0",
"string-similarity": "^4.0.4", "string-similarity": "^4.0.4",
"tough-cookie": "^5.1.2", "tough-cookie": "^5.1.2",
"ttl-cache": "^1.0.2", "ttl-cache": "^1.0.2",
"vm2": "^3.9.19",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.10", "eslint": "^8.57.0",
"eslint": "^8.57.0" "nodemon": "^3.1.10"
} }
} }
+230 -75
View File
@@ -9,6 +9,9 @@ const path = require('path');
const DOMAINS_URL = 'https://raw.githubusercontent.com/phisher98/TVVVV/refs/heads/main/domains.json'; const DOMAINS_URL = 'https://raw.githubusercontent.com/phisher98/TVVVV/refs/heads/main/domains.json';
let cachedDomains = null; let cachedDomains = null;
// Permanent policy: block r2.dev links (previously optional via env flag)
console.log('[4KHDHub] r2.dev links will be removed (permanent policy)');
// --- Caching Configuration --- // --- Caching Configuration ---
const CACHE_ENABLED = process.env.DISABLE_CACHE !== 'true'; const CACHE_ENABLED = process.env.DISABLE_CACHE !== 'true';
console.log(`[4KHDHub] Internal cache is ${CACHE_ENABLED ? 'enabled' : 'disabled'}.`); console.log(`[4KHDHub] Internal cache is ${CACHE_ENABLED ? 'enabled' : 'disabled'}.`);
@@ -61,8 +64,7 @@ function validateUrl(url) {
// Skip validation for known reliable hosting services // Skip validation for known reliable hosting services
const trustedHosts = [ const trustedHosts = [
'pixeldrain.dev', 'pixeldrain.dev'
'r2.dev'
]; ];
const isTrustedHost = trustedHosts.some(host => urlObj.hostname.includes(host)); const isTrustedHost = trustedHosts.some(host => urlObj.hostname.includes(host));
@@ -74,11 +76,21 @@ function validateUrl(url) {
const protocol = urlObj.protocol === 'https:' ? https : http; const protocol = urlObj.protocol === 'https:' ? https : http;
// Add referer/origin for r2.dev validation attempts
const extraHeaders = {};
if (/r2\.dev$/i.test(urlObj.hostname)) {
const origin = `${urlObj.protocol}//${urlObj.hostname}`;
extraHeaders['Referer'] = origin + '/';
extraHeaders['Origin'] = origin;
console.log(`[4KHDHub] Adding Referer/Origin headers for validation of r2.dev URL: ${url}`);
}
const options = { const options = {
method: 'HEAD', method: 'HEAD',
timeout: 15000, timeout: 15000,
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...extraHeaders
} }
}; };
@@ -112,7 +124,11 @@ 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;
let redirectCount = 0;
const doRequest = (currentUrl) => {
const urlObj = new URL(currentUrl);
const isHttps = urlObj.protocol === 'https:'; const isHttps = urlObj.protocol === 'https:';
const httpModule = isHttps ? https : http; const httpModule = isHttps ? https : http;
@@ -129,8 +145,27 @@ function makeRequest(url, options = {}) {
}; };
const req = httpModule.request(requestOptions, (res) => { const req = httpModule.request(requestOptions, (res) => {
if (options.allowRedirects === false && (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303 || res.statusCode === 307 || res.statusCode === 308)) { const redirectCodes = [301, 302, 303, 307, 308];
resolve({ statusCode: res.statusCode, headers: res.headers }); 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; return;
} }
@@ -139,9 +174,9 @@ function makeRequest(url, options = {}) {
res.on('end', () => { res.on('end', () => {
if (options.parseHTML && data) { if (options.parseHTML && data) {
const $ = cheerio.load(data); const $ = cheerio.load(data);
resolve({ $: $, body: data, statusCode: res.statusCode, headers: res.headers }); resolve({ $: $, body: data, statusCode: res.statusCode, headers: res.headers, url: currentUrl });
} else { } else {
resolve({ body: data, statusCode: res.statusCode, headers: res.headers }); resolve({ body: data, statusCode: res.statusCode, headers: res.headers, url: currentUrl });
} }
}); });
}); });
@@ -149,6 +184,9 @@ function makeRequest(url, options = {}) {
req.on('error', reject); req.on('error', reject);
req.on('timeout', () => reject(new Error('Request timeout'))); req.on('timeout', () => reject(new Error('Request timeout')));
req.end(); req.end();
};
doRequest(url);
}); });
} }
@@ -397,8 +435,10 @@ function generateAlternativeQueries(title, originalTitle = null) {
return romans[num] || match; return romans[num] || match;
}), }),
title.replace(/\b(I{1,3}|IV|V|VI{0,3}|IX|X)\b/g, match => { title.replace(/\b(I{1,3}|IV|V|VI{0,3}|IX|X)\b/g, match => {
const romans = { 'I': '1', 'II': '2', 'III': '3', 'IV': '4', 'V': '5', const romans = {
'VI': '6', 'VII': '7', 'VIII': '8', 'IX': '9', 'X': '10' }; 'I': '1', 'II': '2', 'III': '3', 'IV': '4', 'V': '5',
'VI': '6', 'VII': '7', 'VIII': '8', 'IX': '9', 'X': '10'
};
return romans[match] || match; return romans[match] || match;
}) })
]; ];
@@ -657,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;
@@ -705,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]'];
@@ -749,12 +805,24 @@ function extractHubCloudLinks(url, referer) {
if (displayFilename) titleParts.push(displayFilename); if (displayFilename) titleParts.push(displayFilename);
if (size) titleParts.push(size); if (size) titleParts.push(size);
const finalTitle = titleParts.join('\n'); const finalTitle = titleParts.join('\n');
let headers = {};
try {
const host = new URL(link).hostname;
if (/r2\.dev$/i.test(host)) {
headers = { Referer: baseUrl + '/', Origin: baseUrl };
console.log('[4KHDHub] Added Referer/Origin headers for r2.dev FSL link');
}
} catch {
// Ignore URL parsing errors
}
resolve({ resolve({
name: `4KHDHub - FSL Server${qualityLabel}`, name: `4KHDHub - FSL Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers
}); });
}) })
.catch(() => { .catch(() => {
@@ -763,12 +831,24 @@ function extractHubCloudLinks(url, referer) {
if (displayFilename) titleParts.push(displayFilename); if (displayFilename) titleParts.push(displayFilename);
if (size) titleParts.push(size); if (size) titleParts.push(size);
const finalTitle = titleParts.join('\n'); const finalTitle = titleParts.join('\n');
let headers = {};
try {
const host = new URL(link).hostname;
if (/r2\.dev$/i.test(host)) {
headers = { Referer: baseUrl + '/', Origin: baseUrl };
console.log('[4KHDHub] Added Referer/Origin headers for r2.dev FSL link (fallback)');
}
} catch {
// Ignore URL parsing errors
}
resolve({ resolve({
name: `4KHDHub - FSL Server${qualityLabel}`, name: `4KHDHub - FSL Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers
}); });
}); });
} else if (text.includes('Download File')) { } else if (text.includes('Download File')) {
@@ -786,7 +866,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - HubCloud${qualityLabel}`, name: `4KHDHub - HubCloud${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -800,7 +882,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - HubCloud${qualityLabel}`, name: `4KHDHub - HubCloud${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} else if (text.includes('BuzzServer')) { } else if (text.includes('BuzzServer')) {
@@ -829,7 +913,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - BuzzServer${qualityLabel}`, name: `4KHDHub - BuzzServer${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: finalUrl, url: finalUrl,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -843,7 +929,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - BuzzServer${qualityLabel}`, name: `4KHDHub - BuzzServer${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: finalUrl, url: finalUrl,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} else { } else {
@@ -858,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}`);
} }
@@ -880,7 +968,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - Pixeldrain${qualityLabel}`, name: `4KHDHub - Pixeldrain${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: convertedLink, url: convertedLink,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -894,7 +984,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - Pixeldrain${qualityLabel}`, name: `4KHDHub - Pixeldrain${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: convertedLink, url: convertedLink,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} else if (text.includes('S3 Server')) { } else if (text.includes('S3 Server')) {
@@ -912,7 +1004,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - S3 Server${qualityLabel}`, name: `4KHDHub - S3 Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -926,33 +1020,46 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - S3 Server${qualityLabel}`, name: `4KHDHub - S3 Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} else if (text.includes('10Gbps')) { } else if (text.includes('10Gbps') || /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 })
const followRedirects = () => {
return makeRequest(currentLink, {
parseHTML: false,
allowRedirects: false
})
.then(response => { .then(response => {
const redirectUrl = response.headers['location']; const finalUrl = response.url || link;
if (!redirectUrl) { let decodedUrl = '';
throw new Error('No redirect found');
try {
const urlObj = new URL(finalUrl);
const linkParam = urlObj.searchParams.get('link');
if (linkParam) {
decodedUrl = linkParam;
if (decodedUrl.includes('%')) {
try { decodedUrl = decodeURIComponent(decodedUrl); } catch { /* keep as-is */ }
}
}
} catch { /* ignore */ }
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 */ }
}
} }
console.log(`[4KHDHub] 10Gbps redirect: ${redirectUrl}`); if (!decodedUrl) {
throw new Error('Final link not found');
}
console.log(`[4KHDHub] 10Gbps final link: ${decodedUrl}`);
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 // Get actual filename from HEAD request
return getFilenameFromUrl(decodedUrl) return getFilenameFromUrl(decodedUrl)
.then(actualFilename => { .then(actualFilename => {
@@ -966,7 +1073,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - 10Gbps Server${qualityLabel}`, name: `4KHDHub - 10Gbps Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: decodedUrl, url: decodedUrl,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}; };
}) })
.catch(() => { .catch(() => {
@@ -980,19 +1089,12 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - 10Gbps Server${qualityLabel}`, name: `4KHDHub - 10Gbps Server${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: decodedUrl, url: decodedUrl,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}; };
}); });
} })
throw new Error('Final link not found');
} else {
currentLink = redirectUrl;
return followRedirects();
}
});
};
followRedirects()
.then(result => { .then(result => {
console.log(`[4KHDHub] 10Gbps processing completed`); console.log(`[4KHDHub] 10Gbps processing completed`);
resolve(result); resolve(result);
@@ -1016,7 +1118,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - HubCloud${qualityLabel}`, name: `4KHDHub - HubCloud${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}) })
.catch(() => { .catch(() => {
@@ -1030,7 +1134,9 @@ function extractHubCloudLinks(url, referer) {
name: `4KHDHub - HubCloud${qualityLabel}`, name: `4KHDHub - HubCloud${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}); });
}); });
} }
@@ -1274,14 +1380,18 @@ function extractStreamingLinks(downloadLinks) {
return Promise.all(promises) return Promise.all(promises)
.then(results => { .then(results => {
const validResults = results.filter(result => result !== null); const validResults = results.filter(r => r !== null).flat();
const flatResults = validResults.flat(); const filtered = validResults.filter(link => {
// Filter out .zip files if (!link || !link.url) return false;
const filteredResults = flatResults.filter(link => { const lower = link.url.toLowerCase();
return link && link.url && !link.url.toLowerCase().endsWith('.zip'); if (lower.endsWith('.zip')) {
console.log(`[4KHDHub] Skipping archive (zip) link: ${link.url}`);
return false;
}
return true;
}); });
// Note: Link count will be logged after validation completes console.log(`[4KHDHub] Post-processing: kept ${filtered.length}, skipped ${validResults.length - filtered.length} zip archive links`);
return filteredResults; return filtered;
}); });
} }
@@ -1302,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
@@ -1327,9 +1443,13 @@ 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) {
const altHref = foundBtn.attr('href');
if (altHref && /^https?:\/\//i.test(altHref)) {
console.log(`[4KHDHub] Found download button with selector: ${selector}`); console.log(`[4KHDHub] Found download button with selector: ${selector}`);
break; break;
} }
foundBtn = null;
}
} }
if (!foundBtn || foundBtn.length === 0) { if (!foundBtn || foundBtn.length === 0) {
@@ -1388,7 +1508,9 @@ function processHubDriveLink(href, referer, filename = 'Unknown', size = '', qua
name: `4KHDHub - HubDrive${qualityLabel}`, name: `4KHDHub - HubDrive${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: href, url: href,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}]; }];
}) })
.catch(() => { .catch(() => {
@@ -1402,7 +1524,9 @@ function processHubDriveLink(href, referer, filename = 'Unknown', size = '', qua
name: `4KHDHub - HubDrive${qualityLabel}`, name: `4KHDHub - HubDrive${qualityLabel}`,
title: finalTitle, title: finalTitle,
url: href, url: href,
quality: quality quality: quality,
provider: '4khdhub',
headers: {}
}]; }];
}); });
} }
@@ -1463,7 +1587,9 @@ function processExtractorLink(link, resolve, linkNumber) {
name: '4KHDHub Direct Link', name: '4KHDHub Direct Link',
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: 1080 quality: 1080,
provider: "4khdhub",
headers: {}
}]); }]);
}) })
.catch(() => { .catch(() => {
@@ -1477,7 +1603,9 @@ function processExtractorLink(link, resolve, linkNumber) {
name: '4KHDHub Direct Link', name: '4KHDHub Direct Link',
title: finalTitle, title: finalTitle,
url: link, url: link,
quality: 1080 quality: 1080,
provider: "4khdhub",
headers: {}
}]); }]);
}); });
} else { } else {
@@ -1696,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));
@@ -1740,16 +1870,41 @@ async function get4KHDHubStreams(tmdbId, type, season = null, episode = null) {
} }
// Normalize to generic stream object format // Normalize to generic stream object format
const streams = validatedLinks.map(link => ({ let streams = validatedLinks.map(link => ({
name: link.name, // Don't add prefix since it's already included name: link.name, // Don't add prefix since it's already included
title: link.title || link.name, title: link.title || link.name,
url: link.url, url: link.url,
quality: `${link.quality}p`, quality: `${link.quality}p`,
behaviorHints: { provider: "4khdhub",
bingeGroup: '4khdhub-streams' headers: {}
}
})); }));
// Always remove r2.dev links
{
const before = streams.length;
streams = streams.filter(s => {
try { return !new URL(s.url).hostname.endsWith('r2.dev'); } catch { return true; }
});
const removed = before - streams.length;
if (removed > 0) console.log(`[4KHDHub] Removed ${removed} r2.dev streams (permanent policy)`);
}
// Host distribution instrumentation
try {
const hostCounts = streams.reduce((acc, s) => {
try {
const h = new URL(s.url).hostname;
acc[h] = (acc[h] || 0) + 1;
} catch {
// Ignore URL parsing errors
}
return acc;
}, {});
console.log('[4KHDHub] Final host distribution:', hostCounts);
} catch {
// Ignore URL parsing errors
}
console.log(`[4KHDHub] Returning ${streams.length} streams`); console.log(`[4KHDHub] Returning ${streams.length} streams`);
return streams; return streams;
-506
View File
@@ -1,506 +0,0 @@
const axios = require('axios');
const FormData = require('form-data');
// Helper function to generate slug from title
function generateSlug(title) {
return title.toLowerCase()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-'); // Replace multiple hyphens with a single hyphen
}
// Process episode data from MP4Hydra response
function processEpisode(episode, baseServer, serverName, serverNumber) {
const videoUrl = `${baseServer}${episode.src}`;
const subtitles = episode.subs ? episode.subs.map(sub => ({
label: sub.label,
url: `${baseServer}${sub.src}`
})) : [];
return {
title: episode.show_title || episode.title,
episode: episode.title,
type: episode.type,
quality: episode.quality || episode.label,
videoUrl: videoUrl,
server: serverName,
serverNumber: serverNumber,
subtitles: subtitles
};
}
// Main function to get streams from MP4Hydra
async function getMP4HydraStreams(tmdbId, mediaType, seasonNum = null, episodeNum = null) {
try {
console.log(`[MP4Hydra] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}, Season: ${seasonNum}, Episode: ${episodeNum}`);
// Get TMDB details
const details = await getTMDBDetails(tmdbId, mediaType);
if (!details) {
console.log(`[MP4Hydra] Could not fetch details for TMDB ID: ${tmdbId}`);
return [];
}
console.log(`[MP4Hydra] Found title: ${details.title} (${details.year})`);
// Generate slug in the format "movie-name-year"
let slug = details.slug;
if (mediaType === 'movie' && details.year) {
slug = `${details.slug}-${details.year}`;
}
console.log(`[MP4Hydra] Using slug: ${slug}`);
// Create form data for multipart/form-data request
const formData = new FormData();
formData.append('v', '8');
formData.append('z', JSON.stringify([{
s: slug,
t: mediaType,
se: seasonNum,
ep: episodeNum
}]));
// Make request to MP4Hydra API
const response = await axios({
method: 'post',
url: 'https://mp4hydra.org/info2?v=8',
data: formData,
headers: {
...formData.getHeaders(),
'User-Agent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
'Origin': 'https://mp4hydra.org',
'Referer': `https://mp4hydra.org/${mediaType}/${slug}`,
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin'
},
timeout: 10000
});
// Process response
if (response.data && response.data.playlist && response.data.playlist.length > 0) {
const playlist = response.data.playlist;
const servers = response.data.servers;
console.log(`[MP4Hydra] Found ${playlist.length} videos`);
console.log(`[MP4Hydra] Available servers: ${Object.keys(servers).join(', ')}`);
// For TV shows, find the specific episode
if (mediaType === 'tv' && seasonNum && episodeNum) {
const paddedSeason = seasonNum.toString().padStart(2, '0');
const paddedEpisode = episodeNum.toString().padStart(2, '0');
const seasonEpisode = `S${paddedSeason}E${paddedEpisode}`;
const targetEpisode = playlist.find(item =>
item.title && item.title.toUpperCase() === seasonEpisode.toUpperCase()
);
if (!targetEpisode) {
console.log(`[MP4Hydra] Could not find ${seasonEpisode}`);
return [];
}
console.log(`[MP4Hydra] Found episode: ${targetEpisode.show_title || targetEpisode.title}`);
// Process streams from main servers
const streams = [];
const serverConfig = [
{ name: 'Beta', number: '#1' },
{ name: 'Beta#3', number: '#2' }
];
serverConfig.forEach(server => {
const serverName = server.name;
const serverNumber = server.number;
if (servers[serverName]) {
const baseServer = servers[serverName];
console.log(`[MP4Hydra] Processing server: ${serverName} (${baseServer})`);
const processedEpisode = processEpisode(targetEpisode, baseServer, serverName, serverNumber);
// Convert to standard stream format
streams.push({
title: `${details.title} - ${seasonEpisode} - ${processedEpisode.quality} [MP4Hydra ${serverNumber}]`,
url: processedEpisode.videoUrl,
quality: processedEpisode.quality,
provider: "MP4Hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: {
'Referer': 'https://mp4hydra.org/'
}
}
});
// Add subtitle tracks if available
if (processedEpisode.subtitles && processedEpisode.subtitles.length > 0) {
streams[streams.length - 1].subtitles = processedEpisode.subtitles.map(sub => ({
url: sub.url,
lang: sub.label
}));
}
}
});
return streams;
}
// For movies, process all videos
const streams = [];
const serverConfig = [
{ name: 'Beta', number: '#1' },
{ name: 'Beta#3', number: '#2' }
];
serverConfig.forEach(server => {
const serverName = server.name;
const serverNumber = server.number;
if (servers[serverName]) {
const baseServer = servers[serverName];
console.log(`[MP4Hydra] Processing server: ${serverName} (${baseServer})`);
playlist.forEach(item => {
const processedItem = processEpisode(item, baseServer, serverName, serverNumber);
// Convert to standard stream format
streams.push({
title: `${details.title} - ${processedItem.quality} [MP4Hydra ${serverNumber}]`,
url: processedItem.videoUrl,
quality: processedItem.quality,
provider: "MP4Hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: {
'Referer': 'https://mp4hydra.org/'
}
}
});
// Add subtitle tracks if available
if (processedItem.subtitles && processedItem.subtitles.length > 0) {
streams[streams.length - 1].subtitles = processedItem.subtitles.map(sub => ({
url: sub.url,
lang: sub.label
}));
}
});
}
});
return streams;
} else {
// Try with original title if the first attempt failed
if (details.title !== details.original_title) {
return await tryAlternativeTitle(details, mediaType, seasonNum, episodeNum);
}
// Try without year for movies
if (mediaType === 'movie' && details.year) {
return await tryWithoutYear(details, mediaType, seasonNum, episodeNum);
}
console.log('[MP4Hydra] No streaming links found');
return [];
}
} catch (error) {
console.error(`[MP4Hydra] Error fetching streams:`, error.message);
return [];
}
}
// Helper function to try with original title
async function tryAlternativeTitle(details, mediaType, seasonNum, episodeNum) {
try {
console.log(`[MP4Hydra] Retrying with original title: ${details.original_title}`);
const originalSlug = generateSlug(details.original_title);
let originalFullSlug = originalSlug;
if (mediaType === 'movie' && details.year) {
originalFullSlug = `${originalSlug}-${details.year}`;
}
// Create new form data with original title
const formData = new FormData();
formData.append('v', '8');
formData.append('z', JSON.stringify([{
s: originalFullSlug,
t: mediaType,
se: seasonNum,
ep: episodeNum
}]));
const response = await axios({
method: 'post',
url: 'https://mp4hydra.org/info2?v=8',
data: formData,
headers: {
...formData.getHeaders(),
'User-Agent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
'Origin': 'https://mp4hydra.org',
'Referer': `https://mp4hydra.org/${mediaType}/${originalFullSlug}`,
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin'
},
timeout: 10000
});
// Process response
if (response.data && response.data.playlist && response.data.playlist.length > 0) {
const playlist = response.data.playlist;
const servers = response.data.servers;
console.log(`[MP4Hydra] Found ${playlist.length} videos with original title`);
// For TV shows, find the specific episode
if (mediaType === 'tv' && seasonNum && episodeNum) {
const paddedSeason = seasonNum.toString().padStart(2, '0');
const paddedEpisode = episodeNum.toString().padStart(2, '0');
const seasonEpisode = `S${paddedSeason}E${paddedEpisode}`;
const targetEpisode = playlist.find(item =>
item.title && item.title.toUpperCase() === seasonEpisode.toUpperCase()
);
if (!targetEpisode) {
console.log(`[MP4Hydra] Could not find ${seasonEpisode} with original title`);
return [];
}
// Process streams from main servers
const streams = [];
const serverConfig = [
{ name: 'Beta', number: '#1' },
{ name: 'Beta#3', number: '#2' }
];
serverConfig.forEach(server => {
const serverName = server.name;
const serverNumber = server.number;
if (servers[serverName]) {
const baseServer = servers[serverName];
console.log(`[MP4Hydra] Processing server: ${serverName} (${baseServer})`);
const processedEpisode = processEpisode(targetEpisode, baseServer, serverName, serverNumber);
// Convert to standard stream format
streams.push({
title: `${details.original_title} - ${seasonEpisode} - ${processedEpisode.quality} [MP4Hydra ${serverNumber}]`,
url: processedEpisode.videoUrl,
quality: processedEpisode.quality,
provider: "MP4Hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: {
'Referer': 'https://mp4hydra.org/'
}
}
});
// Add subtitle tracks if available
if (processedEpisode.subtitles && processedEpisode.subtitles.length > 0) {
streams[streams.length - 1].subtitles = processedEpisode.subtitles.map(sub => ({
url: sub.url,
lang: sub.label
}));
}
}
});
return streams;
}
// For movies, process all videos
const streams = [];
const serverConfig = [
{ name: 'Beta', number: '#1' },
{ name: 'Beta#3', number: '#2' }
];
serverConfig.forEach(server => {
const serverName = server.name;
const serverNumber = server.number;
if (servers[serverName]) {
const baseServer = servers[serverName];
console.log(`[MP4Hydra] Processing server: ${serverName} (${baseServer})`);
playlist.forEach(item => {
const processedItem = processEpisode(item, baseServer, serverName, serverNumber);
// Convert to standard stream format
streams.push({
title: `${details.original_title} - ${processedItem.quality} [MP4Hydra ${serverNumber}]`,
url: processedItem.videoUrl,
quality: processedItem.quality,
provider: "MP4Hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: {
'Referer': 'https://mp4hydra.org/'
}
}
});
// Add subtitle tracks if available
if (processedItem.subtitles && processedItem.subtitles.length > 0) {
streams[streams.length - 1].subtitles = processedItem.subtitles.map(sub => ({
url: sub.url,
lang: sub.label
}));
}
});
}
});
return streams;
}
return [];
} catch (error) {
console.error(`[MP4Hydra] Error with alternative title:`, error.message);
return [];
}
}
// Helper function to try without year
async function tryWithoutYear(details, mediaType, seasonNum, episodeNum) {
try {
console.log(`[MP4Hydra] Retrying with title only (without year): ${details.title}`);
const titleOnlySlug = details.slug;
// Create new form data with title only
const formData = new FormData();
formData.append('v', '8');
formData.append('z', JSON.stringify([{
s: titleOnlySlug,
t: mediaType,
se: seasonNum,
ep: episodeNum
}]));
const response = await axios({
method: 'post',
url: 'https://mp4hydra.org/info2?v=8',
data: formData,
headers: {
...formData.getHeaders(),
'User-Agent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
'Origin': 'https://mp4hydra.org',
'Referer': `https://mp4hydra.org/${mediaType}/${titleOnlySlug}`,
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin'
},
timeout: 10000
});
// Process response
if (response.data && response.data.playlist && response.data.playlist.length > 0) {
const playlist = response.data.playlist;
const servers = response.data.servers;
console.log(`[MP4Hydra] Found ${playlist.length} videos with title only`);
// For movies, process all videos
const streams = [];
const serverConfig = [
{ name: 'Beta', number: '#1' },
{ name: 'Beta#3', number: '#2' }
];
serverConfig.forEach(server => {
const serverName = server.name;
const serverNumber = server.number;
if (servers[serverName]) {
const baseServer = servers[serverName];
console.log(`[MP4Hydra] Processing server: ${serverName} (${baseServer})`);
playlist.forEach(item => {
const processedItem = processEpisode(item, baseServer, serverName, serverNumber);
// Convert to standard stream format
streams.push({
title: `${details.title} - ${processedItem.quality} [MP4Hydra ${serverNumber}]`,
url: processedItem.videoUrl,
quality: processedItem.quality,
provider: "MP4Hydra",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: {
'Referer': 'https://mp4hydra.org/'
}
}
});
// Add subtitle tracks if available
if (processedItem.subtitles && processedItem.subtitles.length > 0) {
streams[streams.length - 1].subtitles = processedItem.subtitles.map(sub => ({
url: sub.url,
lang: sub.label
}));
}
});
}
});
return streams;
}
return [];
} catch (error) {
console.error(`[MP4Hydra] Error with title-only:`, error.message);
return [];
}
}
// Helper function to get TMDB details
async function getTMDBDetails(tmdbId, mediaType) {
const { getTmdbApiKey } = require('../utils/tmdbKey');
const TMDB_API_KEY = getTmdbApiKey();
if(!TMDB_API_KEY){
console.warn('[MP4Hydra] TMDB API key missing; skipping TMDB metadata fetch');
return null;
}
try {
console.log(`[MP4Hydra] Fetching ${mediaType} details for TMDB ID: ${tmdbId}`);
const response = await axios.get(`https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${TMDB_API_KEY}`);
if (mediaType === 'movie') {
return {
title: response.data.title,
original_title: response.data.original_title,
year: response.data.release_date ? response.data.release_date.split('-')[0] : null,
slug: generateSlug(response.data.title)
};
} else {
return {
title: response.data.name,
original_title: response.data.original_name,
year: response.data.first_air_date ? response.data.first_air_date.split('-')[0] : null,
slug: generateSlug(response.data.name)
};
}
} catch (error) {
console.error(`[MP4Hydra] Error fetching details from TMDB:`, error.message);
return null;
}
}
module.exports = { getMP4HydraStreams };
+36 -27
View File
@@ -62,8 +62,9 @@ const getCookieForRequest = async (regionPreference = null, userCookie = null) =
try { try {
const array = Array.isArray(global.currentRequestConfig.cookies) ? global.currentRequestConfig.cookies.filter(Boolean) : []; const array = Array.isArray(global.currentRequestConfig.cookies) ? global.currentRequestConfig.cookies.filter(Boolean) : [];
if (array.length > 0) { if (array.length > 0) {
// Just pick first for now (quota logic removed here if previously elsewhere) // Strip 'ui=' prefix if present — callers always prepend 'ui=' themselves
baseCookieToUse = array[0]; const raw = array[0];
baseCookieToUse = raw.startsWith('ui=') ? raw.slice(3) : raw;
} }
} catch (e) { } catch (e) {
console.warn(`[CookieManager] Cookie array selection error: ${e.message}`); console.warn(`[CookieManager] Cookie array selection error: ${e.message}`);
@@ -1211,7 +1212,12 @@ const fetchSourcesForSingleFid = async (fidToProcess, shareKey, regionPreference
const baseHeaders = { const baseHeaders = {
'Cookie': `ui=${cookieForRequest}`, 'Cookie': `ui=${cookieForRequest}`,
'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'Origin': 'https://www.febbox.com',
'Referer': `https://www.febbox.com/share/${shareKey}`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Accept': '*/*',
}; };
const finalPostUrl = FEBBOX_PLAYER_URL; const finalPostUrl = FEBBOX_PLAYER_URL;
@@ -1221,7 +1227,7 @@ const fetchSourcesForSingleFid = async (fidToProcess, shareKey, regionPreference
console.log(` Fetching fresh player data for video FID: ${fidToProcess} (Share: ${shareKey}) directly to ${FEBBOX_PLAYER_URL}`); console.log(` Fetching fresh player data for video FID: ${fidToProcess} (Share: ${shareKey}) directly to ${FEBBOX_PLAYER_URL}`);
try { try {
const response = await axios.post(finalPostUrl, postDataForAxios, axiosConfig); const response = await axios.post(finalPostUrl, postDataForAxios, { ...axiosConfig, responseType: 'text' });
const playerContent = response.data; const playerContent = response.data;
// Mark the region as available if the request succeeded // Mark the region as available if the request succeeded
@@ -1980,10 +1986,12 @@ const getStreamsFromTmdbId = async (tmdbType, tmdbId, seasonNum = null, episodeN
} }
} catch (e) { /* ignore if URL parsing fails */ } } catch (e) { /* ignore if URL parsing fails */ }
streamsFromThisShareInfo.push({ streamsFromThisShareInfo.push({
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(key5FromDirectSource || streamTitle) provider: 'Febbox Direct',
headers: {}
}); });
} }
// If direct sources are found, original code used 'continue', // If direct sources are found, original code used 'continue',
@@ -2007,10 +2015,12 @@ const getStreamsFromTmdbId = async (tmdbType, tmdbId, seasonNum = null, episodeN
const streamTitle = `${baseStreamTitle} - ${source.label}`; const streamTitle = `${baseStreamTitle} - ${source.label}`;
streamsFromThisShareInfo.push({ streamsFromThisShareInfo.push({
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle) provider: 'Febbox FID',
headers: {}
}); });
} }
} }
@@ -2202,10 +2212,12 @@ const processShowWithSeasonsEpisodes = async (febboxUrl, showboxTitle, seasonNum
for (const source of directSources) { for (const source of directSources) {
const streamTitle = `${showboxTitle} - ${source.label}`; const streamTitle = `${showboxTitle} - ${source.label}`;
streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle) provider: 'Febbox Direct',
headers: {}
}); });
} }
console.timeEnd(processTimerLabel); console.timeEnd(processTimerLabel);
@@ -2222,10 +2234,12 @@ const processShowWithSeasonsEpisodes = async (febboxUrl, showboxTitle, seasonNum
for (const source of sources) { for (const source of sources) {
const streamTitle = `${showboxTitle} - ${source.label}`; const streamTitle = `${showboxTitle} - ${source.label}`;
streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle) provider: 'Febbox FID',
headers: {}
}); });
} }
} }
@@ -2506,10 +2520,12 @@ const processShowWithSeasonsEpisodes = async (febboxUrl, showboxTitle, seasonNum
const streamTitle = `${showboxTitle} - S${seasonNum}${episodeNum && selectedEpisode && fid === selectedEpisode.fid ? `E${episodeNum}` : (episodeDetail ? `E${episodeDetail.episodeNum}` : '')} - ${episodeName} - ${source.label}`; const streamTitle = `${showboxTitle} - S${seasonNum}${episodeNum && selectedEpisode && fid === selectedEpisode.fid ? `E${episodeNum}` : (episodeDetail ? `E${episodeDetail.episodeNum}` : '')} - ${episodeName} - ${source.label}`;
streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall streamsForThisCall.push({ // MODIFIED: Push to streamsForThisCall
name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.label), quality: parseQualityFromLabel(source.label),
codecs: extractCodecDetails(source.detailedFilename || streamTitle) provider: 'Febbox FID',
headers: {}
}); });
} }
} else { } else {
@@ -2670,9 +2686,8 @@ const sortStreamsByQuality = (streams) => {
// Provider priority ordering (lower number = higher priority) // Provider priority ordering (lower number = higher priority)
const providerSortKeys = { const providerSortKeys = {
'ShowBox': 1, 'ShowBox': 1,
'Xprime.tv': 2, 'HollyMovieHD': 2,
'HollyMovieHD': 3, 'Soaper TV': 3,
'Soaper TV': 4,
// Default for unknown providers // Default for unknown providers
default: 99 default: 99
}; };
@@ -2862,17 +2877,15 @@ const processPStreamResponse = (apiResponse, tmdbType, seasonNum, episodeNum) =>
for (const [quality, url] of Object.entries(apiResponse.streams)) { for (const [quality, url] of Object.entries(apiResponse.streams)) {
if (url && typeof url === 'string') { if (url && typeof url === 'string') {
// Use "Auto" for name and actual filename for title // Use "Auto" for name and actual filename for title
const streamName = "Auto";
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${quality}`; const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${quality}`;
streams.push({ streams.push({
name: streamName, name: streamTitle,
title: streamTitle, title: streamTitle,
url: url, url: url,
quality: parseQualityFromLabel(quality), quality: parseQualityFromLabel(quality),
codecs: extractCodecDetails(apiResponse.name || quality), // Use filename for codec detection provider: 'PStream',
size: apiResponse.size || 'Unknown size', headers: {}
provider: 'PStream'
}); });
} }
} }
@@ -2890,17 +2903,15 @@ const processPStreamResponse = (apiResponse, tmdbType, seasonNum, episodeNum) =>
continue; continue;
} }
const streamName = "Auto";
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${source.quality}`; const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${source.quality}`;
streams.push({ streams.push({
name: streamName, name: streamTitle,
title: streamTitle, title: streamTitle,
url: source.url, url: source.url,
quality: parseQualityFromLabel(source.quality), quality: parseQualityFromLabel(source.quality),
codecs: source.codec ? [source.codec] : [], provider: 'PStream',
size: source.size || 'Unknown size', headers: {}
provider: 'PStream'
}); });
} }
@@ -2913,17 +2924,15 @@ const processPStreamResponse = (apiResponse, tmdbType, seasonNum, episodeNum) =>
for (const item of apiResponse.data) { for (const item of apiResponse.data) {
if (item.url || item.file) { if (item.url || item.file) {
const streamName = "Auto";
const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${item.quality || item.label || 'Unknown'}`; const streamTitle = apiResponse.name || `${tmdbType === 'movie' ? 'PStream' : `PStream - S${seasonNum}E${episodeNum}`} - ${item.quality || item.label || 'Unknown'}`;
streams.push({ streams.push({
name: streamName, name: streamTitle,
title: streamTitle, title: streamTitle,
url: item.url || item.file, url: item.url || item.file,
quality: parseQualityFromLabel(item.quality || item.label), quality: parseQualityFromLabel(item.quality || item.label),
codecs: item.codec ? [item.codec] : [], provider: 'PStream',
size: item.size || 'Unknown size', headers: {}
provider: 'PStream'
}); });
} }
} }
-124
View File
@@ -1,124 +0,0 @@
const axios = require('axios');
// Removed unused parseArgs helper
const getVidZeeStreams = async (tmdbId, mediaType, seasonNum, episodeNum) => {
if (!tmdbId) {
console.error('[VidZee] Error: TMDB ID (tmdbId) is required.');
return [];
}
if (!mediaType || (mediaType !== 'movie' && mediaType !== 'tv')) {
console.error('[VidZee] Error: mediaType is required and must be either "movie" or "tv".');
return [];
}
if (mediaType === 'tv') {
if (!seasonNum) {
console.error('[VidZee] Error: Season (seasonNum) is required for TV shows.');
return [];
}
if (!episodeNum) {
console.error('[VidZee] Error: Episode (episodeNum) is required for TV shows.');
return [];
}
}
const servers = [1,2,3,4,5,6,7,8,9,10]; // VidZee server identifiers
const streamPromises = servers.map(async (sr) => {
let targetApiUrl = `https://player.vidzee.wtf/api/server?id=${tmdbId}&sr=${sr}`;
if (mediaType === 'tv') {
targetApiUrl += `&ss=${seasonNum}&ep=${episodeNum}`;
}
let finalApiUrl;
const headers = {
'Referer': `https://player.vidzee.wtf/embed/movie/${tmdbId}`
};
const timeout = 7000; // Reduced timeout
finalApiUrl = targetApiUrl;
console.log(`[VidZee] Fetching from server ${sr}: ${targetApiUrl}`);
try {
const response = await axios.get(finalApiUrl, {
headers: headers,
timeout: timeout
});
const responseData = response.data;
if (!responseData || typeof responseData !== 'object') {
console.error(`[VidZee S${sr}] Error: Invalid response data from API.`);
return [];
}
if (responseData.tracks) {
delete responseData.tracks;
}
let apiSources = [];
if (responseData.url && Array.isArray(responseData.url)) {
apiSources = responseData.url;
} else if (responseData.link && typeof responseData.link === 'string') {
apiSources = [responseData];
}
if (!apiSources || apiSources.length === 0) {
console.log(`[VidZee S${sr}] No stream sources found in API response.`);
return [];
}
const streams = apiSources.map(sourceItem => {
// Prefer sourceItem.name as label, fallback to sourceItem.type, then 'VidZee Stream'
const label = sourceItem.name || sourceItem.type || 'VidZee';
// Ensure quality has 'p' if it's a resolution, or keep it as is
let quality = String(label).match(/^\d+$/) ? `${label}p` : label;
// Normalize non-numeric or ambiguous quality labels to a baseline so they survive minQuality filter
if (!/(\d{3,4})p/.test(quality.toLowerCase())) {
quality = '720p';
}
const language = sourceItem.language || sourceItem.lang;
return {
title: `VidZee S${sr} - ${quality}`,
url: sourceItem.link, // Use sourceItem.link for the URL
quality: quality,
language: language,
provider: "VidZee",
size: "Unknown size",
behaviorHints: {
notWebReady: true,
headers: {
'Referer': 'https://core.vidzee.wtf/'
}
}
};
}).filter(stream => stream.url);
console.log(`[VidZee S${sr}] Successfully extracted ${streams.length} streams. Qualities: ${streams.map(s=>s.quality).join(', ')}`);
return streams;
} catch (error) {
if (error.response) {
console.error(`[VidZee S${sr}] Error fetching: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
console.error(`[VidZee S${sr}] Error fetching: No response received.`);
} else {
console.error(`[VidZee S${sr}] Error fetching:`, error.message);
}
return [];
}
});
const allStreamsNested = await Promise.all(streamPromises);
const allStreams = allStreamsNested.flat();
console.log(`[VidZee] Found a total of ${allStreams.length} streams from servers ${servers.join(', ')}.`);
return allStreams;
};
module.exports = { getVidZeeStreams };
+417
View File
@@ -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 };
+152
View File
@@ -0,0 +1,152 @@
const axios = require('axios');
const { getTmdbApiKey } = require('../utils/tmdbKey');
const DAHMER_MOVIES_API = 'https://a.111477.xyz';
const DAHMER_WORKER_API = 'https://p.111477.xyz/bulk?u=';
const REQUEST_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Referer': `${DAHMER_MOVIES_API}/`
};
function parseLinks(html) {
const links = [];
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let match;
while ((match = rowRegex.exec(html)) !== null) {
const rowContent = match[1];
const linkMatch = rowContent.match(/<a[^>]*href=["']([^"']*)["'][^>]*>([^<]*)<\/a>/i);
const sizeMatch = rowContent.match(/<td[^>]*>(\d+(?:\.\d+)?\s?[KMGT]B)<\/td>/i);
if (linkMatch) {
const href = linkMatch[1];
const text = linkMatch[2].trim();
const size = sizeMatch ? sizeMatch[1].trim() : 'N/A';
if (text && href !== '../' && /\.(mkv|mp4|avi|webm|m3u8)$/i.test(text)) {
links.push({ text, href, size });
}
}
}
return links;
}
async function fetchDirectory(title, year, season) {
const cleanTitle = title.replace(/:/g, '');
const variants = season !== null ? [
`/tvs/${encodeURIComponent(cleanTitle)}/Season%20${season < 10 ? '0' + season : season}/`,
`/tvs/${encodeURIComponent(cleanTitle)}/Season%20${season}/`
] : [
`/movies/${encodeURIComponent(`${cleanTitle} (${year})`)}/`
];
for (const variant of variants) {
try {
const res = await axios.get(DAHMER_MOVIES_API + variant, {
headers: REQUEST_HEADERS,
timeout: 10000,
responseType: 'text'
});
if (res.data) return { html: res.data, dirUrl: DAHMER_MOVIES_API + variant };
} catch {
// try next variant
}
}
return null;
}
async function getDahmermoviesStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
console.log(`[DahmerMovies] Searching for ${mediaType} ${tmdbId}`);
const tmdbKey = getTmdbApiKey();
if (!tmdbKey) {
console.error('[DahmerMovies] No TMDB API key configured.');
return [];
}
let title, year;
try {
const type = mediaType === 'tv' ? 'tv' : 'movie';
const { data } = await axios.get(
`https://api.themoviedb.org/3/${type}/${tmdbId}?api_key=${tmdbKey}`,
{ timeout: 8000 }
);
title = data.title || data.name || '';
year = (data.release_date || data.first_air_date || '').substring(0, 4);
} catch (err) {
console.error(`[DahmerMovies] TMDB lookup failed: ${err.message}`);
return [];
}
if (!title) return [];
const dir = await fetchDirectory(title, year, seasonNum);
if (!dir) {
console.log(`[DahmerMovies] Directory not found for "${title}"`);
return [];
}
let paths = parseLinks(dir.html);
// For TV, filter to the specific episode when possible
if (seasonNum !== null && episodeNum !== null) {
const epStr = String(episodeNum).padStart(2, '0');
const seStr = String(seasonNum).padStart(2, '0');
const epFiltered = paths.filter(p => {
const name = p.text.toLowerCase();
return name.includes(`s${seStr}e${epStr}`) || name.includes(`e${epStr}`);
});
if (epFiltered.length > 0) paths = epFiltered;
}
// Sort 4K first
paths.sort((a, b) => (/2160p|4k/i.test(b.text) ? 1 : 0) - (/2160p|4k/i.test(a.text) ? 1 : 0));
const streams = [];
for (const path of paths.slice(0, 5)) {
let directUrl;
if (path.href.startsWith('http')) {
directUrl = path.href;
} else if (path.href.includes('/movies/') || path.href.includes('/tvs/')) {
directUrl = DAHMER_MOVIES_API + (path.href.startsWith('/') ? '' : '/') + path.href;
} else {
directUrl = dir.dirUrl + path.href;
}
directUrl = decodeURI(directUrl.replace(/([^:]\/)\/+/g, '$1'));
const fileName = path.text;
const isMulti = /\b(HIN|TAM|TEL|Multi|Dual|DUB|Multi-Audio|MULTI)\b/i.test(fileName);
const hasEngTag = /\b(Eng|English)\b/i.test(fileName);
const isEnglishTitle = /^[a-zA-Z0-9\s?!\-:]+$/.test(title);
let language = 'Original';
if (isMulti) language = 'Multi Audio';
else if (isEnglishTitle && hasEngTag) language = 'English';
const formatMatch = fileName.match(/\.(mkv|mp4|m3u8|avi|webm)$/i);
const fileFormat = formatMatch ? formatMatch[1].toUpperCase() : 'LINK';
const resolution = fileName.match(/\b(2160p|1080p|720p|4[Kk])\b/)?.[0] || '1080p';
const info = fileName
.replace(/\.(mkv|mp4|avi|webm|m3u8)$/i, '')
.replace(/[\[\]()._-]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
streams.push({
name: 'DahmerMovies',
title: `${resolution} | ${language} | ${path.size} | ${fileFormat} | ${info}`,
url: DAHMER_WORKER_API + encodeURI(directUrl),
quality: resolution,
provider: 'DahmerMovies',
headers: {
'User-Agent': REQUEST_HEADERS['User-Agent'],
'Referer': `${DAHMER_MOVIES_API}/`,
'Accept': '*/*',
'Range': 'bytes=0-'
}
});
}
console.log(`[DahmerMovies] Total results found: ${streams.length}`);
return streams;
}
module.exports = { getDahmermoviesStreams };
+128
View File
@@ -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 };
File diff suppressed because it is too large Load Diff
+303
View File
@@ -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 };
+334
View File
@@ -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 };
+101 -220
View File
@@ -1,12 +1,27 @@
const { config } = require('../utils/config'); const { config } = require('../utils/config');
// Lazy load showbox to avoid early side-effects; temporarily disabled until stabilized const fs = require('fs');
let showbox = null; const path = require('path');
let k4hdhub = null; // 4khdhub provider lazy ref
let moviesmod = null; // moviesmod provider lazy ref // Lazy load cache for providers
let mp4hydra = null; // MP4Hydra provider lazy ref const providerCache = new Map();
let vidzee = null; // VidZee provider lazy ref
let vixsrc = null; // vixsrc provider lazy ref // Function name mappings for existing providers
let xprime = null; // xprime provider lazy ref const providerFunctionMap = {
'Showbox.js': 'getStreamsFromTmdbId',
'4khdhub.js': 'get4KHDHubStreams',
'vixsrc.js': 'getVixsrcStreams',
'videasy.js': 'getVideasyStreams',
'vidlink.js': 'getVidlinkStreams',
'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
let lastCookieStats = { selected: null, index: null, total: 0, remainingMB: null, timestamp: null }; let lastCookieStats = { selected: null, index: null, total: 0, remainingMB: null, timestamp: null };
@@ -14,11 +29,50 @@ async function getEffectiveCookies() {
return Array.isArray(config.febboxCookies) ? config.febboxCookies : []; return Array.isArray(config.febboxCookies) ? config.febboxCookies : [];
} }
// Adapter for Showbox provider using existing exported getStreamsFromTmdbId // Get all provider files
async function fetchShowbox(ctx) { function getProviderFiles() {
if (!showbox) { const providersDir = path.join(__dirname);
try { showbox = require('./Showbox.js'); } catch (e) { console.error('Failed to load Showbox provider:', e.message); return []; } return fs.readdirSync(providersDir)
.filter(file => file.endsWith('.js') && file !== 'registry.js')
.map(file => ({
name: path.parse(file).name.toLowerCase(),
file: file,
functionName: providerFunctionMap[file]
}));
} }
// Load a provider module
function loadProvider(providerFile) {
if (!providerCache.has(providerFile)) {
try {
providerCache.set(providerFile, require(path.join(__dirname, providerFile)));
} catch (e) {
console.error(`Failed to load provider ${providerFile}:`, e.message);
return null;
}
}
return providerCache.get(providerFile);
}
// Create fetch function for a provider
function createFetchFunction(providerInfo) {
return async function(ctx) {
const module = loadProvider(providerInfo.file);
if (!module) return [];
const funcName = providerInfo.functionName;
if (!module[funcName]) {
console.error(`Provider ${providerInfo.name} does not export ${funcName}`);
return [];
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
let result;
if (providerInfo.name === 'showbox') {
// Special case for Showbox with TMDB key and cookies
const { getTmdbApiKey } = require('../utils/tmdbKey'); const { getTmdbApiKey } = require('../utils/tmdbKey');
const tmdbApiKey = getTmdbApiKey(); const tmdbApiKey = getTmdbApiKey();
if (!tmdbApiKey) { if (!tmdbApiKey) {
@@ -37,227 +91,54 @@ async function fetchShowbox(ctx) {
lastCookieStats = { selected: selected.slice(0, 16) + '...', index, total: cookies.length, remainingMB: null, timestamp: Date.now() }; lastCookieStats = { selected: selected.slice(0, 16) + '...', index, total: cookies.length, remainingMB: null, timestamp: Date.now() };
console.log(`[registry] Cookie random pick index=${index} total=${cookies.length}`); console.log(`[registry] Cookie random pick index=${index} total=${cookies.length}`);
} }
try { result = await module[funcName](mediaType, ctx.tmdbId, ctx.season || null, ctx.episode || null, null, selected);
const tmdbType = ctx.type === 'movie' ? 'movie' : 'tv';
console.log(`[registry] Fetching Showbox streams for ${tmdbType} tmdbId=${ctx.tmdbId} season=${ctx.season||''} episode=${ctx.episode||''}`);
const t0 = Date.now();
const streams = await showbox.getStreamsFromTmdbId(tmdbType, ctx.tmdbId, ctx.season || null, ctx.episode || null, null, selected);
const durationMs = Date.now() - t0;
console.log(`[registry] Provider showbox fetch duration ${durationMs}ms`);
if (global.currentRequestUserCookieRemainingMB != null) { if (global.currentRequestUserCookieRemainingMB != null) {
lastCookieStats.remainingMB = global.currentRequestUserCookieRemainingMB; lastCookieStats.remainingMB = global.currentRequestUserCookieRemainingMB;
} }
console.log(`[registry] Showbox returned ${Array.isArray(streams)?streams.length:0} streams`);
return Array.isArray(streams) ? streams : [];
} catch (e) {
console.error('[registry] Showbox fetch error:', e.message);
return [];
} finally {
global.currentRequestConfig = previousConfig || {}; global.currentRequestConfig = previousConfig || {};
} } else {
// Standard provider call
result = await module[funcName](ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
} }
const durationMs = Date.now() - t0;
console.log(`[registry] ${providerInfo.name} fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
// Add provider name if not present
return result.map(s => ({ ...s, provider: s.provider || providerInfo.name }));
} catch (e) {
console.error(`[registry] ${providerInfo.name} fetch error:`, e.message);
return [];
}
};
}
// Initialize providers - always load all; enabled state is checked dynamically from live config
const providerFiles = getProviderFiles();
const providers = []; const providers = [];
if (config.enableShowboxProvider) { for (const providerInfo of providerFiles) {
providers.push({ name: 'showbox', enabled: true, fetch: fetchShowbox }); providers.push({
} else { name: providerInfo.name,
console.log('[registry] showbox disabled via config'); fetch: createFetchFunction(providerInfo)
}
// 4KHDHub adapter
async function fetch4khdhub(ctx) {
if (!k4hdhub) {
try { k4hdhub = require('./4khdhub.js'); } catch (e) { console.error('Failed to load 4khdhub provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await k4hdhub.get4KHDHubStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] 4khdhub fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || '4KHDHub', provider: '4khdhub' }));
} catch (e) {
console.error('[registry] 4khdhub fetch error:', e.message);
return [];
}
}
if (config.enable4khdhubProvider) {
providers.push({ name: '4khdhub', enabled: true, fetch: fetch4khdhub });
console.log('[registry] 4khdhub provider enabled');
} else {
console.log('[registry] 4khdhub disabled via config');
}
// MoviesMod adapter
async function fetchMoviesMod(ctx) {
if (!moviesmod) {
try { moviesmod = require('./moviesmod.js'); } catch (e) { console.error('Failed to load moviesmod provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await moviesmod.getMoviesModStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] moviesmod fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || 'MoviesMod', provider: 'moviesmod' }));
} catch (e) {
console.error('[registry] moviesmod fetch error:', e.message);
return [];
}
}
if (config.enableMoviesmodProvider) {
providers.push({ name: 'moviesmod', enabled: true, fetch: fetchMoviesMod });
console.log('[registry] moviesmod provider enabled');
} else {
console.log('[registry] moviesmod disabled via config');
}
// MP4Hydra adapter
async function fetchMp4hydra(ctx) {
if (!mp4hydra) {
try { mp4hydra = require('./MP4Hydra.js'); } catch (e) { console.error('Failed to load MP4Hydra provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await mp4hydra.getMP4HydraStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] mp4hydra fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || 'MP4Hydra', provider: 'mp4hydra' }));
} catch (e) {
console.error('[registry] mp4hydra fetch error:', e.message);
return [];
}
}
if (config.enableMp4hydraProvider) {
providers.push({ name: 'mp4hydra', enabled: true, fetch: fetchMp4hydra });
console.log('[registry] mp4hydra provider enabled');
} else {
console.log('[registry] mp4hydra disabled via config');
}
// VidZee adapter
async function fetchVidZee(ctx) {
if (!vidzee) {
try { vidzee = require('./VidZee.js'); } catch (e) { console.error('Failed to load VidZee provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await vidzee.getVidZeeStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] vidzee fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || 'VidZee', provider: 'vidzee' }));
} catch (e) {
console.error('[registry] vidzee fetch error:', e.message);
return [];
}
}
if (config.enableVidzeeProvider) {
providers.push({ name: 'vidzee', enabled: true, fetch: fetchVidZee });
console.log('[registry] vidzee provider enabled');
} else {
console.log('[registry] vidzee disabled via config');
}
// vixsrc adapter
async function fetchVixsrc(ctx) {
if (!vixsrc) {
try { vixsrc = require('./vixsrc.js'); } catch (e) { console.error('Failed to load vixsrc provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const t0 = Date.now();
const result = await vixsrc.getVixsrcStreams(ctx.tmdbId, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] vixsrc fetch duration ${durationMs}ms`);
if (!Array.isArray(result)) return [];
return result.map(s => ({ ...s, name: s.name || 'Vixsrc', provider: 'vixsrc' }));
} catch (e) {
console.error('[registry] vixsrc fetch error:', e.message);
return [];
}
}
if (config.enableVixsrcProvider) {
providers.push({ name: 'vixsrc', enabled: true, fetch: fetchVixsrc });
console.log('[registry] vixsrc provider enabled');
} else {
console.log('[registry] vixsrc disabled via config');
}
// xprime adapter (requires TMDB metadata lookup for title/year)
async function fetchXprime(ctx) {
if (!xprime) {
try { xprime = require('./xprime.js'); } catch (e) { console.error('Failed to load xprime provider:', e.message); return []; }
}
try {
const mediaType = ctx.type === 'movie' ? 'movie' : 'tv';
const { getTmdbApiKey } = require('../utils/tmdbKey');
const tmdbApiKey = getTmdbApiKey();
if (!tmdbApiKey) {
console.warn('[registry] xprime skipped: TMDB API key missing');
return [];
}
// Fetch minimal TMDB metadata for title + year
let title = null; let year = null;
try {
const axios = require('axios');
const metaUrl = `https://api.themoviedb.org/3/${mediaType}/${ctx.tmdbId}?api_key=${tmdbApiKey}`;
const { data: meta } = await axios.get(metaUrl, { timeout: 8000 });
title = mediaType === 'movie' ? (meta.title || meta.original_title) : (meta.name || meta.original_name);
const dateStr = mediaType === 'movie' ? meta.release_date : meta.first_air_date;
if (dateStr) year = dateStr.split('-')[0];
} catch (mErr) {
console.warn('[registry] xprime metadata fetch failed:', mErr.message);
return [];
}
if (!title || !year) {
console.log('[registry] xprime missing title/year after TMDB lookup');
return [];
}
const t0 = Date.now();
const streams = await xprime.getXprimeStreams(title, year, mediaType, ctx.season || null, ctx.episode || null);
const durationMs = Date.now() - t0;
console.log(`[registry] xprime fetch duration ${durationMs}ms (title='${title}' year=${year}) returned ${Array.isArray(streams)?streams.length:0}`);
if (!Array.isArray(streams) || !streams.length) return [];
// Normalize quality labels that are non-numeric (e.g., FHD/HD/SD)
return streams.map(s => {
let q = s.quality || 'Unknown';
if (!/(\d{3,4})p/.test(q.toLowerCase())) {
const qLower = q.toLowerCase();
if (qLower.includes('fhd') || qLower.includes('full')) q = '1080p';
else if (qLower === 'hd' || qLower.includes('720')) q = '720p';
else if (qLower.includes('sd') || qLower.includes('480')) q = '480p';
else if (qLower.includes('cam')) q = '360p';
}
return { ...s, quality: q, name: s.name || 'Xprime', provider: 'xprime' };
}); });
} catch (e) { console.log(`[registry] ${providerInfo.name} provider loaded`);
console.error('[registry] xprime fetch error:', e.message);
return [];
}
} }
if (config.enableXprimeProvider) { function isProviderEnabled(name) {
providers.push({ name: 'xprime', enabled: true, fetch: fetchXprime }); const flag = `enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`;
console.log('[registry] xprime provider enabled'); return config[flag] !== false;
} else {
console.log('[registry] xprime disabled via config');
} }
function listProviders() { return providers.map(p => ({ name: p.name, enabled: p.enabled })); } function listProviders() { return providers.map(p => ({ name: p.name, enabled: isProviderEnabled(p.name) })); }
function getProvider(name) { return providers.find(p => p.name === name.toLowerCase()) || null; } function getProvider(name) {
const p = providers.find(p => p.name === name.toLowerCase());
if (!p) return null;
return { ...p, enabled: isProviderEnabled(p.name) };
}
function getCookieStats() { return lastCookieStats; } function getCookieStats() { return lastCookieStats; }
+133
View File
@@ -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 };
File diff suppressed because it is too large Load Diff
+87
View File
@@ -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 };
+216
View File
@@ -0,0 +1,216 @@
const axios = require('axios');
const { getTmdbApiKey } = require('../utils/tmdbKey');
const VIDEASY_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Origin': 'https://player.videasy.net',
'Referer': 'https://player.videasy.net/'
};
const VIDEASY_API = 'https://api.speedracelight.com';
// Each server maps a name to its API route. moviesOnly:true skips TV requests.
const SERVERS = {
'CDN': { url: `${VIDEASY_API}/cdn/sources-with-title` },
'LaMovie': { url: `${VIDEASY_API}/lamovie/sources-with-title` },
'Meine': { url: `${VIDEASY_API}/meine/sources-with-title`, moviesOnly: true }
};
// --- 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) {
console.log(`[Videasy] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
const tmdbKey = getTmdbApiKey();
if (!tmdbKey) {
console.error('[Videasy] No TMDB API key configured.');
return [];
}
// Step 1: Resolve title/year/imdbId from TMDB
let details;
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 }
);
details = {
title: data.title || data.name || '',
year: (data.release_date || data.first_air_date || '').split('-')[0],
imdbId: (data.external_ids && data.external_ids.imdb_id) || '',
type
};
} catch (err) {
console.error(`[Videasy] TMDB lookup failed: ${err.message}`);
return [];
}
if (!details.title) {
console.error('[Videasy] No title returned from TMDB.');
return [];
}
const allStreams = [];
const seen = new Set();
// Step 2: Fetch a decryption seed (cached server-side per mediaId, short TTL)
let seed;
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 [];
}
const queryServers = async (activeSeed) => {
await Promise.all(Object.entries(SERVERS).map(async ([name, server]) => {
if (server.moviesOnly && mediaType === 'tv') return;
let apiUrl = `${server.url}?title=${encodeURIComponent(details.title)}`
+ `&mediaType=${details.type === 'tv' ? 'TV Series' : 'Movie'}&year=${details.year}`
+ `&tmdbId=${tmdbId}&imdbId=${details.imdbId || ''}`;
if (mediaType === 'tv') apiUrl += `&seasonId=${seasonNum}&episodeId=${episodeNum}`;
apiUrl += `&enc=2&seed=${activeSeed}`;
try {
const encRes = await axios.get(apiUrl, {
headers: VIDEASY_HEADERS,
timeout: 8000,
responseType: 'text'
});
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
}
}));
};
// 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 {
// ignore retry failure
}
}
console.log(`[Videasy] Total streams: ${allStreams.length}`);
return allStreams;
}
module.exports = { getVideasyStreams };
+74
View File
@@ -0,0 +1,74 @@
const axios = require('axios');
const VIDLINK_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
'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) {
console.log(`[Vidlink] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
try {
// Step 1: Encrypt the TMDB ID via enc-dec.app
const encRes = await axios.get(
`https://enc-dec.app/api/enc-vidlink?text=${encodeURIComponent(String(tmdbId))}`,
{ timeout: 8000 }
);
const encodedTmdb = encRes.data && encRes.data.result;
if (!encodedTmdb) {
console.log('[Vidlink] Encryption step returned no result.');
return [];
}
// Step 2: Fetch file-quality streams from Vidlink API
const apiUrl = mediaType === 'tv'
? `https://vidlink.pro/api/b/tv/${encodedTmdb}/${seasonNum}/${episodeNum}?multiLang=0`
: `https://vidlink.pro/api/b/movie/${encodedTmdb}?multiLang=0`;
const apiRes = await axios.get(apiUrl, { headers: VIDLINK_HEADERS, timeout: 8000 });
const streamData = apiRes.data && apiRes.data.stream;
if (!streamData || !streamData.qualities) {
console.log('[Vidlink] No quality streams in response.');
return [];
}
const streams = Object.entries(streamData.qualities)
.filter(([, entry]) => entry && entry.url)
.sort((a, b) => qualityToNumber(b[0]) - qualityToNumber(a[0]))
.map(([qualityKey, entry]) => ({
name: 'Vidlink',
title: `Vidlink - ${qualityLabel(qualityKey)}`,
url: entry.url,
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) {
console.error(`[Vidlink] Error: ${err.message}`);
return [];
}
}
module.exports = { getVidlinkStreams };
-411
View File
@@ -1,411 +0,0 @@
#!/usr/bin/env node
const cheerio = require('cheerio');
// --- Constants ---
let BASEDOM = "https://cloudnestra.com"; // This can be updated by serversLoad
const SOURCE_URL = "https://vidsrc.xyz/embed";
// --- Helper: Direct Fetch ---
async function fetchWrapper(url, options) {
console.log(`[VidSrc Direct] Fetching: ${url}`);
return fetch(url, options);
}
// --- Helper Functions (copied and adapted from src/extractor.ts) ---
async function serversLoad(html) {
const $ = cheerio.load(html);
const servers = [];
const title = $("title").text() ?? "";
const baseFrameSrc = $("iframe").attr("src") ?? "";
if (baseFrameSrc) {
try {
const fullUrl = baseFrameSrc.startsWith("//") ? "https:" + baseFrameSrc : baseFrameSrc;
BASEDOM = new URL(fullUrl).origin;
}
catch (e) {
console.warn(`(Attempt 1) Failed to parse base URL from iframe src: ${baseFrameSrc} using new URL(), error: ${e.message}`);
// Attempt 2: Regex fallback for origin
const originMatch = (baseFrameSrc.startsWith("//") ? "https:" + baseFrameSrc : baseFrameSrc).match(/^(https?:\/\/[^/]+)/);
if (originMatch && originMatch[1]) {
BASEDOM = originMatch[1];
console.log(`(Attempt 2) Successfully extracted origin using regex: ${BASEDOM}`);
} else {
console.error(`(Attempt 2) Failed to extract origin using regex from: ${baseFrameSrc}. Using default: ${BASEDOM}`);
// Keep the default BASEDOM = "https://cloudnestra.com" if all fails
}
}
}
$(".serversList .server").each((index, element) => {
const server = $(element);
servers.push({
name: server.text().trim(),
dataHash: server.attr("data-hash") ?? null,
});
});
return {
servers: servers,
title: title,
};
}
async function parseMasterM3U8(m3u8Content, masterM3U8Url) {
const lines = m3u8Content.split('\n').map(line => line.trim());
const streams = [];
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith("#EXT-X-STREAM-INF:")) {
const infoLine = lines[i];
let quality = "unknown";
const resolutionMatch = infoLine.match(/RESOLUTION=(\d+x\d+)/);
if (resolutionMatch && resolutionMatch[1]) {
quality = resolutionMatch[1];
}
else {
const bandwidthMatch = infoLine.match(/BANDWIDTH=(\d+)/);
if (bandwidthMatch && bandwidthMatch[1]) {
quality = `${Math.round(parseInt(bandwidthMatch[1]) / 1000)}kbps`;
}
}
if (i + 1 < lines.length && lines[i + 1] && !lines[i + 1].startsWith("#")) {
const streamUrlPart = lines[i + 1];
try {
const fullStreamUrl = new URL(streamUrlPart, masterM3U8Url).href;
streams.push({ quality: quality, url: fullStreamUrl });
}
catch (e) {
console.error(`Error constructing URL for stream part: ${streamUrlPart} with base: ${masterM3U8Url}`, e);
streams.push({ quality: quality, url: streamUrlPart }); // Store partial URL as a fallback
}
i++;
}
}
}
// Sort streams by quality (highest first)
streams.sort((a, b) => {
// Extract resolution height from quality (e.g., "1280x720" -> 720)
const getHeight = (quality) => {
const match = quality.match(/(\d+)x(\d+)/);
return match ? parseInt(match[2], 10) : 0;
};
const heightA = getHeight(a.quality);
const heightB = getHeight(b.quality);
// Higher resolution comes first
return heightB - heightA;
});
return streams;
}
async function PRORCPhandler(prorcp) {
try {
const prorcpUrl = `${BASEDOM}/prorcp/${prorcp}`;
const prorcpFetch = await fetchWrapper(prorcpUrl, {
headers: {
"accept": "*/*", "accept-language": "en-US,en;q=0.9", "priority": "u=1",
"sec-ch-ua": "\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"",
"sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "script", "sec-fetch-mode": "no-cors", "sec-fetch-site": "same-origin",
'Sec-Fetch-Dest': 'iframe', "Referer": `${BASEDOM}/`, "Referrer-Policy": "origin",
},
timeout: 10000
});
if (!prorcpFetch.ok) {
console.error(`Failed to fetch prorcp: ${prorcpUrl}, status: ${prorcpFetch.status}`);
return null;
}
const prorcpResponse = await prorcpFetch.text();
const regex = /file:\s*'([^']*)'/gm;
const match = regex.exec(prorcpResponse);
if (match && match[1]) {
const masterM3U8Url = match[1];
const m3u8FileFetch = await fetchWrapper(masterM3U8Url, {
headers: { "Referer": prorcpUrl, "Accept": "*/*" },
timeout: 10000
});
if (!m3u8FileFetch.ok) {
console.error(`Failed to fetch master M3U8: ${masterM3U8Url}, status: ${m3u8FileFetch.status}`);
return null;
}
const m3u8Content = await m3u8FileFetch.text();
return parseMasterM3U8(m3u8Content, masterM3U8Url);
}
console.warn("No master M3U8 URL found in prorcp response for:", prorcpUrl);
return null;
}
catch (error) {
console.error(`Error in PRORCPhandler for ${BASEDOM}/prorcp/${prorcp}:`, error);
return null;
}
}
async function SRCRCPhandler(srcrcpPath, refererForSrcrcp) {
try {
const srcrcpUrl = BASEDOM + srcrcpPath;
console.log(`[VidSrc - SRCRCP] Fetching: ${srcrcpUrl} (Referer: ${refererForSrcrcp})`);
const response = await fetchWrapper(srcrcpUrl, {
headers: {
"accept": "*/*",
"accept-language": "en-US,en;q=0.9",
"sec-ch-ua": "\"Chromium\";v=\"128\", \"Not;A=Brand\";v=\"24\", \"Google Chrome\";v=\"128\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "iframe",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin", // This might need to be 'cross-site' if BASEDOM's origin differs from srcrcp link's origin
"Referer": refererForSrcrcp,
"Referrer-Policy": "origin",
},
timeout: 10000
});
if (!response.ok) {
console.error(`[VidSrc - SRCRCP] Failed to fetch ${srcrcpUrl}, status: ${response.status}`);
return null;
}
const responseText = await response.text();
console.log(`[VidSrc - SRCRCP] Response from ${srcrcpUrl} (first 500 chars): ${responseText.substring(0, 500)}`);
// Attempt 1: Check for "file: '...'" like in PRORCP
const fileRegex = /file:\s*'([^']*)'/gm;
const fileMatch = fileRegex.exec(responseText);
if (fileMatch && fileMatch[1]) {
const masterM3U8Url = fileMatch[1];
console.log(`[VidSrc - SRCRCP] Found M3U8 URL (via fileMatch): ${masterM3U8Url}`);
const m3u8FileFetch = await fetchWrapper(masterM3U8Url, {
headers: { "Referer": srcrcpUrl, "Accept": "*/*" },
timeout: 10000
});
if (!m3u8FileFetch.ok) {
console.error(`[VidSrc - SRCRCP] Failed to fetch master M3U8: ${masterM3U8Url}, status: ${m3u8FileFetch.status}`);
return null;
}
const m3u8Content = await m3u8FileFetch.text();
return parseMasterM3U8(m3u8Content, masterM3U8Url);
}
// Attempt 2: Check if the responseText itself is an M3U8 playlist
if (responseText.trim().startsWith("#EXTM3U")) {
console.log(`[VidSrc - SRCRCP] Response from ${srcrcpUrl} appears to be an M3U8 playlist directly.`);
return parseMasterM3U8(responseText, srcrcpUrl);
}
// Attempt 3: Look for sources = [...] or sources: [...] in script tags or in JSON-like structures
const $ = cheerio.load(responseText);
let sourcesFound = null;
$('script').each((i, script) => {
const scriptContent = $(script).html();
if (scriptContent) {
// Regex for various ways sources might be defined
const sourcesRegexes = [
/sources\s*[:=]\s*(\[[^\]]*\{(?:\s*|.*?)file\s*:\s*['"]([^'"]+)['"](?:\s*|.*?)\}[^\]]*\])/si, // extracts the URL from sources: [{file: "URL"}]
/playerInstance\.setup\s*\(\s*\{\s*sources\s*:\s*(\[[^\]]*\{(?:\s*|.*?)file\s*:\s*['"]([^'"]+)['"](?:\s*|.*?)\}[^\]]*\])/si, // for playerInstance.setup({sources: [{file: "URL"}]})
/file\s*:\s*['"]([^'"]+\.m3u8[^'"]*)['"]/i, // Direct M3U8 link in a var or object e.g. file: "URL.m3u8"
/src\s*:\s*['"]([^'"]+\.m3u8[^'"]*)['"]/i, // Direct M3U8 link e.g. src: "URL.m3u8"
/loadSource\(['"]([^'"]+\.m3u8[^'"]*)['"]\)/i, // For .loadSource("URL.m3u8")
/new\s+Player\([^)]*\{\s*src\s*:\s*['"]([^'"]+)['"]\s*\}\s*\)/i // For new Player({src: "URL"})
];
for (const regex of sourcesRegexes) {
const sourcesMatch = scriptContent.match(regex);
// For regexes that capture a JSON array-like structure in group 1 and then the URL in group 2 (first two regexes)
if (regex.source.includes('file') && regex.source.includes('sources')) { // Heuristic to identify complex sources regex capturing group 2
if (sourcesMatch && sourcesMatch[2]) { // URL is in group 2
console.log(`[VidSrc - SRCRCP] Found M3U8 URL (script complex): ${sourcesMatch[2]}`);
sourcesFound = [{ quality: 'default', url: sourcesMatch[2] }];
return false; // break cheerio loop
}
}
// For simpler regexes where URL is in group 1
else if (sourcesMatch && sourcesMatch[1]) {
console.log(`[VidSrc - SRCRCP] Found M3U8 URL (script simple): ${sourcesMatch[1]}`);
sourcesFound = [{ quality: 'default', url: sourcesMatch[1] }];
return false; // break cheerio loop
}
}
// Fallback: Look for any absolute .m3u8 URL within the script tag
if (!sourcesFound) {
const m3u8GenericMatch = scriptContent.match(/['"](https?:\/\/[^'"\s]+\.m3u8[^'"\s]*)['"]/i);
if (m3u8GenericMatch && m3u8GenericMatch[1]) {
console.log(`[VidSrc - SRCRCP] Found M3U8 URL (script generic fallback): ${m3u8GenericMatch[1]}`);
sourcesFound = [{ quality: 'default', url: m3u8GenericMatch[1] }];
return false; // break cheerio loop
}
}
}
});
if (sourcesFound && sourcesFound.length > 0) {
// Process the first valid M3U8 URL found, or return direct links
const m3u8Source = sourcesFound.find(s => s.url && s.url.includes('.m3u8'));
if (m3u8Source) {
console.log(`[VidSrc - SRCRCP] First M3U8 source from script: ${m3u8Source.url}`);
// Ensure URL is absolute
const absoluteM3u8Url = m3u8Source.url.startsWith('http') ? m3u8Source.url : new URL(m3u8Source.url, srcrcpUrl).href;
const m3u8FileFetch = await fetchWrapper(absoluteM3u8Url, {
headers: { "Referer": srcrcpUrl, "Accept": "*/*" },
timeout: 10000
});
if (!m3u8FileFetch.ok) {
console.error(`[VidSrc - SRCRCP] Failed to fetch M3U8 from script source: ${absoluteM3u8Url}, status: ${m3u8FileFetch.status}`);
return null;
}
const m3u8Content = await m3u8FileFetch.text();
return parseMasterM3U8(m3u8Content, absoluteM3u8Url);
} else {
// Assuming direct links if no .m3u8 found in the sources array
console.log(`[VidSrc - SRCRCP] Assuming direct links from script sources:`, sourcesFound);
return sourcesFound.map(s => ({
quality: s.quality || s.label || 'auto',
url: s.url.startsWith('http') ? s.url : new URL(s.url, srcrcpUrl).href
}));
}
}
console.warn(`[VidSrc - SRCRCP] No stream extraction method succeeded for ${srcrcpUrl}`);
return null;
} catch (error) {
console.error(`[VidSrc - SRCRCP] Error in SRCRCPhandler for ${srcrcpPath}:`, error);
return null;
}
}
async function rcpGrabber(html) {
const regex = /src:\s*'([^']*)'/;
const match = html.match(regex);
if (!match || !match[1])
return null;
return { metadata: { image: "" }, data: match[1] };
}
function getObject(id) {
const arr = id.split(':');
return { id: arr[0], season: arr[1], episode: arr[2] };
}
function getUrl(id, type) {
if (type === "movie") {
return `${SOURCE_URL}/movie/${id}`;
}
else {
const obj = getObject(id);
return `${SOURCE_URL}/tv/${obj.id}/${obj.season}-${obj.episode}`;
}
}
async function getStreamContent(id, type) {
const url = getUrl(id, type);
const embedRes = await fetchWrapper(url, { headers: { "Referer": SOURCE_URL } });
if (!embedRes.ok) {
console.error(`Failed to fetch embed page ${url}: ${embedRes.status}`);
return [];
}
const embedResp = await embedRes.text();
const { servers, title } = await serversLoad(embedResp);
const apiResponse = [];
// MODIFIED: Process servers in parallel
const serverPromises = servers.map(async (server) => {
if (!server.dataHash) return null; // Skip servers without dataHash
try {
const rcpUrl = `${BASEDOM}/rcp/${server.dataHash}`;
const rcpRes = await fetchWrapper(rcpUrl, {
headers: { 'Sec-Fetch-Dest': 'iframe', "Referer": url }
});
if (!rcpRes.ok) {
console.warn(`RCP fetch failed for server ${server.name}: ${rcpRes.status}`);
return null; // Failed to fetch RCP
}
const rcpHtml = await rcpRes.text();
const rcpData = await rcpGrabber(rcpHtml);
if (!rcpData || !rcpData.data) {
console.warn(`Skipping server ${server.name} due to missing rcp data.`);
return null; // Missing RCP data
}
let streamDetails = null;
if (rcpData.data.startsWith("/prorcp/")) {
streamDetails = await PRORCPhandler(rcpData.data.replace("/prorcp/", ""));
} else if (rcpData.data.startsWith("/srcrcp/")) {
if (server.name === "Superembed" || server.name === "2Embed") {
console.warn(`[VidSrc] Skipping SRCRCP for known problematic server: ${server.name}`);
return null;
}
streamDetails = await SRCRCPhandler(rcpData.data, rcpUrl); // Pass rcpUrl as referer
} else {
console.warn(`Unhandled rcp data type for server ${server.name}: ${rcpData.data.substring(0, 50)}`);
return null; // Unhandled type
}
if (streamDetails && streamDetails.length > 0) {
return {
name: title,
image: rcpData.metadata.image,
mediaId: id,
streams: streamDetails,
referer: BASEDOM,
};
} else {
console.warn(`No stream details from handler for server ${server.name} (${rcpData.data})`);
return null; // No stream details found by the handler
}
} catch (e) {
console.error(`Error processing server ${server.name} (${server.dataHash}):`, e);
return null; // Error during server processing
}
});
const results = await Promise.all(serverPromises);
// Filter out null results (failed servers or no streams) and add valid results to apiResponse
results.forEach(result => {
if (result) {
apiResponse.push(result);
}
});
// END MODIFICATION
return apiResponse;
}
// --- Main execution logic (conditional) ---
async function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error("Usage: node vidsrcextractor.js <id> <type>");
console.error("Example (movie): node vidsrcextractor.js tt0111161 movie");
console.error("Example (series): node vidsrcextractor.js tt0944947:1:1 series"); // Game of Thrones S1E1
return 1; // error code
}
const id = args[0];
const type = args[1];
if (type !== "movie" && type !== "series") {
console.error("Invalid type. Must be 'movie' or 'series'.");
return 1;
}
if (type === "series" && id.split(':').length < 3) {
console.error("Invalid series ID format. Expected 'ttID:season:episode' (e.g., tt0944947:1:1).");
return 1;
}
console.log(`Fetching streams for ID: ${id}, Type: ${type}`);
try {
const results = await getStreamContent(id, type);
if (results && results.length > 0) {
console.log("Extracted Data:");
console.log(JSON.stringify(results, null, 2));
} else {
console.log("No streams found or an error occurred.");
}
return 0;
} catch (error) {
console.error("Error during extraction process:", error);
return 1;
}
}
// Export the function for use in other modules
module.exports = { getStreamContent };
// Run main only if the script is executed directly
if (require.main === module) {
main()
.then(code => {
if (code !== 0) {
console.error(`[vidsrcextractor] Finished with non-zero code ${code}`);
}
})
.catch(error => {
console.error("Unhandled error in main execution:", error);
});
}
+143 -178
View File
@@ -1,215 +1,180 @@
/** const axios = require('axios');
* Vixsrc streaming provider integration (standalone)
* React Native compatible variant - no external addon framework
*/
// Constants
const TMDB_API_KEY = "68e094699525b18a70bab2f86b1fa706";
const BASE_URL = 'https://vixsrc.to'; const BASE_URL = 'https://vixsrc.to';
const VIXSRC_HEADERS = {
// Helper function to make HTTP requests with default headers 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150 Safari/537.36',
function makeRequest(url, options = {}) { 'Accept': 'application/json, text/javascript, */*; q=0.01',
const defaultHeaders = { 'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Referer': BASE_URL,
'Accept': 'application/json,*/*', 'Origin': BASE_URL
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
...options.headers
}; };
return fetch(url, { // Step 1: GET /api/movie/{id} or /api/tv/{id}/{s}/{e} → { src: "/embed/..." }
method: options.method || 'GET', async function fetchApi(url) {
headers: defaultHeaders, try {
...options const response = await axios.get(url, { headers: VIXSRC_HEADERS, timeout: 10000 });
}) if (response.status !== 200 || !response.data) return null;
.then(response => { return response.data;
if (!response.ok) { } catch {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); return null;
} }
return response; }
})
.catch(error => { // Step 2: GET BASE_URL + sublink.src → HTML embed page
console.error(`[Vixsrc] Request failed for ${url}: ${error.message}`); async function fetchEmbedPage(suburl) {
throw error; try {
const response = await axios.get(BASE_URL + suburl, {
headers: { ...VIXSRC_HEADERS, Accept: 'text/html,application/xhtml+xml,*/*' },
timeout: 10000,
responseType: 'text'
}); });
} if (response.status !== 200) return null;
return response.data;
// Helper function to get TMDB info } catch {
function getTmdbInfo(tmdbId, mediaType) { return null;
const url = `https://api.themoviedb.org/3/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}?api_key=${TMDB_API_KEY}`;
return makeRequest(url)
.then(response => response.json())
.then(data => ({ data }));
}
// Helper function to parse M3U8 playlist
// Removed obsolete parseM3U8Playlist implementation
// Removed unused helpers getQualityFromResolution and resolveUrl
// Helper function to extract stream URL from Vixsrc page
function extractStreamFromPage(url, contentType, contentId, seasonNum, episodeNum) {
let vixsrcUrl;
let subtitleApiUrl;
if (contentType === 'movie') {
vixsrcUrl = `${BASE_URL}/movie/${contentId}`;
subtitleApiUrl = `https://sub.wyzie.ru/search?id=${contentId}`;
} else {
vixsrcUrl = `${BASE_URL}/tv/${contentId}/${seasonNum}/${episodeNum}`;
subtitleApiUrl = `https://sub.wyzie.ru/search?id=${contentId}&season=${seasonNum}&episode=${episodeNum}`;
}
console.log(`[Vixsrc] Fetching: ${vixsrcUrl}`);
return makeRequest(vixsrcUrl, {
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
})
.then(response => response.text())
.then(html => {
console.log(`[Vixsrc] HTML length: ${html.length} characters`);
let masterPlaylistUrl = null;
// Method 1: Look for window.masterPlaylist (primary method)
if (html.includes('window.masterPlaylist')) {
console.log('[Vixsrc] Found window.masterPlaylist');
const urlMatch = html.match(/url:\s*['"]([^'"]+)['"]/);
const tokenMatch = html.match(/['"]?token['"]?\s*:\s*['"]([^'"]+)['"]/);
const expiresMatch = html.match(/['"]?expires['"]?\s*:\s*['"]([^'"]+)['"]/);
if (urlMatch && tokenMatch && expiresMatch) {
const baseUrl = urlMatch[1];
const token = tokenMatch[1];
const expires = expiresMatch[1];
console.log('[Vixsrc] Extracted tokens:');
console.log(` - Base URL: ${baseUrl}`);
console.log(` - Token: ${token.substring(0, 20)}...`);
console.log(` - Expires: ${expires}`);
// Construct the master playlist URL
if (baseUrl.includes('?b=1')) {
masterPlaylistUrl = `${baseUrl}&token=${token}&expires=${expires}&h=1&lang=en`;
} else {
masterPlaylistUrl = `${baseUrl}?token=${token}&expires=${expires}&h=1&lang=en`;
}
console.log(`[Vixsrc] Constructed master playlist URL: ${masterPlaylistUrl}`);
} }
} }
// Method 2: Look for direct .m3u8 URLs // Step 3: Extract token, expires, playlist URL from embed HTML
if (!masterPlaylistUrl) { function extractTokenData(html) {
const m3u8Match = html.match(/(https?:\/\/[^'"\s]+\.m3u8[^'"\s]*)/); const token = html.match(/token["']\s*:\s*["']([^"']+)/)?.[1];
if (m3u8Match) { const expires = html.match(/expires["']\s*:\s*["']([^"']+)/)?.[1];
masterPlaylistUrl = m3u8Match[1]; const playlist = html.match(/url\s*:\s*["']([^"']+)/)?.[1];
console.log('[Vixsrc] Found direct .m3u8 URL:', masterPlaylistUrl);
}
}
// Method 3: Look for stream URLs in script tags if (!token || !expires || !playlist) return null;
if (!masterPlaylistUrl) {
const scriptMatches = html.match(/<script[^>]*>(.*?)<\/script>/gs);
if (scriptMatches) {
for (const script of scriptMatches) {
const streamMatch = script.match(/['"]?(https?:\/\/[^'"\s]+(?:\.m3u8|playlist)[^'"\s]*)/);
if (streamMatch) {
masterPlaylistUrl = streamMatch[1];
console.log('[Vixsrc] Found stream in script:', masterPlaylistUrl);
break;
}
}
}
}
if (!masterPlaylistUrl) { // Reject expired tokens (with 60s grace period)
console.log('[Vixsrc] No master playlist URL found'); if (parseInt(expires, 10) * 1000 - 60_000 < Date.now()) {
console.log('[Vixsrc] Token is expired');
return null; return null;
} }
return { masterPlaylistUrl, subtitleApiUrl }; return { token, expires, playlist };
}
// Step 4: Append token params to master URL
function buildMasterUrl(tokenData) {
const { token, expires, playlist } = tokenData;
const sep = playlist.includes('?') ? '&' : '?';
return `${playlist}${sep}token=${token}&expires=${expires}&h=1`;
}
// Step 5: Fetch the master HLS playlist
async function fetchPlaylist(masterUrl, pageApiUrl) {
try {
const response = await axios.get(masterUrl, {
headers: { ...VIXSRC_HEADERS, Referer: pageApiUrl },
timeout: 10000,
responseType: 'text'
}); });
if (response.status !== 200) return null;
return response.data;
} catch {
return null;
}
} }
// Helper function to get subtitles // Step 6: Parse HLS manifest for quality variants, audio tracks, subtitles
function getSubtitles(subtitleApiUrl) { function parsePlaylist(content, masterUrl, pageApiUrl) {
return makeRequest(subtitleApiUrl) const sources = [];
.then(response => response.json()) const subtitles = [];
.then(subtitleData => { const audioTracks = [];
// Find English subtitle track (same logic as original)
let subtitleTrack = subtitleData.find(track =>
track.display.includes('English') && (track.encoding === 'ASCII' || track.encoding === 'UTF-8')
);
if (!subtitleTrack) { const lines = content.split('\n');
subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP1252');
// Audio tracks
for (const line of lines) {
if (!line.startsWith('#EXT-X-MEDIA:TYPE=AUDIO')) continue;
const language = line.match(/LANGUAGE="([^"]+)"/)?.[1] ?? 'unknown';
const label = line.match(/NAME="([^"]+)"/)?.[1] ?? 'Audio';
audioTracks.push({ language, label });
} }
if (!subtitleTrack) { // Subtitles
subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP1250'); for (const line of lines) {
if (!line.startsWith('#EXT-X-MEDIA:TYPE=SUBTITLES')) continue;
const url = line.match(/URI="([^"]+)"/)?.[1];
if (!url) continue;
const label = line.match(/NAME="([^"]+)"/)?.[1] ?? 'unknown';
subtitles.push({ url, label, format: 'vtt' });
} }
if (!subtitleTrack) { // Quality variants — find the highest resolution
subtitleTrack = subtitleData.find(track => track.display.includes('English') && track.encoding === 'CP850'); const variantRegex = /#EXT-X-STREAM-INF:[^\n]*RESOLUTION=\d+x(\d+)[^\n]*\n([^\n]+)/g;
let match;
let bestResolution = 0;
while ((match = variantRegex.exec(content)) !== null) {
const res = parseInt(match[1], 10);
if (res > bestResolution) bestResolution = res;
} }
const subtitles = subtitleTrack ? subtitleTrack.url : ''; if (bestResolution === 0) return { sources: [], subtitles: [] };
console.log(subtitles ? `[Vixsrc] Found subtitles: ${subtitles}` : '[Vixsrc] No English subtitles found');
return subtitles; sources.push({
}) name: `Vixsrc - ${bestResolution}p`,
.catch(error => { title: `Vixsrc - ${bestResolution}p`,
console.log('[Vixsrc] Subtitle fetch failed:', error.message); url: masterUrl,
return ''; quality: `${bestResolution}p`,
provider: 'Vixsrc',
headers: {
'Referer': pageApiUrl,
'User-Agent': VIXSRC_HEADERS['User-Agent']
}
}); });
return { sources, subtitles };
} }
// Main function to get streams async function getVixsrcStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
function getVixsrcStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) {
console.log(`[Vixsrc] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`); console.log(`[Vixsrc] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`);
return getTmdbInfo(tmdbId, mediaType) // Build API URL
.then(() => extractStreamFromPage(null, mediaType, tmdbId, seasonNum, episodeNum)) let apiUrl;
.then(streamData => { if (mediaType === 'movie') {
if (!streamData) { apiUrl = `${BASE_URL}/api/movie/${tmdbId}`;
console.log('[Vixsrc] No stream data found'); } else {
apiUrl = `${BASE_URL}/api/tv/${tmdbId}/${seasonNum}/${episodeNum}`;
}
console.log(`[Vixsrc] Step 1 - Calling API: ${apiUrl}`);
const apiData = await fetchApi(apiUrl);
if (!apiData || !apiData.src) {
console.log('[Vixsrc] No src returned from API');
return [];
}
console.log(`[Vixsrc] Step 2 - Fetching embed page: ${BASE_URL}${apiData.src}`);
const html = await fetchEmbedPage(apiData.src);
if (!html) {
console.log('[Vixsrc] Failed to fetch embed page');
return [];
}
console.log(`[Vixsrc] Embed HTML length: ${html.length} characters`);
const tokenData = extractTokenData(html);
if (!tokenData) {
console.log('[Vixsrc] Could not extract token/expires/playlist from embed HTML');
return []; return [];
} }
const { masterPlaylistUrl, subtitleApiUrl } = streamData; const masterUrl = buildMasterUrl(tokenData);
console.log(`[Vixsrc] Step 3 - Master URL: ${masterUrl}`);
// Return single master playlist with Auto quality const playlistContent = await fetchPlaylist(masterUrl, apiUrl);
console.log('[Vixsrc] Returning master playlist with Auto quality...'); if (!playlistContent) {
console.log('[Vixsrc] Failed to fetch HLS playlist');
// Get subtitles
return getSubtitles(subtitleApiUrl)
.then(subtitles => {
// Return single stream with master playlist
const tmdbStreams = [{
name: "Vixsrc",
title: "Auto Quality Stream",
url: masterPlaylistUrl,
quality: '1080p',
type: 'direct',
headers: {
'Referer': BASE_URL,
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
}];
console.log('[Vixsrc] Successfully processed 1 stream with Auto quality');
return tmdbStreams;
});
})
.catch(error => {
console.error(`[Vixsrc] Error in getVixsrcStreams: ${error.message}`);
return []; return [];
}); }
const { sources, subtitles } = parsePlaylist(playlistContent, masterUrl, apiUrl);
if (sources.length === 0) {
console.log('[Vixsrc] No streams found in HLS playlist');
return [];
}
console.log(`[Vixsrc] Successfully extracted ${sources.length} stream(s). Subtitles: ${subtitles.length}`);
return sources;
} }
module.exports = { getVixsrcStreams }; module.exports = { getVixsrcStreams };
-158
View File
@@ -1,158 +0,0 @@
// xprime.js (simplified & cleaned)
require('dotenv').config();
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const RETRY_DELAY_MS_XPRIME = 1000;
const MAX_RETRIES_XPRIME = 3;
// Cache directory (Vercel ephemeral vs local)
const CACHE_DIR = process.env.VERCEL ? path.join('/tmp', '.cache') : path.join(__dirname, '.cache');
async function ensureCacheDir(dirPath) {
try { await fs.mkdir(dirPath, { recursive: true }); } catch (_) {
// swallow errors creating cache dir (ephemeral env or permission)
}
}
async function getFromCache(cacheKey, subDir = '') {
if (process.env.DISABLE_CACHE === 'true') return null;
try {
const fullDir = path.join(CACHE_DIR, subDir);
const filePath = path.join(fullDir, cacheKey);
const data = await fs.readFile(filePath, 'utf8');
return data;
} catch (_) { return null; }
}
async function saveToCache(cacheKey, data, subDir = '') {
if (process.env.DISABLE_CACHE === 'true') return;
try {
const fullDir = path.join(CACHE_DIR, subDir);
await fs.mkdir(fullDir, { recursive: true });
const filePath = path.join(fullDir, cacheKey);
await fs.writeFile(filePath, data, 'utf8');
} catch (_) {
// ignore cache write errors (non-fatal)
}
}
const BROWSER_HEADERS_XPRIME = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Sec-Ch-Ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Connection': 'keep-alive'
};
async function fetchWithRetry(url, options, maxRetries = MAX_RETRIES_XPRIME) {
const { default: fetch } = await import('node-fetch');
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res;
} catch (err) {
lastError = err;
if (attempt < maxRetries) {
await new Promise(r => setTimeout(r, RETRY_DELAY_MS_XPRIME * Math.pow(2, attempt - 1)));
}
}
}
throw lastError;
}
async function fetchStreamSize(url) {
const subDir = 'xprime_stream_sizes';
const hash = crypto.createHash('md5').update(url).digest('hex');
const key = `${hash}.txt`;
const cached = await getFromCache(key, subDir);
if (cached) return cached;
if (url.toLowerCase().includes('.m3u8')) {
await saveToCache(key, 'Playlist (size N/A)', subDir);
return 'Playlist (size N/A)';
}
try {
const { default: fetch } = await import('node-fetch');
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000);
let size = 'Unknown size';
try {
const head = await fetch(url, { method: 'HEAD', signal: controller.signal });
const cl = head.headers.get('content-length');
if (cl) {
const bytes = parseInt(cl, 10);
if (!isNaN(bytes)) {
if (bytes < 1024) size = `${bytes} B`;
else if (bytes < 1024 * 1024) size = `${(bytes / 1024).toFixed(2)} KB`;
else if (bytes < 1024 * 1024 * 1024) size = `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
else size = `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
}
} finally { clearTimeout(t); }
await saveToCache(key, size, subDir);
return size;
} catch (_) {
await saveToCache(key, 'Unknown size', subDir);
return 'Unknown size';
}
}
async function getXprimeStreams(title, year, type, seasonNum, episodeNum) {
if (!title || !year) return [];
const encoded = encodeURIComponent(title);
let apiUrl;
if (type === 'movie') {
apiUrl = `https://backend.xprime.tv/primebox?name=${encoded}&year=${year}&fallback_year=${year}`;
} else if (type === 'tv') {
if (seasonNum == null || episodeNum == null) return [];
apiUrl = `https://backend.xprime.tv/primebox?name=${encoded}&year=${year}&fallback_year=${year}&season=${seasonNum}&episode=${episodeNum}`;
} else return [];
console.log(`[Xprime.tv] Fetch attempt '${title}' (${year}) type=${type}`);
let resJson;
try {
const r = await fetchWithRetry(apiUrl, { headers: BROWSER_HEADERS_XPRIME });
resJson = await r.json();
} catch (e) {
console.error('[Xprime.tv] Fetch failed:', e.message);
return [];
}
const streams = [];
const processItem = (item) => {
if (!item || item.error || !item.streams) return;
Object.entries(item.streams).forEach(([quality, url]) => {
if (!url || typeof url !== 'string') return;
streams.push({
url,
quality: quality || 'Unknown',
title: `${title} - ${type === 'tv' ? `S${String(seasonNum).padStart(2,'0')}E${String(episodeNum).padStart(2,'0')} ` : ''}${quality}`,
provider: 'Xprime.tv',
codecs: [],
size: 'Unknown size'
});
});
};
if (Array.isArray(resJson)) resJson.forEach(processItem); else processItem(resJson);
if (streams.length) {
console.time('[Xprime.tv] Fetch stream sizes');
await Promise.all(streams.map(async s => { s.size = await fetchStreamSize(s.url); }));
console.timeEnd('[Xprime.tv] Fetch stream sizes');
}
console.log(`[Xprime.tv] Returning ${streams.length} streams.`);
return streams;
}
ensureCacheDir(CACHE_DIR).catch(()=>{});
module.exports = { getXprimeStreams };
+309
View File
@@ -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 };
+453
View File
@@ -0,0 +1,453 @@
// Lightweight proxy/m3u8/segment/subtitle rewriting layer. thanks to https://github.com/cinepro-org/ for the original code.
// Adapted from external project (user supplied) to fit CommonJS style and existing config system.
// Provides conditional route registration via enableProxy flag.
const cors = require('cors');
const fetch = require('node-fetch');
const CACHE_MAX_SIZE = 2000;
const CACHE_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
const segmentCache = new Map();
// Track first open-ended (bytes=0-) range per target to clamp only once per TTL window
const openRangeClampMap = new Map();
const OPEN_RANGE_CLAMP_TTL_MS = 5 * 60 * 1000; // 5 minutes
// Progressive open-ended growth tracking: keeps expanding bytes=0- served window.
const progressiveOpenMap = new Map(); // url -> { lastEnd }
// Tail prefetch cache: stores last N KB for quick tail range responses
const tailPrefetchMap = new Map(); // url -> { data: Buffer, start: number, end: number, size: number, ts: number }
const TAIL_PREFETCH_TTL_MS = 10 * 60 * 1000; // 10 minutes
function isCacheDisabled() {
return process.env.DISABLE_CACHE === 'true';
}
function cleanupCache() {
const now = Date.now();
for (const [url, entry] of segmentCache.entries()) {
if (now - entry.timestamp > CACHE_EXPIRY_MS) segmentCache.delete(url);
}
if (segmentCache.size > CACHE_MAX_SIZE) {
const entries = Array.from(segmentCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp);
const toRemove = entries.slice(0, segmentCache.size - CACHE_MAX_SIZE);
toRemove.forEach(([u]) => segmentCache.delete(u));
}
return segmentCache.size;
}
setInterval(cleanupCache, 30 * 60 * 1000).unref();
function cleanupClampMap() {
const now = Date.now();
for (const [url, ts] of openRangeClampMap.entries()) {
if (now - ts > OPEN_RANGE_CLAMP_TTL_MS) openRangeClampMap.delete(url);
}
}
setInterval(cleanupClampMap, 10 * 60 * 1000).unref();
function cleanupTailPrefetch() {
const now = Date.now();
for (const [url, entry] of tailPrefetchMap.entries()) {
if (now - entry.ts > TAIL_PREFETCH_TTL_MS) tailPrefetchMap.delete(url);
}
}
setInterval(cleanupTailPrefetch, 15 * 60 * 1000).unref();
function getCachedSegment(url) {
if (isCacheDisabled()) return null;
const e = segmentCache.get(url);
if (!e) return null;
if (Date.now() - e.timestamp > CACHE_EXPIRY_MS) { segmentCache.delete(url); return null; }
return e;
}
async function prefetchSegment(url, headers) {
if (isCacheDisabled() || segmentCache.size >= CACHE_MAX_SIZE) return;
const existing = segmentCache.get(url);
if (existing && Date.now() - existing.timestamp <= CACHE_EXPIRY_MS) return;
try {
const resp = await fetch(url, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
if (!resp.ok) return;
const data = new Uint8Array(await resp.arrayBuffer());
const responseHeaders = {};
resp.headers.forEach((v, k) => responseHeaders[k] = v);
segmentCache.set(url, { data, headers: responseHeaders, timestamp: Date.now() });
} catch (_e) { /* ignore */ }
}
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
function inferContentType(upstreamCT, targetUrl) {
const bad = !upstreamCT || /application\/octet-stream/i.test(upstreamCT) || /application\/(x-)?zip/i.test(upstreamCT);
if (!bad) return upstreamCT;
if (/\.mkv(\?|$)/i.test(targetUrl)) return 'video/x-matroska';
if (/\.mp4(\?|$)/i.test(targetUrl)) return 'video/mp4';
if (/\.m3u8(\?|$)/i.test(targetUrl)) return 'application/vnd.apple.mpegurl';
if (/\.ts(\?|$)/i.test(targetUrl)) return 'video/mp2t';
return 'application/octet-stream';
}
function extractOriginalUrl(proxyUrl) {
try {
const url = new URL(proxyUrl);
if (url.pathname.includes('/proxy/')) {
const m = url.pathname.match(/\/proxy\/(.+)$/);
if (m) {
let decoded = decodeURIComponent(m[1]);
while (decoded.includes('%2F')) {
try { decoded = decodeURIComponent(decoded); } catch { break; }
}
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'));
const patterns = [
/\/api\/[^/]+\/proxy\?url=(.+)$/,
/\/proxy\?.*url=([^&]+)/,
/\/stream\/proxy\/(.+)$/,
/\/p\/(.+)$/
];
for (const p of patterns) {
const m = proxyUrl.match(p); if (m) return decodeURIComponent(m[1]);
}
return proxyUrl;
} catch { return proxyUrl; }
}
function rewriteM3u8(content, targetUrl, baseProxyUrl, headers) {
const lines = content.split('\n');
const out = []; const segmentUrls = [];
for (const line of lines) {
if (line.startsWith('#')) {
if (line.startsWith('#EXT-X-KEY:')) {
const regex = /https?:\/\/[^""\s]+/g; const keyUrl = regex.exec(line)?.[0];
if (keyUrl) {
const proxyUrl = `${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(keyUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`;
out.push(line.replace(keyUrl, proxyUrl));
if (!isCacheDisabled()) prefetchSegment(keyUrl, headers);
} else out.push(line);
} else if (line.startsWith('#EXT-X-MEDIA:') || line.startsWith('#EXT-X-I-FRAME-STREAM-INF:')) {
const uriMatch = line.match(/URI="([^"]+)"/);
if (uriMatch) {
try {
const mediaUrl = new URL(uriMatch[1], targetUrl).href;
const proxyUrl = `${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(mediaUrl)}&headers=${encodeURIComponent(JSON.stringify(headers))}`;
out.push(line.replace(uriMatch[1], proxyUrl));
} catch { out.push(line); }
} else out.push(line);
} else out.push(line);
} else if (line.trim()) {
try {
const abs = new URL(line, targetUrl).href;
if (/\.m3u8(\?|$)/i.test(abs)) {
out.push(`${baseProxyUrl}/m3u8-proxy?url=${encodeURIComponent(abs)}&headers=${encodeURIComponent(JSON.stringify(headers))}`);
} else if (/\.ts(\?|$)/i.test(abs)) {
segmentUrls.push(abs);
out.push(`${baseProxyUrl}/ts-proxy?url=${encodeURIComponent(abs)}&headers=${encodeURIComponent(JSON.stringify(headers))}`);
} else out.push(line);
} catch { out.push(line); }
} else out.push(line);
}
if (segmentUrls.length && !isCacheDisabled()) {
Promise.all(segmentUrls.map(u => prefetchSegment(u, headers))).catch(() => undefined);
}
return out.join('\n');
}
function createProxyRoutes(app) {
// m3u8 playlist proxy
app.get('/m3u8-proxy', cors(), async (req, res) => {
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'URL parameter required' });
let headers = {};
try { headers = JSON.parse(req.query.headers || '{}'); } catch {
// Ignore URL parsing errors
}
try {
const response = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
if (!response.ok) return res.status(response.status).json({ error: `M3U8 fetch failed: ${response.status}` });
const text = await response.text();
const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'http';
const baseProxyUrl = `${protocol}://${req.get('host')}`;
const rewritten = rewriteM3u8(text, targetUrl, baseProxyUrl, headers);
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Access-Control-Allow-Origin', '*');
res.send(rewritten);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ts / key segment proxy
app.get('/ts-proxy', cors(), async (req, res) => {
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'URL parameter required' });
const debug = req.query.debug === '1';
const noSynth = req.query.noSynth === '1';
const force200 = req.query.force200 === '1';
const clampOpen = req.query.clampOpen !== '0';
const progressiveOpen = req.query.progressiveOpen !== '0';
const tailPrefetchEnabled = req.query.tailPrefetch !== '0';
let tailPrefetchKB = parseInt(req.query.tailPrefetchKB || '256', 10);
if (isNaN(tailPrefetchKB) || tailPrefetchKB < 64) tailPrefetchKB = 256;
if (tailPrefetchKB > 2048) tailPrefetchKB = 2048; // max 2MB tail window
let openChunkKB = parseInt(req.query.openChunkKB || '4096', 10);
if (isNaN(openChunkKB) || openChunkKB < 64) openChunkKB = 4096;
if (openChunkKB > 16384) openChunkKB = 16384; // cap at 16MB
let initChunkKB = parseInt(req.query.initChunkKB || '512', 10);
if (isNaN(initChunkKB) || initChunkKB < 64) initChunkKB = 512;
if (initChunkKB > 2048) initChunkKB = 2048; // hard cap 2MB
let headers = {}; try { headers = JSON.parse(req.query.headers || '{}'); } catch {
// Ignore URL parsing errors
}
// Pass through Range header for progressive playback / seeking
const range = req.headers['range'];
let appliedClamp = false;
let effectiveRange = range;
if (range) headers.Range = range;
// Clamp first open-ended bytes=0- request to a bounded span (once per TTL) to avoid gigantic initial 206
if (range && /^bytes=0-\s*$/i.test(range) && !force200) {
const now = Date.now();
// Progressive growth supersedes single-shot clamp if enabled
if (progressiveOpen) {
const prog = progressiveOpenMap.get(targetUrl) || { lastEnd: -1 };
const prevEnd = prog.lastEnd;
let nextEnd;
if (prog.lastEnd < 0) {
nextEnd = openChunkKB * 1024 - 1;
} else {
const increment = openChunkKB * 1024;
nextEnd = prog.lastEnd + increment;
const maxCap = 256 * 1024 * 1024 - 1; // 256MB
if (nextEnd > maxCap) nextEnd = maxCap;
}
const newRange = `bytes=0-${nextEnd}`;
headers.Range = newRange;
effectiveRange = newRange;
prog.lastEnd = nextEnd;
progressiveOpenMap.set(targetUrl, prog);
appliedClamp = true; // reuse flag for logging
if (debug) console.log('[ts-proxy] progressiveOpen expand', { prevEnd, nextEnd, increment: openChunkKB * 1024 });
} else if (clampOpen) {
const lastTs = openRangeClampMap.get(targetUrl);
if (!lastTs || (now - lastTs) > OPEN_RANGE_CLAMP_TTL_MS) {
const clampBytes = openChunkKB * 1024;
const newRange = `bytes=0-${clampBytes - 1}`;
headers.Range = newRange;
effectiveRange = newRange;
openRangeClampMap.set(targetUrl, now);
appliedClamp = true;
}
}
}
if (debug) console.log('[ts-proxy] incoming', { url: targetUrl, range, effectiveRange, appliedClamp, clampOpen, progressiveOpen, openChunkKB, noSynth, initChunkKB, force200, tailPrefetchEnabled, tailPrefetchKB });
if (!isCacheDisabled()) {
const cached = getCachedSegment(targetUrl);
if (cached) {
const ct = inferContentType(cached.headers['content-type'], targetUrl);
res.setHeader('Content-Type', ct);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Accept-Ranges', 'bytes');
if (debug) console.log('[ts-proxy] cache hit');
return res.send(Buffer.from(cached.data));
}
}
try {
// If we have a tail prefetch entry and the client requests a tail range fully inside it, serve from memory early.
if (effectiveRange && /^bytes=\d+-$/i.test(effectiveRange)) {
const tailEntry = tailPrefetchMap.get(targetUrl);
if (tailEntry) {
const m = effectiveRange.match(/bytes=(\d+)-/i);
if (m) {
const startReq = parseInt(m[1], 10);
if (!isNaN(startReq) && startReq >= tailEntry.start && startReq <= tailEntry.end) {
const offset = startReq - tailEntry.start;
const slice = tailEntry.data.subarray(offset);
const endByte = tailEntry.end;
res.status(206);
const ct = inferContentType(null, targetUrl);
res.setHeader('Content-Type', ct);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Length', slice.length.toString());
res.setHeader('Content-Range', `bytes ${startReq}-${endByte}/${tailEntry.size}`);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
if (debug) console.log('[ts-proxy] tail serve hit', { startReq, cachedStart: tailEntry.start, cachedEnd: tailEntry.end, size: tailEntry.size });
return res.end(slice);
}
}
}
}
// HEAD preflight (only when no explicit Range requested) to get size for synthesized partial response
let contentLength = null;
let upstreamAcceptRanges = null;
if (!effectiveRange) {
try {
const headResp = await fetch(targetUrl, { method: 'HEAD', headers: { 'User-Agent': DEFAULT_UA, ...headers } });
if (headResp.ok) {
contentLength = headResp.headers.get('content-length');
upstreamAcceptRanges = headResp.headers.get('accept-ranges');
if (debug) console.log('[ts-proxy] HEAD ok', { contentLength, upstreamAcceptRanges });
} else if (debug) {
console.log('[ts-proxy] HEAD status', headResp.status);
}
} catch (_e) { if (debug) console.log('[ts-proxy] HEAD failed'); }
}
// Fallback probe: if still no size, attempt a 0-0 range request to derive total size from Content-Range
if (!effectiveRange && !contentLength) {
try {
const probeRange = 'bytes=0-0';
if (debug) console.log('[ts-proxy] probe range');
const probeResp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, 'Range': probeRange } });
if (probeResp.status === 206) {
const cr = probeResp.headers.get('content-range');
if (cr) {
const m = cr.match(/\/(\d+)$/); if (m) contentLength = m[1];
}
try { await probeResp.arrayBuffer(); } catch (readErr) {
if (debug) console.log('[ts-proxy] probe body read failed', readErr.message);
}
if (debug) console.log('[ts-proxy] probe success', { contentLength });
} else if (debug) {
console.log('[ts-proxy] probe status', probeResp.status);
}
} catch (_e) { if (debug) console.log('[ts-proxy] probe failed'); }
}
if (debug && contentLength) console.log('[ts-proxy] size determined', contentLength);
// Initiate tail prefetch asynchronously (once) when size known and feature enabled
if (tailPrefetchEnabled && contentLength && !tailPrefetchMap.has(targetUrl)) {
const total = parseInt(contentLength, 10);
if (!isNaN(total) && total > 0) {
const tailBytes = Math.min(tailPrefetchKB * 1024, total);
const start = Math.max(0, total - tailBytes);
const tailRange = `bytes=${start}-`;
(async () => {
try {
if (debug) console.log('[ts-proxy] tail prefetch start', { tailRange });
const tr = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, Range: tailRange } });
if (tr.status === 206) {
const buf = Buffer.from(await tr.arrayBuffer());
const cr = tr.headers.get('content-range');
if (cr) {
const mm = cr.match(/bytes\s+(\d+)-(\d+)\/(\d+)/i);
if (mm) {
const s = parseInt(mm[1], 10); const e = parseInt(mm[2], 10); const sz = parseInt(mm[3], 10);
if (!isNaN(s) && !isNaN(e) && !isNaN(sz)) {
tailPrefetchMap.set(targetUrl, { data: buf, start: s, end: e, size: sz, ts: Date.now() });
if (debug) console.log('[ts-proxy] tail prefetch success', { start: s, end: e, bytes: buf.length });
}
}
}
} else if (debug) {
console.log('[ts-proxy] tail prefetch status', tr.status);
}
} catch (e) { if (debug) console.log('[ts-proxy] tail prefetch error', e.message); }
})();
}
}
// If no range requested but we now know size (HEAD or probe), synthesize an initial small range fetch to accelerate playback.
if (!force200 && !effectiveRange && contentLength && !noSynth && req.query.progressiveOpen === '0') {
const total = parseInt(contentLength, 10);
const desired = initChunkKB * 1024;
const chunkSize = Math.min(desired, Math.max(0, total - 1));
const syntheticRange = `bytes=0-${chunkSize}`;
const resp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers, 'Range': syntheticRange } });
if (resp.status === 206) {
if (debug) console.log('[ts-proxy] synthetic 206', syntheticRange);
const upstreamCT = inferContentType(resp.headers.get('content-type'), targetUrl);
res.status(206);
res.setHeader('Content-Type', upstreamCT);
res.setHeader('Accept-Ranges', upstreamAcceptRanges || 'bytes');
const contentRange = resp.headers.get('content-range');
if (contentRange) res.setHeader('Content-Range', contentRange);
const cl = resp.headers.get('content-length'); if (cl) res.setHeader('Content-Length', cl);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
// Pipe first chunk then leave connection open? Simpler: end after chunk; player will request next range.
return resp.body.pipe(res);
}
// If server ignored range (e.g., returned 200), fall through to normal logic below.
}
const upstreamOptions = { headers: { 'User-Agent': DEFAULT_UA, ...headers } };
// If client supplied a suffix range like bytes=0- we pass it; force200 strips Range
if (force200 && headers.Range) delete headers.Range;
const resp = await fetch(targetUrl, upstreamOptions);
if (!resp.ok && resp.status !== 206) {
if (debug) console.log('[ts-proxy] upstream error', resp.status);
return res.status(resp.status).json({ error: `TS fetch failed: ${resp.status}` });
}
// Forward partial content status when range used
if (effectiveRange && resp.status === 206 && !force200) { res.status(206); if (debug) console.log('[ts-proxy] forwarding 206', { appliedClamp }); }
if (force200 && resp.status === 206) {
// Some origins still return 206 even without Range; normalize to 200 for players expecting full stream
if (debug) console.log('[ts-proxy] normalizing 206 -> 200 due to force200');
}
// Copy key streaming headers
const upstreamCT = inferContentType(resp.headers.get('content-type'), targetUrl);
res.setHeader('Content-Type', upstreamCT);
let cl = resp.headers.get('content-length');
const crHeader = resp.headers.get('content-range');
if (!cl && crHeader) {
// Try to derive length from range span
const m = crHeader.match(/bytes\s+(\d+)-(\d+)\/(\d+|\*)/i);
if (m) {
const start = parseInt(m[1], 10); const end = parseInt(m[2], 10);
if (!isNaN(start) && !isNaN(end)) cl = (end - start + 1).toString();
}
}
if (cl) res.setHeader('Content-Length', cl);
const acceptRanges = resp.headers.get('accept-ranges');
if (acceptRanges) res.setHeader('Accept-Ranges', acceptRanges); else res.setHeader('Accept-Ranges', 'bytes');
const contentRange = resp.headers.get('content-range'); if (contentRange && !force200) res.setHeader('Content-Range', contentRange);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
if (debug) console.log('[ts-proxy] streaming body', { status: resp.status, ct: upstreamCT, cl, contentRange: force200 ? undefined : contentRange });
resp.body.pipe(res);
} catch (e) { if (debug) console.log('[ts-proxy] exception', e.message); res.status(500).json({ error: e.message }); }
});
// subtitle proxy
app.get('/sub-proxy', cors(), async (req, res) => {
const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'url parameter required' });
let headers = {}; try { headers = JSON.parse(req.query.headers || '{}'); } catch {
// Ignore URL parsing errors
}
try {
const resp = await fetch(targetUrl, { headers: { 'User-Agent': DEFAULT_UA, ...headers } });
if (!resp.ok) return res.status(resp.status).json({ error: `subtitle fetch failed: ${resp.status}` });
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Content-Type', resp.headers.get('content-type') || 'text/vtt');
resp.body.pipe(res);
} catch (e) { res.status(500).json({ error: e.message }); }
});
}
function processStreamsForProxy(streams, serverUrl) {
if (!Array.isArray(streams)) return streams;
return streams.map(s => {
if (!s || !s.url || typeof s.url !== 'string') return s;
const original = extractOriginalUrl(s.url);
const headers = s.headers || {};
const hParam = Object.keys(headers).length ? `&headers=${encodeURIComponent(JSON.stringify(headers))}` : '';
let host = '';
try {
host = new URL(original).hostname.toLowerCase();
} catch {
// Ignore errors
}
// Force specific hosts through ts-proxy (direct file style) even without extension
if (host.includes('pixeldrain.') || host === 'video-downloads.googleusercontent.com') {
return { ...s, url: `${serverUrl}/ts-proxy?url=${encodeURIComponent(original)}${hParam}` };
}
if (/\.(mp4|mkv)(\?|$)/i.test(original)) {
return { ...s, url: `${serverUrl}/ts-proxy?url=${encodeURIComponent(original)}${hParam}` };
}
return { ...s, url: `${serverUrl}/m3u8-proxy?url=${encodeURIComponent(original)}${hParam}` };
});
}
module.exports = { createProxyRoutes, processStreamsForProxy };
+11 -3
View File
@@ -29,15 +29,15 @@
</footer> </footer>
<footer class="side-footer">Credits to :<a class="hint" style="margin-top: .5rem; color: var(--primary-fg);" href="https://github.com/tapframe/NuvioStreamsAddon" target="_blank">NuvioStreamsAddon for Stremio</a></footer> <footer class="side-footer">Credits to :<a class="hint" style="margin-top: .5rem; color: var(--primary-fg);" href="https://github.com/tapframe/NuvioStreamsAddon" target="_blank">NuvioStreamsAddon for Stremio</a></footer>
<div class="bmc-widget-container"> <div class="bmc-widget-container" style="text-align: center;">
<script type="text/javascript" src="https://cdnjs.buymeacoffee.com/1.0.0/button.prod.min.js" data-name="bmc-button" data-slug="K3QSoR2" data-color="#5F7FFF" data-emoji="☕" data-font="Bree" data-text="Buy me a coffee" data-outline-color="#000000" data-font-color="#ffffff" data-coffee-color="#FFDD00" ></script> <iframe src="https://github.com/sponsors/Inside4ndroid/button" title="Sponsor Inside4ndroid" height="32" width="114" style="border:0;border-radius:6px;margin-top:.5rem;"></iframe>
</div> </div>
<footer class="side-footer">Made with ❤️ by Inside4ndroid</footer> <footer class="side-footer">Made with ❤️ by Inside4ndroid</footer>
</aside> </aside>
<main class="main"> <main class="main">
<div id="bootWarn" class="alert warning" style="display:none;">No FEBBOX_COOKIES configured. Showbox / PStream streams may be unavailable.</div> <div id="bootWarn" class="alert warning" style="display:none;">No FEBBOX_COOKIES configured. Showbox / PStream streams may be unavailable.</div>
<div id="cacheDisabledBanner" class="alert info" style="display:none;">Cache is <strong>DISABLED</strong>; expect slower responses & higher source load.</div> <div id="cacheDisabledBanner" class="alert info" style="display:none;">Cache is <strong>DISABLED</strong>; expect slower responses & higher source load.</div>
<div id="tmdbMissingBanner" class="alert warning" style="display:none;">No TMDB API Keys configured. Metadata-dependent providers (Showbox, 4khdhub, MoviesMod, MP4Hydra) may degrade.</div> <div id="tmdbMissingBanner" class="alert warning" style="display:none;">No TMDB API Keys configured. Metadata-dependent providers (Showbox, 4khdhub) may degrade.</div>
<form id="cfgForm" onsubmit="return false;" class="panels"> <form id="cfgForm" onsubmit="return false;" class="panels">
<section id="panel-status" class="panel"> <section id="panel-status" class="panel">
<h1>Server Status<button style="margin-left: 1rem;" id="refreshStatusBtn" type="button">Refresh Status</button></h1> <h1>Server Status<button style="margin-left: 1rem;" id="refreshStatusBtn" type="button">Refresh Status</button></h1>
@@ -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>
@@ -177,6 +184,7 @@
<label class="adv-flag-item"><input type="checkbox" name="adv_disableCache" value="1" /> <span>Disable Cache</span></label> <label class="adv-flag-item"><input type="checkbox" name="adv_disableCache" value="1" /> <span>Disable Cache</span></label>
<label class="adv-flag-item"><input type="checkbox" name="adv_disableUrlValidation" value="1" /> <span>Disable URL Validation</span></label> <label class="adv-flag-item"><input type="checkbox" name="adv_disableUrlValidation" value="1" /> <span>Disable URL Validation</span></label>
<label class="adv-flag-item"><input type="checkbox" name="adv_disable4khdhubValidation" value="1" /> <span>Disable 4KHDHub Validation</span></label> <label class="adv-flag-item"><input type="checkbox" name="adv_disable4khdhubValidation" value="1" /> <span>Disable 4KHDHub Validation</span></label>
<label class="adv-flag-item"><input type="checkbox" name="adv_enableProxy" value="1" /> <span>Enable Stream Proxy</span></label>
</div> </div>
<h1>Paths</h1> <h1>Paths</h1>
<p class="hint">Provider enable/disable & feature flags apply immediately. Click toggles to enable (highlighted) or disable (dim).</p> <p class="hint">Provider enable/disable & feature flags apply immediately. Click toggles to enable (highlighted) or disable (dim).</p>
+43 -26
View File
@@ -153,19 +153,14 @@ function fillForm(data){
const tmdbMissing = !((override.tmdbApiKeys && override.tmdbApiKeys.length) || (merged.tmdbApiKeys && merged.tmdbApiKeys.length) || override.tmdbApiKey || merged.tmdbApiKey); const tmdbMissing = !((override.tmdbApiKeys && override.tmdbApiKeys.length) || (merged.tmdbApiKeys && merged.tmdbApiKeys.length) || override.tmdbApiKey || merged.tmdbApiKey);
const tmdbProvidersEnabled = [ const tmdbProvidersEnabled = [
merged.enableShowboxProvider !== false, merged.enableShowboxProvider !== false,
merged.enable4khdhubProvider !== false, merged.enable4khdhubProvider !== false
merged.enableMoviesmodProvider !== false,
merged.enableMp4hydraProvider !== false
].some(Boolean); ].some(Boolean);
tmdbBanner.style.display = (tmdbMissing && tmdbProvidersEnabled)? 'block':'none'; tmdbBanner.style.display = (tmdbMissing && tmdbProvidersEnabled)? 'block':'none';
} }
const advBools = [ const advBools = [
['enableXprimeProvider','adv_enableXprime'],
['enable4khdhubProvider','adv_enable4khdhub'], ['enable4khdhubProvider','adv_enable4khdhub'],
['enableMoviesmodProvider','adv_enableMoviesmod'],
['enableMp4hydraProvider','adv_enableMp4hydra'],
['enableVidzeeProvider','adv_enableVidzee'],
['enableVixsrcProvider','adv_enableVixsrc'], ['enableVixsrcProvider','adv_enableVixsrc'],
['enableProxy','adv_enableProxy'],
['disableCache','adv_disableCache'], ['disableCache','adv_disableCache'],
['enablePStreamApi','adv_enablePStream'], ['enablePStreamApi','adv_enablePStream'],
['disableUrlValidation','adv_disableUrlValidation'], ['disableUrlValidation','adv_disableUrlValidation'],
@@ -244,12 +239,9 @@ async function save(){
payload.febboxCookies = cookiesVal? cookiesVal.split(',').filter(Boolean) : []; payload.febboxCookies = cookiesVal? cookiesVal.split(',').filter(Boolean) : [];
} }
const boolMap = { const boolMap = {
adv_enableXprime:'enableXprimeProvider',
adv_enable4khdhub:'enable4khdhubProvider', adv_enable4khdhub:'enable4khdhubProvider',
adv_enableMoviesmod:'enableMoviesmodProvider',
adv_enableMp4hydra:'enableMp4hydraProvider',
adv_enableVidzee:'enableVidzeeProvider',
adv_enableVixsrc:'enableVixsrcProvider', adv_enableVixsrc:'enableVixsrcProvider',
adv_enableProxy:'enableProxy',
adv_disableCache:'disableCache', adv_disableCache:'disableCache',
adv_enablePStream:'enablePStreamApi', adv_enablePStream:'enablePStreamApi',
adv_disableUrlValidation:'disableUrlValidation', adv_disableUrlValidation:'disableUrlValidation',
@@ -337,18 +329,16 @@ async function clearAll(){
tmdbApiKey: null, tmdbApiKey: null,
// Provider enable flags default to true // Provider enable flags default to true
enableShowboxProvider: true, enableShowboxProvider: true,
enableXprimeProvider: true,
enable4khdhubProvider: true, enable4khdhubProvider: true,
enableMoviesmodProvider: true,
enableMp4hydraProvider: true,
enableVidzeeProvider: true,
enableVixsrcProvider: true, enableVixsrcProvider: true,
enableProxy: false,
// Other defaults // Other defaults
disableCache: false, disableCache: false,
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)});
@@ -422,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 = '';
@@ -449,7 +441,8 @@ async function loadServerStatus(){
} }
} }
async function runProviderFunctionalChecks(){ async function runProviderFunctionalChecks(){
const targetTmdb = '293660'; // 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;
@@ -489,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();
@@ -544,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');
@@ -596,15 +603,25 @@ async function renderProviderMatrix(merged){
if(!merged){ try { const data = await fetchConfig(); merged = data.merged||{}; } catch { return; } } if(!merged){ try { const data = await fetchConfig(); merged = data.merged||{}; } catch { return; } }
const container = document.getElementById('providerMatrix'); if(!container) return; const container = document.getElementById('providerMatrix'); if(!container) return;
const defaults = (merged.defaultProviders||[]).map(s=>s.toLowerCase()); const defaults = (merged.defaultProviders||[]).map(s=>s.toLowerCase());
const providers = [
['showbox','enableShowboxProvider'], // Fetch available providers from API
['4khdhub','enable4khdhubProvider'], let availableProviders = [];
['moviesmod','enableMoviesmodProvider'], try {
['mp4hydra','enableMp4hydraProvider'], const providersResponse = await fetch('/api/providers');
['vidzee','enableVidzeeProvider'], if (providersResponse.ok) {
['vixsrc','enableVixsrcProvider'], const providersData = await providersResponse.json();
['xprime','enableXprimeProvider'] availableProviders = providersData.providers || [];
]; }
} catch (e) {
console.warn('Failed to fetch providers list:', e.message);
}
// Create provider list with enable flags
const providers = availableProviders.map(p => {
const flag = `enable${p.name.charAt(0).toUpperCase() + p.name.slice(1)}Provider`;
return [p.name, flag];
});
container.innerHTML = providers.map(([name,flag])=>{ container.innerHTML = providers.map(([name,flag])=>{
const enabled = merged[flag] !== false; const enabled = merged[flag] !== false;
const isDefault = defaults.includes(name); const isDefault = defaults.includes(name);
+1 -1
View File
@@ -1 +1 @@
1758105049325 1787175846578
+1 -1
View File
@@ -3,7 +3,7 @@
"users": [ "users": [
{ {
"username": "admin", "username": "admin",
"hash": "7fffd5f131a733035eaf443cd99905c0:f1cfa876f9b84fede40cd618a4352221f7ab36e4f8bead432ba5d866b06cb9fcc2903ce623d8286bb541f8d28490fc692d0bf3fadda08002265374915cdb27b5" "hash": "3c2efa431b6de0bee19847236e2c296b:d88ccc2ee950eda254a03d073bc55bbc8d0efc263c93c4e20307154dc90fadbc3141dcc9556a150b39b52088bb8ad06b120bc8832d7d8dc35918e710803d207c"
} }
] ]
} }
+66
View File
@@ -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 };
+53 -30
View File
@@ -52,6 +52,19 @@ function writeOverrideFile(obj) {
} }
} }
function getProviderNames() {
try {
const providersDir = path.join(process.cwd(), 'providers');
if (!fs.existsSync(providersDir)) return [];
return fs.readdirSync(providersDir)
.filter(file => file.endsWith('.js') && file !== 'registry.js')
.map(file => path.parse(file).name.toLowerCase());
} catch (e) {
console.warn('[config] Failed to scan providers directory:', e.message);
return [];
}
}
function normalizeConfig(base) { function normalizeConfig(base) {
const cfg = { ...base }; const cfg = { ...base };
// Marker: if user explicitly provided an empty array for tmdbApiKeys in override file we should not resurrect legacy key // Marker: if user explicitly provided an empty array for tmdbApiKeys in override file we should not resurrect legacy key
@@ -80,25 +93,29 @@ function normalizeConfig(base) {
cfg.tmdbApiKeys = Array.from(new Set(cfg.tmdbApiKeys.map(k=>String(k).trim()).filter(Boolean))); cfg.tmdbApiKeys = Array.from(new Set(cfg.tmdbApiKeys.map(k=>String(k).trim()).filter(Boolean)));
// Do not expose legacy single-key field in merged config output // Do not expose legacy single-key field in merged config output
delete cfg.tmdbApiKey; delete cfg.tmdbApiKey;
// Booleans normalization
const boolKeys = [ // Dynamic provider enable flags
'enableShowboxProvider','enableXprimeProvider','enable4khdhubProvider','enableMoviesmodProvider','enableMp4hydraProvider','enableVidzeeProvider','enableVixsrcProvider', const providerNames = getProviderNames();
'disableCache','enablePStreamApi','disableUrlValidation','disable4khdhubUrlValidation' const boolKeys = providerNames.map(name => `enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`);
]; boolKeys.push('disableCache', 'enablePStreamApi', 'disableUrlValidation', 'disable4khdhubUrlValidation', 'enableProxy');
boolKeys.forEach(k=>{ if (cfg[k] === 'true') cfg[k] = true; else if (cfg[k] === 'false') cfg[k] = false; }); boolKeys.forEach(k=>{ if (cfg[k] === 'true') cfg[k] = true; else if (cfg[k] === 'false') cfg[k] = false; });
// Default values if undefined
if (cfg.enableShowboxProvider === undefined) cfg.enableShowboxProvider = true; // Set defaults for provider enable flags
if (cfg.enableXprimeProvider === undefined) cfg.enableXprimeProvider = true; providerNames.forEach(name => {
if (cfg.enable4khdhubProvider === undefined) cfg.enable4khdhubProvider = true; const flag = `enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`;
if (cfg.enableMoviesmodProvider === undefined) cfg.enableMoviesmodProvider = true; if (cfg[flag] === undefined) cfg[flag] = true; // Default to enabled
if (cfg.enableMp4hydraProvider === undefined) cfg.enableMp4hydraProvider = true; });
if (cfg.enableVidzeeProvider === undefined) cfg.enableVidzeeProvider = true;
if (cfg.enableVixsrcProvider === undefined) cfg.enableVixsrcProvider = true; // Default values for other flags
if (cfg.disableCache === undefined) cfg.disableCache = false; if (cfg.disableCache === undefined) cfg.disableCache = false;
if (cfg.enablePStreamApi === undefined) cfg.enablePStreamApi = true; if (cfg.enablePStreamApi === undefined) cfg.enablePStreamApi = true;
if (cfg.enableProxy === undefined) cfg.enableProxy = false; // default off
// Proxy features removed; always use direct connections // Proxy features removed; always use direct connections
if (cfg.disableUrlValidation === undefined) cfg.disableUrlValidation = false; if (cfg.disableUrlValidation === undefined) cfg.disableUrlValidation = false;
if (cfg.disable4khdhubUrlValidation === undefined) cfg.disable4khdhubUrlValidation = false; if (cfg.disable4khdhubUrlValidation === undefined) cfg.disable4khdhubUrlValidation = false;
// TMDB ID used by the dashboard's "Run Provider Functional Checks" feature
if (!cfg.providerCheckTmdbId) cfg.providerCheckTmdbId = '278';
return cfg; return cfg;
} }
@@ -114,26 +131,29 @@ function applyConfigToEnv(cfg){
if (cfg.febboxCookies && cfg.febboxCookies.length) process.env.FEBBOX_COOKIES = cfg.febboxCookies.join(','); if (cfg.febboxCookies && cfg.febboxCookies.length) process.env.FEBBOX_COOKIES = cfg.febboxCookies.join(',');
else delete process.env.FEBBOX_COOKIES; else delete process.env.FEBBOX_COOKIES;
if (cfg.defaultRegion) process.env.DEFAULT_REGION = cfg.defaultRegion; else delete process.env.DEFAULT_REGION; if (cfg.defaultRegion) process.env.DEFAULT_REGION = cfg.defaultRegion; else delete process.env.DEFAULT_REGION;
// Provider enable flags
process.env.ENABLE_SHOWBOX_PROVIDER = cfg.enableShowboxProvider ? 'true':'false'; // Dynamic provider enable flags
process.env.ENABLE_XPRIME_PROVIDER = cfg.enableXprimeProvider ? 'true':'false'; const providerNames = getProviderNames();
process.env.ENABLE_4KHDHUB_PROVIDER = cfg.enable4khdhubProvider ? 'true':'false'; providerNames.forEach(name => {
process.env.ENABLE_MOVIESMOD_PROVIDER = cfg.enableMoviesmodProvider ? 'true':'false'; const flag = `enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`;
process.env.ENABLE_MP4HYDRA_PROVIDER = cfg.enableMp4hydraProvider ? 'true':'false'; const envName = `ENABLE_${name.toUpperCase()}_PROVIDER`;
process.env.ENABLE_VIDZEE_PROVIDER = cfg.enableVidzeeProvider ? 'true':'false'; process.env[envName] = cfg[flag] ? 'true' : 'false';
process.env.ENABLE_VIXSRC_PROVIDER = cfg.enableVixsrcProvider ? 'true':'false'; });
// Caching / validation / PStream // Caching / validation / PStream
process.env.DISABLE_CACHE = cfg.disableCache ? 'true':'false'; process.env.DISABLE_CACHE = cfg.disableCache ? 'true':'false';
process.env.ENABLE_PSTREAM_API = cfg.enablePStreamApi ? 'true':'false'; process.env.ENABLE_PSTREAM_API = cfg.enablePStreamApi ? 'true':'false';
process.env.DISABLE_URL_VALIDATION = cfg.disableUrlValidation ? 'true':'false'; process.env.DISABLE_URL_VALIDATION = cfg.disableUrlValidation ? 'true':'false';
process.env.DISABLE_4KHDHUB_URL_VALIDATION = cfg.disable4khdhubUrlValidation ? 'true':'false'; process.env.DISABLE_4KHDHUB_URL_VALIDATION = cfg.disable4khdhubUrlValidation ? 'true':'false';
process.env.ENABLE_PROXY = cfg.enableProxy ? 'true':'false';
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
delete process.env.SHOWBOX_USE_ROTATING_PROXY; delete process.env.SHOWBOX_USE_ROTATING_PROXY;
delete process.env.SHOWBOX_PROXY_URL_VALUE; delete process.env.SHOWBOX_PROXY_URL_VALUE;
delete process.env.SHOWBOX_PROXY_URL_ALTERNATE; delete process.env.SHOWBOX_PROXY_URL_ALTERNATE;
delete process.env.XPRIME_PROXY_URL;
delete process.env.VIDZEE_PROXY_URL; delete process.env.VIDZEE_PROXY_URL;
delete process.env.VIDSRC_PROXY_URL; delete process.env.VIDSRC_PROXY_URL;
delete process.env.MOVIESMOD_PROXY_URL; delete process.env.MOVIESMOD_PROXY_URL;
@@ -152,20 +172,23 @@ function loadConfig() {
tmdbApiKeys: parseJsonMaybe(process.env.TMDB_API_KEYS) || null, tmdbApiKeys: parseJsonMaybe(process.env.TMDB_API_KEYS) || null,
febboxCookies: parseCookies(process.env.FEBBOX_COOKIES), febboxCookies: parseCookies(process.env.FEBBOX_COOKIES),
// Extended advanced config (may not exist in env) // Extended advanced config (may not exist in env)
enableShowboxProvider: process.env.ENABLE_SHOWBOX_PROVIDER,
enableXprimeProvider: process.env.ENABLE_XPRIME_PROVIDER,
enable4khdhubProvider: process.env.ENABLE_4KHDHUB_PROVIDER,
enableMoviesmodProvider: process.env.ENABLE_MOVIESMOD_PROVIDER,
enableMp4hydraProvider: process.env.ENABLE_MP4HYDRA_PROVIDER,
enableVidzeeProvider: process.env.ENABLE_VIDZEE_PROVIDER,
enableVixsrcProvider: process.env.ENABLE_VIXSRC_PROVIDER,
disableCache: process.env.DISABLE_CACHE, disableCache: process.env.DISABLE_CACHE,
enablePStreamApi: process.env.ENABLE_PSTREAM_API, enablePStreamApi: process.env.ENABLE_PSTREAM_API,
showboxCacheDir: process.env.SHOWBOX_CACHE_DIR || null, showboxCacheDir: process.env.SHOWBOX_CACHE_DIR || null,
// 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
const providerNames = getProviderNames();
providerNames.forEach(name => {
const envName = `ENABLE_${name.toUpperCase()}_PROVIDER`;
envCfg[`enable${name.charAt(0).toUpperCase() + name.slice(1)}Provider`] = process.env[envName];
});
const override = readOverrideFile(); const override = readOverrideFile();
const merged = { ...envCfg, ...override }; const merged = { ...envCfg, ...override };
const normalized = normalizeConfig(merged); const normalized = normalizeConfig(merged);
+117
View File
@@ -0,0 +1,117 @@
class JsUnpacker {
static Unbase = class {
constructor(radix) {
this.ALPHABET_62 =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
this.ALPHABET_95 =
' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
this.alphabet = null;
this.dictionary = {};
this.radix = radix;
if (radix > 36) {
if (radix < 62) {
this.alphabet = this.ALPHABET_62.substring(0, radix);
} else if (radix > 62 && radix < 95) {
this.alphabet = this.ALPHABET_95.substring(0, radix);
} else if (radix === 62) {
this.alphabet = this.ALPHABET_62;
} else if (radix === 95) {
this.alphabet = this.ALPHABET_95;
}
for (let i = 0; i < this.alphabet.length; i++) {
this.dictionary[this.alphabet[i]] = i;
}
}
}
unbase(str) {
let ret = 0;
if (this.alphabet === null) {
ret = parseInt(str, this.radix);
} else {
const tmp = str.split('').reverse().join('');
for (let i = 0; i < tmp.length; i++) {
ret += Math.pow(this.radix, i) * this.dictionary[tmp[i]];
}
}
return ret;
}
};
constructor(packedJS) {
this.packedJS = packedJS;
}
detect() {
const js = this.packedJS.replace(/\s/g, '');
const regex = /eval\(function\(p,a,c,k,e,(?:r|d)/;
return regex.test(js);
}
unpack() {
let js = this.packedJS;
try {
const regex =
/}\s*\('(.*)',\s*(.*?),\s*(\d+),\s*'(.*?)'\.split\('\|'\)/s;
const match = js.match(regex);
if (match && match.length === 5) {
let payload = match[1].replace(/\\'/g, "'");
const radixStr = match[2];
const countStr = match[3];
const symtab = match[4].split('|');
let radix = 36;
let count = 0;
try {
radix = parseInt(radixStr);
} catch {
// Ignore URL parsing errors
}
try {
count = parseInt(countStr);
} catch {
// Ignore URL parsing errors
}
if (symtab.length !== count) {
throw new Error('Unknown p.a.c.k.e.r. encoding');
}
const unbase = new this.constructor.Unbase(radix);
const wordRegex = /\b\w+\b/g;
let decoded = payload;
let replaceOffset = 0;
let wordMatch;
while ((wordMatch = wordRegex.exec(payload)) !== null) {
const word = wordMatch[0];
const x = unbase.unbase(word);
let value = null;
if (x < symtab.length) {
value = symtab[x];
}
if (value && value.length > 0) {
const start = wordMatch.index + replaceOffset;
const end = start + word.length;
decoded =
decoded.substring(0, start) +
value +
decoded.substring(end);
replaceOffset += value.length - word.length;
}
}
return decoded;
}
} catch (e) {
return null;
}
return null;
}
}
// CommonJS exports
module.exports = JsUnpacker;
+264
View File
@@ -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
View File
@@ -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();
+72
View File
@@ -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 };
+9 -5
View File
@@ -4,16 +4,20 @@
"tmdbApiKeys": [], "tmdbApiKeys": [],
"enableShowboxProvider": true, "enableShowboxProvider": true,
"febboxCookies": [], "febboxCookies": [],
"enableXprimeProvider": true,
"enable4khdhubProvider": true, "enable4khdhubProvider": true,
"enableMoviesmodProvider": true,
"enableMp4hydraProvider": true,
"enableVidzeeProvider": true,
"enableVixsrcProvider": true, "enableVixsrcProvider": true,
"enableProxy": false,
"disableCache": false, "disableCache": false,
"enablePStreamApi": true, "enablePStreamApi": true,
"disableUrlValidation": false, "disableUrlValidation": false,
"disable4khdhubUrlValidation": false, "disable4khdhubUrlValidation": false,
"excludeCodecsRaw": "{\"excludeDV\":false,\"excludeHDR\":false}", "excludeCodecsRaw": "{\"excludeDV\":false,\"excludeHDR\":false}",
"minQualitiesRaw": "all" "minQualitiesRaw": "all",
"enableUhdmoviesProvider": false,
"enableVidlinkProvider": true,
"enableVideasyProvider": true,
"enableLordflixProvider": false,
"enableNotorrentProvider": false,
"enableDahmermoviesProvider": true,
"port": 8787
} }