From 2c9a2cabbf936ecf907866e5fbd6a8ea28b7a58a Mon Sep 17 00:00:00 2001 From: Inside4ndroid Date: Wed, 19 Aug 2026 23:35:13 +0100 Subject: [PATCH] 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. --- .env.example | 5 + .gitignore | 4 +- CHANGELOG.md | 27 +++ README.md | 164 ++++++++++----- apiServer.js | 2 +- docker-compose.yml | 2 +- package-lock.json | 189 +++++++++++------ package.json | 2 +- providers/4khdhub.js | 268 ++++++++++++++---------- providers/castletv.js | 417 ++++++++++++++++++++++++++++++++++++++ providers/hdghartv.js | 128 ++++++++++++ providers/lordflix.js | 118 ----------- providers/netmirror.js | 303 +++++++++++++++++++++++++++ providers/notorrent.js | 92 --------- providers/onetouchtv.js | 334 ++++++++++++++++++++++++++++++ providers/registry.js | 9 +- providers/streamflix.js | 133 ++++++++++++ providers/vaplayer.js | 87 ++++++++ providers/videasy.js | 208 ++++++++++++++----- providers/vidlink.js | 51 +++-- providers/zxcstreams.js | 309 ++++++++++++++++++++++++++++ public/config.html | 7 + public/index.js | 22 +- restart.trigger | 2 +- utils/cinemetaEpisodes.js | 66 ++++++ utils/config.js | 7 +- utils/titleMatch.js | 264 ++++++++++++++++++++++++ utils/tmdb.js | 7 +- utils/tmdbTitleToImdb.js | 72 +++++++ utils/user-config.json | 8 +- 30 files changed, 2791 insertions(+), 516 deletions(-) create mode 100644 .env.example create mode 100644 providers/castletv.js create mode 100644 providers/hdghartv.js delete mode 100644 providers/lordflix.js create mode 100644 providers/netmirror.js delete mode 100644 providers/notorrent.js create mode 100644 providers/onetouchtv.js create mode 100644 providers/streamflix.js create mode 100644 providers/vaplayer.js create mode 100644 providers/zxcstreams.js create mode 100644 utils/cinemetaEpisodes.js create mode 100644 utils/titleMatch.js create mode 100644 utils/tmdbTitleToImdb.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6f8e460 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index edb9dad..9265d39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /node_modules restart.trigger -.vscode/ \ No newline at end of file +.vscode/ +.env +/tmp_context \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1680020..4ec3f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ 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). +- **`utils/tmdb.js` fixed**: 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. + +### 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). + +--- + +## [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 diff --git a/README.md b/README.md index 264958f..5fab48d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - +

@@ -33,30 +33,29 @@ --- ## ✨ Features -- **Multi‑TMDB Key Rotation** – Supply multiple API keys; one is chosen randomly per request. -- **Provider Aggregation** – Pluggable providers (Showbox/FebBox, 4KHDHub, VixSrc, Videasy, Vidlink, LordFlix, NoTorrent, DahmerMovies) with per‑provider enable toggles + default selection. -- **🔥 Plugin System** – Drop new provider files in `providers/` and add its exported function to the registry map (`providers/registry.js` → `providerFunctionMap`). +- **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. +- **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). -- **Runtime Overrides UI** – Fully interactive web admin at `/` (login protected) writing to `utils/user-config.json`. -- **Session Auth + Rate Limiting** – Login system with brute‑force lockouts, logout, password change. -- **Status & Health Panel** – Live metrics, provider status, endpoint list, functional provider checks. +- **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, and password change. +- **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). -- **Back‑Navigation Safe** – Cache-control + visibility/session revalidation. -- **Extensible** – Simple drop-in provider plugin system. +- **Back-Navigation Safe** – Cache-control + visibility/session revalidation. - **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. + - `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). + - `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. + - `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). + - 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. @@ -84,6 +83,8 @@ http://localhost:8787/ 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 @@ -92,7 +93,7 @@ curl http://localhost:8787/api/health If you just want to run it (no building): ```bash docker pull inside4ndroid/tmdb-embed-api:latest -docker run --name tmdb-embed-api -p 8787:8787 \ +docker run --name tmdb-embed-api -p 8787:8787 \ -e TMDB_API_KEY=YOUR_TMDB_KEY \ inside4ndroid/tmdb-embed-api:latest ``` @@ -123,7 +124,7 @@ docker run --name tmdb-embed -p 8787:8787 \ After first login + save, the UI writes overrides into the mounted `user-config.json` so they persist across container restarts. ### 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 docker compose up -d --build ``` @@ -146,38 +147,52 @@ docker run -p 8787:8787 \ ``` or add / remove keys inside the Admin UI (Keys panel) and save. +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 | 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_KEYS` | JSON array of keys | Overrides single key when present | -| `PASSWORD_HASH` | Pre-seed admin password hash | Optional (UI can set) | -| `ADMIN_USERNAME` | Override default username | Default: `admin` | - -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. - ---- +| `DEFAULT_PROVIDERS` | Comma-separated default providers | Used by the aggregate endpoint | +| `DEFAULT_REGION` / `FEBBOX_REGION` | Default region | Passed through for legacy compat | +| `MIN_QUALITIES` | Min quality setting / JSON | Mirrored to the config model | +| `EXCLUDE_CODECS` | Codec exclusion JSON | e.g. `{"excludeDV":true}` | +| `FEBBOX_COOKIES` | FebBox JWT cookie(s) | Comma-separated; required for Showbox | +| `ENABLE__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 ```bash docker compose pull # if using an external registry (future) docker compose up -d --build +``` ### Restart from Admin UI 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. - Docker Compose: the container exits and is restarted by `restart: unless-stopped`. -``` ### Healthcheck 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. +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 | |----------|--------|---------| | `/auth/login` | POST | Authenticate (JSON: `{ username, password }`) | @@ -185,7 +200,7 @@ The root (`/`) serves the login page. After successful login a session cookie (` | `/auth/session` | GET | Check session status | | `/auth/change-password` | POST | Update password (requires session) | -Repeated failed logins trigger exponential lockouts (Retry-After header emitted). +Repeated failed logins trigger escalating lockouts (`Retry-After` header emitted; 5 attempts per window). --- @@ -201,7 +216,8 @@ Saving in the UI writes only changed keys. Setting a field to empty removes the { "defaultProviders": ["4khdhub"], "tmdbApiKeys": ["KEY_A","KEY_B"], - "enable4khdhubProvider": true + "enable4khdhubProvider": true, + "providerCheckTmdbId": "278" } ``` @@ -229,11 +245,17 @@ The API supports a plugin system. Drop a new provider file in the `providers/` f - `4khdhub` - 4KHDHub streams - `vixsrc` - VixSrc streams - `videasy` - Videasy streams (10 servers via enc-dec.app) -- `vidlink` - Vidlink streams (via enc-dec.app) -- `lordflix` - LordFlix streams (9 servers via enc-dec.app) -- `notorrent` - NoTorrent streams (Stremio addon API) +- `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) +Providers that rely on title resolution against TMDB (`castletv`, `hdghartv`, `onetouchtv`, `zxcstreams`, `vaplayer`, `netmirror`) require at least one TMDB API key configured. ### Adding a New Provider 1. **Create** `providers/yourprovider.js` with your stream fetching logic @@ -247,9 +269,14 @@ The API supports a plugin system. Drop a new provider file in the `providers/` f 'vixsrc.js': 'getVixsrcStreams', 'videasy.js': 'getVideasyStreams', 'vidlink.js': 'getVidlinkStreams', - 'lordflix.js': 'getLordflixStreams', - 'notorrent.js': 'getNotorrentStreams', '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' }; ``` @@ -260,6 +287,7 @@ The API supports a plugin system. Drop a new provider file in the `providers/` f async function getYourproviderStreams(tmdbId, mediaType, season, episode) { // Your scraping/API logic here return [{ + name: "YourProvider", title: "Fight Club - 1080p [YourProvider #1]", url: "https://stream.url/video.mp4", quality: "1080p", @@ -287,30 +315,46 @@ The system automatically: |----------|-------------| | `GET /api/health` | Basic heartbeat | | `GET /api/metrics` | Runtime counters & summary | -| `GET /api/status` | Metrics + providers + endpoints list | -| `GET /api/providers` | Enabled providers summary | +| `GET /api/status` | Metrics + providers + endpoints + `providerCheckTmdbId` | +| `GET /api/providers` | All providers with enabled status | | `GET /api/providers/:name` | Single provider status | -| `GET /api/streams/:type/:tmdbId` | Aggregate streams (`type` = movie|series) | -| `GET /api/streams/:provider/:type/:tmdbId` | Provider-specific streams | -| `GET /api/config` | `{ merged, override }` | -| `POST /api/config` | Apply override patch | +| `GET /api/streams/:type/:tmdbId` | Aggregate streams (`type` = movie\|series; supports `?season=&episode=`) | +| `GET /api/streams/:provider/:type/:tmdbId` | Provider-specific streams (same query params) | +| `GET /api/config` | `{ merged, override, overridePath }` | +| `POST /api/config` | Apply override patch (persisted to `utils/user-config.json`) | +| `POST /api/restart` | Graceful restart (writes `restart.trigger` + exits) | +| `GET /api/debug/env` | Debug environment/config snapshot | -Aggregate endpoint auto-resolves IMDb when needed and merges provider output before filtering. +Proxy routes (mounted only when `enableProxy` is on): +| Endpoint | Description | +|----------|-------------| +| `GET /m3u8-proxy?url=...&headers=...` | Rewrites & serves HLS playlists/segments | +| `GET /ts-proxy?url=...&headers=...` | Range-aware file/segment proxy | +| `GET /sub-proxy?url=...` | Subtitle passthrough proxy | + +The aggregate endpoint auto-resolves IMDb when needed, merges all enabled (or `defaultProviders`) provider output, then applies filtering. Provider timing is returned per provider (`providerTimings`). --- ## 🧪 Stream Object Schema (Unified) ```json { - "title": "Fight Club - 1080p [MP4Hydra #2]", + "name": "ProviderDisplay", + "title": "Fight Club - 1080p [YourProvider #1]", "url": "https://stream.url/video.mp4", "quality": "1080p", - "provider": "mp4hydra", - "headers": { "User-Agent": "Mozilla/5.0" } + "provider": "yourprovider", + "headers": { "User-Agent": "Mozilla/5.0" }, + "subtitles": [ { "url": "https://.../en.srt", "lang": "English" } ] } ``` +- `name` / `title` – display strings (often include quality/source hints). +- `url` – direct upstream URL (or proxied URL when `enableProxy` is on). +- `quality` – e.g. `Auto`, `1080p`, `720p`, `480p`, `4K`. Missing/unknown qualities parse as `0`. +- `headers` – optional upstream request headers (stripped when proxying). +- `subtitles` – optional embedded subtitle tracks (CastleTV, NetMirror, OneTouchTV, Showbox). -Filtering passes through `applyFilters` to enforce min quality + codec exclusions. +Filtering passes through `applyFilters` to enforce min quality + codec exclusions (see below). > Note: When the `enableProxy` flag is turned on, provider-specific request headers are stripped from each stream object before responding. Clients should use the proxied URL directly without adding custom Referer/Origin headers. @@ -319,9 +363,14 @@ Filtering passes through `applyFilters` to enforce min quality + codec exclusion ## ⚙️ Configuration Flags (Advanced Panel) | Flag | Default | Purpose | |------|---------|---------| -| disableCache | false | Disables internal caches |\n| 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 | +| `enableProvider` | 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. @@ -329,10 +378,11 @@ Toggle `enableProxy` to activate the internal proxy. This adds lightweight playl ## 🧩 Quality & Codec Filtering - 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 -{ "default": "900p", "4khdhub": "1080p" } +{ "default": "900p" } ``` +- Quality strings are normalized numerically — `Auto` ≈ 1080, `HD` ≈ 720, `SD` ≈ 480, plus `4K`/`2160`, `1440`, `1080`, `720`, `576`, `480`, `360`, `240`. - Codec exclusion JSON example: ```json { "excludeDV": true, "excludeHDR": false } @@ -340,11 +390,20 @@ Toggle `enableProxy` to activate the internal proxy. This adds lightweight playl --- +## 📊 Server Status & Functional Checks +The **Server Status** panel shows live metrics, the endpoint list, and a per-provider functional check table: + +- **Run Provider Functional Checks** – hits `/api/streams/:provider/movie/:tmdbId` for each enabled provider and reports pass/fail with stream counts. +- **Provider check TMDB ID** – the title used for the checks is configurable in the dashboard (default `278` = *The Shawshank Redemption*). The value is persisted to `utils/user-config.json` and exposed via `/api/status` as `providerCheckTmdbId`. + +--- + ## 🔐 Security Notes - Admin UI requires login; session cookie is HttpOnly. - Cache-control headers disable storing sensitive pages. -- Login is rate limited with escalating lockouts. -- Password change endpoint enforces minimum length. +- Login is rate limited with escalating lockouts (5 attempts per window). +- Password change endpoint enforces minimum length (8+). +- Default credentials (`admin` / `change-me`) are generated on first run — change them immediately. --- @@ -354,6 +413,7 @@ Toggle `enableProxy` to activate the internal proxy. This adds lightweight playl | Node Version | 18+ LTS | | Reverse Proxy | Terminate TLS (e.g., Nginx) and forward to API port | | 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 | | Scaling | Use a single instance unless providers are CPU bound | @@ -368,6 +428,8 @@ For ephemeral platforms (e.g., Vercel) note that some providers use temporary di | Provider missing in matrix | Ensure its enable flag exists & UI updated | | Empty merged config after restart | `user-config.json` deleted or unreadable | | Streams low quality | Adjust min quality preset or custom JSON | +| Showbox shows nothing | Provide `FEBBOX_COOKIES` (comma-separated) under Keys | +| Functional checks fail | Some providers 0-stream legit titles; try changing the *Provider check TMDB ID* | --- @@ -393,7 +455,7 @@ Every contribution accelerates feature delivery & sustainability. --- ## 📜 License -MIT. See `LICENSE`. +MIT. --- @@ -404,4 +466,4 @@ Inspired by community scraping/stream aggregation efforts. Credits also to the o > *Happy streaming & hacking!* ✨ ---- +--- \ No newline at end of file diff --git a/apiServer.js b/apiServer.js index 65013b4..58e4196 100644 --- a/apiServer.js +++ b/apiServer.js @@ -281,7 +281,7 @@ app.get('/api/status', (req,res) => { const cookieOk = !cookieRequired || (config.febboxCookies && config.febboxCookies.length > 0); 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), requestsTotal: metrics.requestsTotal, streamRequests: metrics.streamRequests, diff --git a/docker-compose.yml b/docker-compose.yml index a6ec0da..d03cf19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: # Alternative: supply JSON array # - TMDB_API_KEYS=["key1","key2"] - API_PORT=8787 - - BIND_HOST=0.0.0.0 + - BIND_HOST=0.0.0.0 # FebBox cookies (comma separated) if you want Showbox immediately functional - FEBBOX_COOKIES=${FEBBOX_COOKIES:-} ports: diff --git a/package-lock.json b/package-lock.json index 10969a9..f7eea0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tmdb-embed-api", - "version": "1.1.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tmdb-embed-api", - "version": "1.1.1", + "version": "1.3.0", "dependencies": { "aromanize": "^0.1.5", "axios": "^1.9.0", @@ -138,7 +138,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -161,7 +160,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -381,7 +379,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -489,13 +486,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -588,20 +585,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -611,6 +608,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -628,6 +638,26 @@ } } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -645,12 +675,16 @@ } }, "node_modules/body-parser/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/body-parser/node_modules/mime-db": { @@ -663,15 +697,19 @@ } }, "node_modules/body-parser/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/body-parser/node_modules/ms": { @@ -680,18 +718,31 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/body-parser/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/boolbase": { @@ -701,9 +752,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1381,7 +1432,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -1620,9 +1670,9 @@ } }, "node_modules/express/node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1834,16 +1884,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -2052,9 +2102,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2386,10 +2436,20 @@ "license": "ISC" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3048,12 +3108,13 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -3364,14 +3425,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -3583,7 +3644,6 @@ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -3650,11 +3710,10 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.18.1" } @@ -3819,9 +3878,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index abbf3df..a27c257 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tmdb-embed-api", - "version": "1.1.1", + "version": "1.3.0", "description": "Standalone streaming embed source aggregation API (TMDB ID in, streams out)", "main": "apiServer.js", "scripts": { diff --git a/providers/4khdhub.js b/providers/4khdhub.js index 58c2aa8..54b75a3 100644 --- a/providers/4khdhub.js +++ b/providers/4khdhub.js @@ -124,43 +124,69 @@ function validateUrl(url) { function makeRequest(url, options = {}) { return new Promise((resolve, reject) => { - const urlObj = new URL(url); - const isHttps = urlObj.protocol === 'https:'; - const httpModule = isHttps ? https : http; + const maxRedirects = options.maxRedirects || 10; + let redirectCount = 0; - const requestOptions = { - hostname: urlObj.hostname, - port: urlObj.port || (isHttps ? 443 : 80), - path: urlObj.pathname + urlObj.search, - method: options.method || 'GET', - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - ...options.headers - }, - timeout: 30000 + const doRequest = (currentUrl) => { + const urlObj = new URL(currentUrl); + const isHttps = urlObj.protocol === 'https:'; + const httpModule = isHttps ? https : http; + + const requestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || (isHttps ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: options.method || 'GET', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + ...options.headers + }, + timeout: 30000 + }; + + const req = httpModule.request(requestOptions, (res) => { + const redirectCodes = [301, 302, 303, 307, 308]; + const isRedirect = redirectCodes.includes(res.statusCode); + const location = res.headers['location']; + + if (options.allowRedirects === false && isRedirect) { + resolve({ statusCode: res.statusCode, headers: res.headers, url: currentUrl }); + return; + } + + if (isRedirect && location && redirectCount < maxRedirects) { + redirectCount++; + res.resume(); // Consume and discard the redirect body + let nextUrl; + try { + nextUrl = new URL(location, currentUrl).toString(); + } catch (error) { + resolve({ statusCode: res.statusCode, headers: res.headers, body: '', url: currentUrl }); + return; + } + console.log(`[4KHDHub] Following redirect (${redirectCount}/${maxRedirects}): ${currentUrl} -> ${nextUrl}`); + doRequest(nextUrl); + return; + } + + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + if (options.parseHTML && data) { + const $ = cheerio.load(data); + resolve({ $: $, body: data, statusCode: res.statusCode, headers: res.headers, url: currentUrl }); + } else { + resolve({ body: data, statusCode: res.statusCode, headers: res.headers, url: currentUrl }); + } + }); + }); + + req.on('error', reject); + req.on('timeout', () => reject(new Error('Request timeout'))); + req.end(); }; - const req = httpModule.request(requestOptions, (res) => { - if (options.allowRedirects === false && (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303 || res.statusCode === 307 || res.statusCode === 308)) { - resolve({ statusCode: res.statusCode, headers: res.headers }); - return; - } - - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - if (options.parseHTML && data) { - const $ = cheerio.load(data); - resolve({ $: $, body: data, statusCode: res.statusCode, headers: res.headers }); - } else { - resolve({ body: data, statusCode: res.statusCode, headers: res.headers }); - } - }); - }); - - req.on('error', reject); - req.on('timeout', () => reject(new Error('Request timeout'))); - req.end(); + doRequest(url); }); } @@ -671,8 +697,8 @@ function extractHubCloudLinks(url, referer) { const altElement = $(selector).first(); if (altElement.length > 0) { const rawHref = altElement.attr('href'); - if (rawHref) { - href = rawHref.startsWith('http') ? rawHref : `${baseUrl.replace(/\/$/, '')}/${rawHref.replace(/^\//, '')}`; + if (rawHref && /^https?:\/\//i.test(rawHref)) { + href = rawHref; console.log(`[4KHDHub] Found download link with selector ${selector}: ${href}`); found = true; break; @@ -719,6 +745,22 @@ function extractHubCloudLinks(url, referer) { const downloadButtons = $('div.card-body h2 a.btn'); 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(`Download [PixelServer]`); + } + console.log(`[4KHDHub] Applied JS-assigned pixel server URL: ${pxlUrl}`); + } + if (downloadButtons.length === 0) { // Try alternative selectors for download buttons const altSelectors = ['a.btn', '.btn', 'a[href]']; @@ -904,12 +946,12 @@ function extractHubCloudLinks(url, referer) { } else if (link.includes('pixeldra')) { 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; - 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) { 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}`); } @@ -983,74 +1025,76 @@ function extractHubCloudLinks(url, referer) { headers: {} }); }); - } else if (text.includes('10Gbps')) { - console.log(`[4KHDHub] Button ${index + 1} is 10Gbps server, following redirects...`); - // Handle 10Gbps server with multiple redirects - let currentLink = link; + } else if (text.includes('10Gbps') || /pixel\.hubcloud/i.test(link) || link.includes('dl.php')) { + console.log(`[4KHDHub] Button ${index + 1} is 10Gbps/pixel server, resolving final link...`); + // Follow redirects (pixel.hubcloud.cx -> worker -> gamerxyt.com/dl.php?link=) + // and extract the 'link' query parameter from the final effective URL + makeRequest(link, { parseHTML: false }) + .then(response => { + const finalUrl = response.url || link; + let decodedUrl = ''; - const followRedirects = () => { - return makeRequest(currentLink, { - parseHTML: false, - allowRedirects: false - }) - .then(response => { - const redirectUrl = response.headers['location']; - if (!redirectUrl) { - throw new Error('No redirect found'); - } - - console.log(`[4KHDHub] 10Gbps redirect: ${redirectUrl}`); - - if (redirectUrl.includes('id=')) { - // Final redirect, extract the link parameter - const finalLink = redirectUrl.split('link=')[1]; - if (finalLink) { - console.log(`[4KHDHub] 10Gbps final link: ${finalLink}`); - const decodedUrl = decodeURIComponent(finalLink); - // Get actual filename from HEAD request - return getFilenameFromUrl(decodedUrl) - .then(actualFilename => { - const displayFilename = actualFilename || headerDetails || 'Unknown'; - const titleParts = []; - if (displayFilename) titleParts.push(displayFilename); - if (size) titleParts.push(size); - const finalTitle = titleParts.join('\n'); - - return { - name: `4KHDHub - 10Gbps Server${qualityLabel}`, - title: finalTitle, - url: decodedUrl, - quality: quality, - provider: '4khdhub', - headers: {} - }; - }) - .catch(() => { - const displayFilename = headerDetails || 'Unknown'; - const titleParts = []; - if (displayFilename) titleParts.push(displayFilename); - if (size) titleParts.push(size); - const finalTitle = titleParts.join('\n'); - - return { - name: `4KHDHub - 10Gbps Server${qualityLabel}`, - title: finalTitle, - url: decodedUrl, - quality: quality, - provider: '4khdhub', - headers: {} - }; - }); + 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 */ } } - throw new Error('Final link not found'); - } else { - currentLink = redirectUrl; - return followRedirects(); } - }); - }; + } catch { /* ignore */ } - followRedirects() + if (!decodedUrl) { + // Old-style fallback: look for link= in the redirect URL + const oldMatch = finalUrl.match(/[?&]link=([^&]+)/); + if (oldMatch) { + decodedUrl = oldMatch[1]; + try { decodedUrl = decodeURIComponent(decodedUrl); } catch { /* keep as-is */ } + } + } + + if (!decodedUrl) { + throw new Error('Final link not found'); + } + + console.log(`[4KHDHub] 10Gbps final link: ${decodedUrl}`); + + // Get actual filename from HEAD request + return getFilenameFromUrl(decodedUrl) + .then(actualFilename => { + const displayFilename = actualFilename || headerDetails || 'Unknown'; + const titleParts = []; + if (displayFilename) titleParts.push(displayFilename); + if (size) titleParts.push(size); + const finalTitle = titleParts.join('\n'); + + return { + name: `4KHDHub - 10Gbps Server${qualityLabel}`, + title: finalTitle, + url: decodedUrl, + quality: quality, + provider: '4khdhub', + headers: {} + }; + }) + .catch(() => { + const displayFilename = headerDetails || 'Unknown'; + const titleParts = []; + if (displayFilename) titleParts.push(displayFilename); + if (size) titleParts.push(size); + const finalTitle = titleParts.join('\n'); + + return { + name: `4KHDHub - 10Gbps Server${qualityLabel}`, + title: finalTitle, + url: decodedUrl, + quality: quality, + provider: '4khdhub', + headers: {} + }; + }); + }) .then(result => { console.log(`[4KHDHub] 10Gbps processing completed`); resolve(result); @@ -1368,6 +1412,12 @@ function extractHubDriveLinks(url, referer) { 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 let filename = headerDetails || header || 'Unknown'; // Clean up the filename by removing common prefixes and file extensions @@ -1393,8 +1443,12 @@ function extractHubDriveLinks(url, referer) { for (const selector of alternatives) { foundBtn = $(selector).first(); if (foundBtn.length > 0) { - console.log(`[4KHDHub] Found download button with selector: ${selector}`); - break; + const altHref = foundBtn.attr('href'); + if (altHref && /^https?:\/\//i.test(altHref)) { + console.log(`[4KHDHub] Found download button with selector: ${selector}`); + break; + } + foundBtn = null; } } @@ -1770,7 +1824,9 @@ async function get4KHDHubStreams(tmdbId, type, season = null, episode = null) { const suspiciousPatterns = [ 'www-google-com.cdn.ampproject.org', '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)); diff --git a/providers/castletv.js b/providers/castletv.js new file mode 100644 index 0000000..c1ccfa9 --- /dev/null +++ b/providers/castletv.js @@ -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 }; \ No newline at end of file diff --git a/providers/hdghartv.js b/providers/hdghartv.js new file mode 100644 index 0000000..b62e8b0 --- /dev/null +++ b/providers/hdghartv.js @@ -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 }; \ No newline at end of file diff --git a/providers/lordflix.js b/providers/lordflix.js deleted file mode 100644 index 8012255..0000000 --- a/providers/lordflix.js +++ /dev/null @@ -1,118 +0,0 @@ -const axios = require('axios'); -const { getTmdbApiKey } = require('../utils/tmdbKey'); - -const LORDFLIX_HEADERS = { - 'Accept': '*/*', - 'Origin': 'https://lordflix.org', - 'Referer': 'https://lordflix.org/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36' -}; - -const LORDFLIX_API = 'https://snowhouse.lordflix.club'; -const MULTI_DECRYPT_API = 'https://enc-dec.app/api'; -const SERVERS = ['Berlin', 'Marseille', 'Backrooms', 'Phoenix', 'Oslo', 'Luna', 'Sakura', 'Rio', 'Ativa', 'Moscow']; - -// Matches the reference project's encodeQuote: encodeURIComponent with + encoded as %20 -function encodeQuote(str) { - return encodeURIComponent(str).replace(/%20/g, '+').replace(/\+/g, '%20'); -} - -async function getLordflixStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) { - console.log(`[Lordflix] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`); - - const tmdbKey = getTmdbApiKey(); - if (!tmdbKey) { - console.error('[Lordflix] No TMDB API key configured.'); - return []; - } - - // Step 1: TMDB lookup for title, year, imdbId - let info; - try { - const type = mediaType === 'tv' ? 'tv' : 'movie'; - const { data } = await axios.get( - `https://api.themoviedb.org/3/${type}/${tmdbId}?api_key=${tmdbKey}&append_to_response=external_ids`, - { timeout: 8000 } - ); - info = { - title: data.title || data.name || '', - year: (data.release_date || data.first_air_date || '').split('-')[0], - imdbId: (data.external_ids && data.external_ids.imdb_id) || '' - }; - } catch (err) { - console.error(`[Lordflix] TMDB lookup failed: ${err.message}`); - return []; - } - - if (!info.title || !info.imdbId) { - console.error('[Lordflix] Missing title or IMDb ID from TMDB.'); - return []; - } - - const typeParam = mediaType === 'tv' ? 'series' : 'movie'; - const titleEnc = encodeQuote(info.title); - const streams = []; - - // Step 2: Query each server in parallel - await Promise.all(SERVERS.map(async (server) => { - try { - let serverUrl = `${LORDFLIX_API}/?title=${titleEnc}&type=${typeParam}&year=${info.year || ''}` - + `&imdb=${info.imdbId}&tmdb=${tmdbId}&server=${server}`; - if (mediaType === 'tv') { - serverUrl += `&season=${seasonNum}&episode=${episodeNum}`; - } - - // Step 3: Get signed proxy URL from enc-dec.app - const encBridgeRes = await axios.get( - `${MULTI_DECRYPT_API}/enc-lordflix?url=${encodeQuote(serverUrl)}`, - { timeout: 8000 } - ); - const encBridgeJson = encBridgeRes.data; - if (!encBridgeJson || encBridgeJson.status !== 200 || !encBridgeJson.result) return; - - const { url: proxyEncUrl, sign: signature } = encBridgeJson.result; - if (!proxyEncUrl || !signature) return; - - // Step 4: Fetch encrypted stream data from proxy URL - const remoteEncRes = await axios.get(proxyEncUrl, { - headers: LORDFLIX_HEADERS, - timeout: 8000, - responseType: 'text' - }); - const remoteEncData = typeof remoteEncRes.data === 'string' - ? remoteEncRes.data - : JSON.stringify(remoteEncRes.data); - - // Step 5: Decrypt via enc-dec.app - const decRes = await axios.post(`${MULTI_DECRYPT_API}/dec-lordflix`, - { text: remoteEncData, sign: signature }, - { headers: { 'Content-Type': 'application/json' }, timeout: 8000 } - ); - const finalJson = decRes.data; - if (!finalJson || finalJson.status !== 200 || !finalJson.result || finalJson.result.error) return; - - const streamList = finalJson.result.stream; - if (!Array.isArray(streamList) || streamList.length === 0) return; - - const topStream = streamList[0]; - if (topStream.type === 'hls' && topStream.playlist) { - streams.push({ - name: `Lordflix[${server}]`, - title: `Lordflix[${server}]`, - url: topStream.playlist, - quality: 'Auto', - provider: 'Lordflix', - headers: LORDFLIX_HEADERS - }); - console.log(`[Lordflix] Server ${server}: got stream.`); - } - } catch (err) { - console.error(`[Lordflix] Server ${server} error: ${err.message}`); - } - })); - - console.log(`[Lordflix] Total streams: ${streams.length}`); - return streams; -} - -module.exports = { getLordflixStreams }; diff --git a/providers/netmirror.js b/providers/netmirror.js new file mode 100644 index 0000000..f4c5275 --- /dev/null +++ b/providers/netmirror.js @@ -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 }; \ No newline at end of file diff --git a/providers/notorrent.js b/providers/notorrent.js deleted file mode 100644 index a6356ca..0000000 --- a/providers/notorrent.js +++ /dev/null @@ -1,92 +0,0 @@ -const axios = require('axios'); -const { getTmdbApiKey } = require('../utils/tmdbKey'); - -const NOTORRENT_API = 'https://addon-osvh.onrender.com'; - -function cleanText(str) { - if (!str) return ''; - return str.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]/gu, '').trim(); -} - -function extractQuality(titleText) { - const raw = titleText || ''; - const match = raw.match(/(\d{3,4}p)/); - if (match) return match[0]; - if (raw.toUpperCase().includes('FREE')) return 'Auto'; - return 'Unknown'; -} - -async function getNotorrentStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) { - console.log(`[NoTorrent] Searching for ${mediaType} ${tmdbId}`); - - const tmdbKey = getTmdbApiKey(); - if (!tmdbKey) { - console.error('[NoTorrent] No TMDB API key configured.'); - return []; - } - - // Step 1: TMDB → IMDB ID - let imdbId; - try { - const type = mediaType === 'tv' ? 'tv' : 'movie'; - const { data } = await axios.get( - `https://api.themoviedb.org/3/${type}/${tmdbId}?api_key=${tmdbKey}&append_to_response=external_ids`, - { timeout: 8000 } - ); - imdbId = (data.external_ids && data.external_ids.imdb_id) || null; - } catch (err) { - console.error(`[NoTorrent] TMDB lookup failed: ${err.message}`); - return []; - } - - if (!imdbId) { - console.warn('[NoTorrent] Failed to map IMDB ID from TMDB.'); - return []; - } - - // Step 2: Fetch streams from NoTorrent Stremio addon API - const apiUrl = (mediaType === 'tv' && seasonNum != null) - ? `${NOTORRENT_API}/stream/series/${imdbId}:${seasonNum}:${episodeNum}.json` - : `${NOTORRENT_API}/stream/movie/${imdbId}.json`; - - try { - const { data } = await axios.get(apiUrl, { timeout: 20000 }); - const rawList = data.streams || []; - const streams = []; - - for (const item of rawList) { - if (item.externalUrl || !item.url) continue; - if (item.url.includes('github.com') || item.url.includes('googleusercontent')) continue; - - const cleanTitleStr = cleanText(item.title || ''); - const quality = extractQuality(cleanTitleStr); - - let language = 'Default'; - const langMatch = cleanTitleStr.match(/\(([^)]+)\)/); - if (langMatch) { - language = langMatch[1].charAt(0).toUpperCase() + langMatch[1].slice(1).toLowerCase(); - } - - const proxyHeaders = (item.behaviorHints?.proxyHeaders?.request) || {}; - const headers = { ...(item.behaviorHints?.headers || {}), ...proxyHeaders }; - - const nameParts = ['NoTorrent', language !== 'Default' ? language : ''].filter(p => p.trim() !== ''); - streams.push({ - name: nameParts.join(' \u2022 '), - title: quality, - url: item.url, - quality, - provider: 'NoTorrent', - headers: Object.keys(headers).length > 0 ? headers : undefined - }); - } - - console.log(`[NoTorrent] Total results found: ${streams.length}`); - return streams; - } catch (err) { - console.error(`[NoTorrent] Fetch failed: ${err.message}`); - return []; - } -} - -module.exports = { getNotorrentStreams }; diff --git a/providers/onetouchtv.js b/providers/onetouchtv.js new file mode 100644 index 0000000..29ba8fc --- /dev/null +++ b/providers/onetouchtv.js @@ -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 }; \ No newline at end of file diff --git a/providers/registry.js b/providers/registry.js index bea29c9..cb96098 100644 --- a/providers/registry.js +++ b/providers/registry.js @@ -12,9 +12,14 @@ const providerFunctionMap = { 'vixsrc.js': 'getVixsrcStreams', 'videasy.js': 'getVideasyStreams', 'vidlink.js': 'getVidlinkStreams', - 'lordflix.js': 'getLordflixStreams', - 'notorrent.js': 'getNotorrentStreams', '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 diff --git a/providers/streamflix.js b/providers/streamflix.js new file mode 100644 index 0000000..529bf2f --- /dev/null +++ b/providers/streamflix.js @@ -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 }; \ No newline at end of file diff --git a/providers/vaplayer.js b/providers/vaplayer.js new file mode 100644 index 0000000..6fcdba5 --- /dev/null +++ b/providers/vaplayer.js @@ -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 }; \ No newline at end of file diff --git a/providers/videasy.js b/providers/videasy.js index c36a26f..08a3ef4 100644 --- a/providers/videasy.js +++ b/providers/videasy.js @@ -8,21 +8,91 @@ const VIDEASY_HEADERS = { 'Referer': 'https://player.videasy.net/' }; -// Each server maps a name to its API URL. moviesOnly:true skips TV requests. +const VIDEASY_API = 'https://api.speedracelight.com'; + +// Each server maps a name to its API route. moviesOnly:true skips TV requests. const SERVERS = { - 'Neon': { url: 'https://api.videasy.net/myflixerzupcloud/sources-with-title' }, - 'Yoru': { url: 'https://api.videasy.net/cdn/sources-with-title', moviesOnly: true }, - 'Cypher': { url: 'https://api.videasy.net/moviebox/sources-with-title' }, - 'Reyna': { url: 'https://api.videasy.net/primewire/sources-with-title' }, - 'Omen': { url: 'https://api.videasy.net/onionplay/sources-with-title' }, - 'Breach': { url: 'https://api.videasy.net/m4uhd/sources-with-title' }, - 'Ghost': { url: 'https://api.videasy.net/primesrcme/sources-with-title' }, - 'Sage': { url: 'https://api.videasy.net/1movies/sources-with-title' }, - 'Vyse': { url: 'https://api.videasy.net/hdmovie/sources-with-title' }, - 'Raze': { url: 'https://api.videasy.net/superflix/sources-with-title' } + '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 } }; -const DECRYPT_URL = 'https://enc-dec.app/api/dec-videasy'; +// --- Seed-based response decryption (mirrors player.videasy.net) --- +const MAGIC = [109, 118, 109, 49]; // "mvm1" +const HASH_TABLE = [1116352408, 1899447441, 3049323471, 3921009573, 961987163, 1508970993, 2453635748, 2870763221, 3624381080, 310598401, 607225278, 1426881987, 1925078388, 2162078206, 2614888103, 3248222580]; + +function u32x(x) { return x >>> 0; } +function mul32(a, b) { return Math.imul(a, b) >>> 0; } +function rotl32(x, n) { + x >>>= 0; + n &= 31; + return n === 0 ? x : ((x << n) | (x >>> (32 - n))) >>> 0; +} +function hash32(x) { + x = u32x(x); + x ^= x >>> 16; + x = mul32(x, 2246822507); + x ^= x >>> 13; + x = mul32(x, 3266489909); + x ^= x >>> 16; + return u32x(x); +} +function fnv1a(str) { + let h = 2166136261; + for (let i = 0; i < str.length; i++) h = mul32(h ^ str.charCodeAt(i), 16777619); + return hash32(h); +} +function initStream(seed, secondKey) { + const S = new Array(61); + let a = u32x(hash32(fnv1a(seed) ^ hash32(u32x((secondKey >>> 0) ^ 2654435769)))); + for (let i = 0; i < 8; i++) { + if ((i * (i + 1) & 1) === 0) { + const idx = a % 61; + a = rotl32(a + u32x(2654435769), 7 + (7 & i)); + S[idx] = u32x(a ^ hash32(a)); + a = hash32(u32x(a + idx)); + } else { + S[i] = HASH_TABLE[15 & i]; + } + } + return { S, acc: u32x(hash32(2779096485 ^ a)) }; +} +function nextByte(st, ctr) { + const r = st.S; + const o = st.acc; + const n = o % 61; + const inSet = 0 - Number(n in r); + const d = r[n] >>> 0; + const x = u32x(d ^ mul32(2654435769, ctr + 1)); + const y = u32x((o ^ x) | (o & x & inSet)); + const no = hash32(u32x(rotl32(u32x(y + o), 31 & n) ^ rotl32(o, 31 & Math.imul(n, 7))) + 2654435769); + r[n] = no >>> 0; + st.acc = no; + return no >>> 0; +} +function keystream(seed, secondKey, len) { + const st = initStream(seed, secondKey); + const out = new Uint8Array(len); + let ctr = 0; + for (let i = 0; i < len;) { + const b = nextByte(st, ctr++); + out[i++] = 255 & b; + if (i < len) out[i++] = (b >>> 8) & 255; + if (i < len) out[i++] = (b >>> 16) & 255; + if (i < len) out[i++] = (b >>> 24) & 255; + } + return out; +} +function decryptPayload(payload, seed, secondKey) { + const b64 = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(4 * Math.ceil(payload.length / 4), '='); + const data = Buffer.from(b64, 'base64'); + const ks = keystream(seed, secondKey, data.length); + for (let i = 0; i < data.length; i++) data[i] ^= ks[i]; + for (let i = 0; i < MAGIC.length; i++) { + if (data[i] !== MAGIC[i]) throw new Error('Invalid encrypted payload'); + } + return data.subarray(MAGIC.length).toString('utf8'); +} async function getVideasyStreams(tmdbId, mediaType = 'movie', seasonNum = null, episodeNum = null) { console.log(`[Videasy] Fetching streams for TMDB ID: ${tmdbId}, Type: ${mediaType}`); @@ -60,57 +130,87 @@ async function getVideasyStreams(tmdbId, mediaType = 'movie', seasonNum = null, const allStreams = []; const seen = new Set(); - // Step 2: Query each server in parallel - await Promise.all(Object.entries(SERVERS).map(async ([name, server]) => { - if (server.moviesOnly && mediaType === 'tv') return; + // 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 []; + } - let apiUrl = `${server.url}?title=${encodeURIComponent(details.title)}` - + `&mediaType=${details.type}&year=${details.year}` - + `&tmdbId=${tmdbId}&imdbId=${details.imdbId || ''}`; - if (mediaType === 'tv') apiUrl += `&seasonId=${seasonNum}&episodeId=${episodeNum}`; + const queryServers = async (activeSeed) => { + await Promise.all(Object.entries(SERVERS).map(async ([name, server]) => { + if (server.moviesOnly && mediaType === 'tv') return; - try { - const encRes = await axios.get(apiUrl, { - headers: VIDEASY_HEADERS, - timeout: 8000, - responseType: 'text' - }); + 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}`; - const encryptedText = typeof encRes.data === 'string' ? encRes.data : JSON.stringify(encRes.data); - if (!encryptedText || encryptedText.length < 20 || encryptedText.startsWith('<')) return; - - // Step 3: Decrypt via enc-dec.app - const decRes = await axios.post(DECRYPT_URL, - { text: encryptedText, id: String(tmdbId) }, - { headers: { 'Content-Type': 'application/json' }, timeout: 8000 } - ); - - const resData = (decRes.data && decRes.data.result) || decRes.data; - if (!resData || !Array.isArray(resData.sources)) return; - - for (const s of resData.sources) { - if (!s.url || seen.has(s.url)) continue; - seen.add(s.url); - allStreams.push({ - name: `Videasy ${name}`, - title: `Videasy ${name} - ${s.quality || 'Auto'}`, - url: s.url, - quality: s.quality || 'Auto', - provider: 'Videasy', - headers: { - 'Referer': 'https://player.videasy.net/', - 'Origin': 'https://player.videasy.net' - } + 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 } - console.log(`[Videasy] Server ${name}: ${resData.sources.length} source(s)`); + })); + }; + + // Step 4: Query each server in parallel; retry once with a fresh seed if nothing matched + await queryServers(seed); + if (allStreams.length === 0) { + try { + const seedRes = await axios.get(`${VIDEASY_API}/seed?mediaId=${tmdbId}`, { + headers: VIDEASY_HEADERS, + timeout: 8000 + }); + const freshSeed = seedRes.data && seedRes.data.seed; + if (freshSeed && freshSeed !== seed) await queryServers(freshSeed); } catch { - // server unreachable or returned no data — skip silently + // ignore retry failure } - })); + } console.log(`[Videasy] Total streams: ${allStreams.length}`); return allStreams; } -module.exports = { getVideasyStreams }; +module.exports = { getVideasyStreams }; \ No newline at end of file diff --git a/providers/vidlink.js b/providers/vidlink.js index 352bfe8..94339cc 100644 --- a/providers/vidlink.js +++ b/providers/vidlink.js @@ -5,6 +5,19 @@ const VIDLINK_HEADERS = { '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}`); @@ -20,32 +33,42 @@ async function getVidlinkStreams(tmdbId, mediaType = 'movie', seasonNum = null, return []; } - // Step 2: Fetch stream playlist from Vidlink API + // 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 playlist = apiRes.data && apiRes.data.stream && apiRes.data.stream.playlist; - if (!playlist) { - console.log('[Vidlink] No playlist URL in response.'); + const streamData = apiRes.data && apiRes.data.stream; + if (!streamData || !streamData.qualities) { + console.log('[Vidlink] No quality streams in response.'); return []; } - console.log(`[Vidlink] Got stream.`); - return [{ - name: 'Vidlink', - title: 'Vidlink', - url: playlist, - quality: 'Auto', - provider: 'Vidlink', - headers: { 'Referer': 'https://vidlink.pro' } - }]; + 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 }; +module.exports = { getVidlinkStreams }; \ No newline at end of file diff --git a/providers/zxcstreams.js b/providers/zxcstreams.js new file mode 100644 index 0000000..7a8e76d --- /dev/null +++ b/providers/zxcstreams.js @@ -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 }; \ No newline at end of file diff --git a/public/config.html b/public/config.html index 80d8dac..6f54d16 100644 --- a/public/config.html +++ b/public/config.html @@ -48,6 +48,13 @@

Providers

+
+ +
+ + +
+
diff --git a/public/index.js b/public/index.js index fee3c2e..d42760c 100644 --- a/public/index.js +++ b/public/index.js @@ -337,7 +337,8 @@ async function clearAll(){ enablePStreamApi: true, disableUrlValidation: false, disable4khdhubUrlValidation: false, - showboxCacheDir: null + showboxCacheDir: null, + providerCheckTmdbId: null }; try { const r = await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); @@ -411,6 +412,8 @@ async function loadServerStatus(){ const j = await r.json(); if(!j.success) throw new Error('Status error'); 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(provEnhanced){ provEnhanced.innerHTML = ''; @@ -438,7 +441,8 @@ async function loadServerStatus(){ } } async function runProviderFunctionalChecks(){ - const targetTmdb = '550'; // TMDB ID provided + const checkTmdbEl = document.getElementById('providerCheckTmdbId'); + const targetTmdb = (checkTmdbEl && checkTmdbEl.value.trim()) || '278'; // TMDB ID (editable on status page) const provEnhanced = document.getElementById('statusProvidersEnhanced'); const summary = document.getElementById('providerCheckSummary'); if(!provEnhanced) return; @@ -478,6 +482,18 @@ async function runProviderFunctionalChecks(){ } 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', ()=> { initTheme(); attachCoreListeners(); @@ -533,6 +549,8 @@ document.addEventListener('DOMContentLoaded', ()=> { if(rsb) rsb.addEventListener('click', loadServerStatus); const rpc = document.getElementById('runProviderChecksBtn'); if(rpc) rpc.addEventListener('click', runProviderFunctionalChecks); + const spc = document.getElementById('saveProviderCheckTmdbBtn'); + if(spc) spc.addEventListener('click', saveProviderCheckTmdbId); }); function toggleSidebar(force){ const sb = document.getElementById('sidebar'); diff --git a/restart.trigger b/restart.trigger index 964ce21..8250c93 100644 --- a/restart.trigger +++ b/restart.trigger @@ -1 +1 @@ -1778930133625 \ No newline at end of file +1787175846578 \ No newline at end of file diff --git a/utils/cinemetaEpisodes.js b/utils/cinemetaEpisodes.js new file mode 100644 index 0000000..51d8960 --- /dev/null +++ b/utils/cinemetaEpisodes.js @@ -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 }; \ No newline at end of file diff --git a/utils/config.js b/utils/config.js index e2052e9..60ee321 100644 --- a/utils/config.js +++ b/utils/config.js @@ -114,6 +114,8 @@ function normalizeConfig(base) { // Proxy features removed; always use direct connections if (cfg.disableUrlValidation === undefined) cfg.disableUrlValidation = 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; } @@ -144,6 +146,8 @@ function applyConfigToEnv(cfg){ process.env.DISABLE_URL_VALIDATION = cfg.disableUrlValidation ? '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 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 @@ -174,7 +178,8 @@ function loadConfig() { // Proxy/env paths removed; always direct connections disableUrlValidation: process.env.DISABLE_URL_VALIDATION, disable4khdhubUrlValidation: process.env.DISABLE_4KHDHUB_URL_VALIDATION, - enableProxy: process.env.ENABLE_PROXY + enableProxy: process.env.ENABLE_PROXY, + providerCheckTmdbId: process.env.PROVIDER_CHECK_TMDB_ID || null }; // Dynamic provider enable flags from env diff --git a/utils/titleMatch.js b/utils/titleMatch.js new file mode 100644 index 0000000..16887d7 --- /dev/null +++ b/utils/titleMatch.js @@ -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 }; \ No newline at end of file diff --git a/utils/tmdb.js b/utils/tmdb.js index db1f784..8b47cb4 100644 --- a/utils/tmdb.js +++ b/utils/tmdb.js @@ -1,5 +1,5 @@ const fetch = (...args) => import('node-fetch').then(m => m.default(...args)); -const { config } = require('./config'); +const { getTmdbApiKey } = require('./tmdbKey'); const cache = { 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; } 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 full = `${url}${sep}api_key=${config.tmdbApiKey}`; + const full = `${url}${sep}api_key=${apiKey}`; const res = await fetch(full, { timeout: 15000 }); if (!res.ok) throw new Error(`TMDB request failed ${res.status}`); return res.json(); diff --git a/utils/tmdbTitleToImdb.js b/utils/tmdbTitleToImdb.js new file mode 100644 index 0000000..01366ef --- /dev/null +++ b/utils/tmdbTitleToImdb.js @@ -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 }; \ No newline at end of file diff --git a/utils/user-config.json b/utils/user-config.json index 81fdd12..d62967c 100644 --- a/utils/user-config.json +++ b/utils/user-config.json @@ -16,6 +16,8 @@ "enableUhdmoviesProvider": false, "enableVidlinkProvider": true, "enableVideasyProvider": true, - "enableLordflixProvider": true, - "enableNotorrentProvider": true -} + "enableLordflixProvider": false, + "enableNotorrentProvider": false, + "enableDahmermoviesProvider": true, + "port": 8787 +} \ No newline at end of file