nexstream-deploy commited on
Commit
2276d59
·
1 Parent(s): cbe4c34

deploy: nexstream backend (experiment)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +20 -0
  2. .gitattributes +0 -35
  3. Dockerfile +48 -0
  4. README.md +28 -5
  5. backend/.dockerignore +20 -0
  6. backend/.env.example +58 -0
  7. backend/.gitignore +4 -0
  8. backend/Dockerfile +45 -0
  9. backend/README.md +100 -0
  10. backend/deno-wrapper +2 -0
  11. backend/eslint.config.js +114 -0
  12. backend/package-lock.json +0 -0
  13. backend/package.json +113 -0
  14. backend/scripts/bench-convert.js +219 -0
  15. backend/scripts/ci-test.sh +129 -0
  16. backend/scripts/termux-shim.js +35 -0
  17. backend/scripts/test-sequential.sh +58 -0
  18. backend/src/app.ts +548 -0
  19. backend/src/controllers/keychanger.controller.ts +346 -0
  20. backend/src/controllers/remix.controller.ts +703 -0
  21. backend/src/controllers/video.controller.ts +401 -0
  22. backend/src/instrument.ts +37 -0
  23. backend/src/routes/keychanger.routes.ts +17 -0
  24. backend/src/routes/remix.routes.ts +29 -0
  25. backend/src/routes/video.routes.ts +26 -0
  26. backend/src/services/extract.service.ts +390 -0
  27. backend/src/services/extractors/bluesky.ts +170 -0
  28. backend/src/services/extractors/facebook/constants.ts +80 -0
  29. backend/src/services/extractors/facebook/fetcher.ts +46 -0
  30. backend/src/services/extractors/facebook/index.ts +86 -0
  31. backend/src/services/extractors/facebook/json-extractor.ts +90 -0
  32. backend/src/services/extractors/facebook/normalizer.ts +71 -0
  33. backend/src/services/extractors/facebook/parser.ts +158 -0
  34. backend/src/services/extractors/facebook/types.ts +19 -0
  35. backend/src/services/extractors/facebook/utils.ts +32 -0
  36. backend/src/services/extractors/index.ts +320 -0
  37. backend/src/services/extractors/instagram/fetcher.ts +61 -0
  38. backend/src/services/extractors/instagram/index.ts +75 -0
  39. backend/src/services/extractors/instagram/normalizer.ts +50 -0
  40. backend/src/services/extractors/instagram/parser.ts +70 -0
  41. backend/src/services/extractors/soundcloud.ts +187 -0
  42. backend/src/services/extractors/spotify.ts +147 -0
  43. backend/src/services/extractors/threads/constants.ts +20 -0
  44. backend/src/services/extractors/threads/fetcher.ts +62 -0
  45. backend/src/services/extractors/threads/index.ts +75 -0
  46. backend/src/services/extractors/threads/json-extractor.ts +171 -0
  47. backend/src/services/extractors/threads/normalizer.ts +74 -0
  48. backend/src/services/extractors/threads/parser.ts +68 -0
  49. backend/src/services/extractors/threads/types.ts +21 -0
  50. backend/src/services/extractors/tiktok.ts +239 -0
.dockerignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build context lean; never ship host modules or secrets
2
+ **/node_modules
3
+ **/dist
4
+ **/.env
5
+ **/.env.*
6
+ **/*.log
7
+ **/*.rdb
8
+ backend/temp
9
+ backend/*cookies*.txt
10
+
11
+ # unused by the backend image
12
+ .git
13
+ frontend
14
+ mobile
15
+ engine
16
+ temp_refs
17
+ temp_refs2
18
+ BTC-ISMIR19
19
+ bin
20
+ *.md
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces (Docker SDK) build.
2
+ # Place this file as ./Dockerfile at the Space repo root,
3
+ # alongside shared/ and backend/ (build context = repo root).
4
+ FROM node:22-slim
5
+
6
+ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
7
+ # hadolint ignore=DL3008
8
+ RUN set -ex; \
9
+ export DEBIAN_FRONTEND=noninteractive; \
10
+ apt-get update; \
11
+ apt-get install -y --no-install-recommends \
12
+ python3 python3-pip python3-full ffmpeg curl unzip; \
13
+ rm -rf /var/lib/apt/lists/*
14
+
15
+ # yt-dlp toolchain in a world-readable venv
16
+ RUN python3 -m venv /opt/tool-venv \
17
+ && /opt/tool-venv/bin/pip install --no-cache-dir --upgrade pip \
18
+ && /opt/tool-venv/bin/pip install --no-cache-dir --upgrade --pre \
19
+ yt-dlp kaggle yt-dlp-getpot-wpc
20
+
21
+ # hf runs containers as uid 1000
22
+ RUN useradd -m -u 1000 user
23
+ USER user
24
+ ENV HOME=/home/user \
25
+ PATH=/home/user/.deno/bin:/opt/tool-venv/bin:/home/user/.local/bin:$PATH
26
+ WORKDIR $HOME/app
27
+
28
+ RUN curl -fsSL https://deno.land/install.sh | sh
29
+
30
+ # shared has its own deps (zod) the schemas need
31
+ COPY --chown=user shared/ ./shared/
32
+ RUN npm install --prefix shared
33
+
34
+ COPY --chown=user backend/package*.json ./backend/
35
+ WORKDIR $HOME/app/backend
36
+ RUN npm install
37
+
38
+ COPY --chown=user backend/ ./
39
+ RUN npx tsc
40
+
41
+ RUN mkdir -p temp
42
+
43
+ ENV PORT=7860 \
44
+ API_ONLY=true \
45
+ NODE_ENV=production
46
+ EXPOSE 7860
47
+
48
+ CMD ["node", "--import", "./dist/backend/src/instrument.js", "dist/backend/src/app.js"]
README.md CHANGED
@@ -1,10 +1,33 @@
1
  ---
2
- title: Nexstream Api
3
- emoji: 📉
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: NexStream Backend
3
+ emoji: 🎬
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ short_description: NexStream media backend (experimental)
10
  ---
11
 
12
+ # NexStream Backend Hugging Face Space
13
+
14
+ Experimental Docker deployment of the NexStream Express backend.
15
+
16
+ This Space serves the API only (`API_ONLY=true`). The container listens on
17
+ `7860` (matching `app_port`).
18
+
19
+ ## Required Space secrets
20
+
21
+ Set these in **Settings → Variables and secrets** (they are injected as env
22
+ at runtime):
23
+
24
+ - `PROXY_SIGNING_SECRET` — pin it so signed `/proxy` links survive restarts
25
+ - `COOKIES_URL` — remote cookies source (storage here is ephemeral)
26
+ - `ALLOWED_ORIGINS` — your frontend origin(s), comma-separated
27
+ - Redis connection vars (e.g. `REDIS_URL`)
28
+ - Turso vars (`TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`)
29
+ - `GEMINI_API_KEY`, `GROQ_API_KEY`
30
+ - `YT_PROXY` — optional; residential proxy for YouTube (experiment branch)
31
+
32
+ `NODE_ENV` is already set to `production` in the Dockerfile (must not be
33
+ `test`, or the server won't start).
backend/.dockerignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.local
3
+ .env.*.local
4
+
5
+ # Ignore build artifacts and nodes
6
+ node_modules
7
+ dist
8
+ node_modules
9
+ temp
10
+ temp_cookies.txt
11
+
12
+ # Ignore logs and large files
13
+ *.log
14
+ *.mp4
15
+ *.webm
16
+ *.mp3
17
+
18
+ # Ignore git
19
+ .git
20
+ .gitignore
backend/.env.example ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # full reference: docs/env-variables.md.
2
+ # none of these are strictly required — each enables a feature.
3
+
4
+ # Spotify resolution
5
+ # as of now, requires premium acc. but dont worry; will works without it
6
+ SPOTIFY_CLIENT_ID=
7
+ SPOTIFY_CLIENT_SECRET=
8
+
9
+ # AI fallback for query synthesis — set at least one.
10
+ GEMINI_API_KEY=
11
+ GROQ_API_KEY=
12
+
13
+ # data layer
14
+ REDIS_URL=redis://127.0.0.1:6379
15
+ TURSO_URL=
16
+ TURSO_AUTH_TOKEN=
17
+
18
+ # ISRC fallback
19
+ SOUNDCHARTS_APP_ID=
20
+ SOUNDCHARTS_API_KEY=
21
+
22
+ # yt-dlp cookies — improves YouTube reliability
23
+ COOKIES_URL=
24
+
25
+ # observability
26
+ SENTRY_DSN=
27
+
28
+
29
+ # ─── advanced / situational — most users won't need these ───
30
+
31
+ # core overrides
32
+ # PORT=5000
33
+ # LOG_LEVEL=info
34
+ # max simultaneous downloads/muxes; defaults to cpu count
35
+ # MAX_CONCURRENT_MEDIA=
36
+ # youtube client refresh interval (ms); defaults to 4h
37
+ # YOUTUBE_CLIENT_TTL_MS=
38
+ API_ONLY=false
39
+
40
+ # alternative gateway to GEMINI
41
+ VERTEX_API_KEY=
42
+
43
+ # yt-dlp tweaks
44
+ YTDLP_COOKIES_FILE=
45
+ ENABLE_POT_PLUGIN=0
46
+
47
+ # Remix Lab > Kaggle sync
48
+ KAGGLE_USERNAME=
49
+ KAGGLE_KEY=
50
+
51
+ # public-instance hardening (see docs/protect-an-instance.md)
52
+ API_KEY=
53
+ # comma-separated cors allowlist; empty = reflect origin without credentials
54
+ ALLOWED_ORIGINS=
55
+ PROXY_SIGNING_SECRET=
56
+ PROXY_URL_TTL_SECONDS=
57
+ # set true ONLY if a cdn's tls breaks the proxy (disables cert checks for cdns)
58
+ PROXY_ALLOW_INSECURE_TLS=
backend/.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ \n# Ignore sensitive cookie files\nfacebook_cookies.txt\nyoutube_cookies.txt\n*.txt
2
+ .npmrc
3
+ test-results.xml
4
+ coverage/
backend/Dockerfile ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:22-slim
2
+
3
+ WORKDIR /app
4
+
5
+ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
6
+ # hadolint ignore=DL3008
7
+ RUN set -ex; \
8
+ export DEBIAN_FRONTEND=noninteractive; \
9
+ apt-get update; \
10
+ apt-get install -y --no-install-recommends \
11
+ python3 \
12
+ python3-pip \
13
+ python3-full \
14
+ ffmpeg \
15
+ curl \
16
+ unzip; \
17
+ curl -fsSL https://deno.land/install.sh | sh; \
18
+ rm -rf /var/lib/apt/lists/*
19
+
20
+ ENV DENO_INSTALL="/root/.deno"
21
+ ENV PATH="$DENO_INSTALL/bin:$PATH"
22
+
23
+ RUN python3 -m venv /opt/tool-venv \
24
+ && /opt/tool-venv/bin/pip install --upgrade pip \
25
+ && /opt/tool-venv/bin/pip install --upgrade --pre yt-dlp kaggle yt-dlp-getpot-wpc
26
+ ENV PATH="/opt/tool-venv/bin:$PATH"
27
+
28
+ # shared has its own deps (zod) the schemas need
29
+ COPY shared/ ./shared/
30
+ RUN npm install --prefix shared
31
+
32
+ COPY backend/package*.json ./backend/
33
+ WORKDIR /app/backend
34
+ RUN npm install
35
+
36
+ COPY backend/ ./
37
+ RUN npx tsc
38
+
39
+ RUN mkdir -p temp && chmod 777 temp
40
+
41
+ ENV PORT=8000
42
+ ENV API_ONLY=true
43
+ EXPOSE 8000
44
+
45
+ CMD ["node", "--import", "./dist/backend/src/instrument.js", "dist/backend/src/app.js"]
backend/README.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend
2
+
3
+ the Express 5 + TypeScript service that powers stream resolution, muxing, the Spotify resolution race, and the Remix Lab API. for the project overview see [`../README.md`](../README.md). for self-hosting see [`../docs/run-an-instance.md`](../docs/run-an-instance.md).
4
+
5
+ ## Layout
6
+
7
+ ```text
8
+ backend/
9
+ ├── src/
10
+ │ ├── app.ts # Express setup, middleware, route wiring, lifecycle
11
+ │ ├── instrument.ts # Sentry instrumentation — must load before app.ts
12
+ │ ├── controllers/
13
+ │ │ ├── video.controller.ts # /info, /convert, /stream-urls handlers
14
+ │ │ └── keychanger.controller.ts # pitch-shift / key-change uploads
15
+ │ ├── routes/
16
+ │ │ ├── video.routes.ts # core video / stream endpoints
17
+ │ │ ├── remix.routes.ts # Remix Lab kernel proxy + results
18
+ │ │ └── keychanger.routes.ts # key-changer route
19
+ │ ├── services/
20
+ │ │ ├── spotify/ # ISRC race, Turso registry, AI query synthesis
21
+ │ │ ├── ytdlp/ # yt-dlp integration: streamer, info, turbo-mux, config
22
+ │ │ ├── extractors/ # pure-JS extractors (YouTube/FB/IG/TikTok/SoundCloud)
23
+ │ │ ├── extract.service.ts # generic metadata extractor
24
+ │ │ ├── seeder.service.ts # background catalog seeder
25
+ │ │ ├── social.service.ts # metascraper-based social-link metadata
26
+ │ │ ├── ug-grounding.service.ts # Ultimate Guitar tab lookup
27
+ │ │ ├── spotify.service.ts # Spotify facade
28
+ │ │ └── ytdlp.service.ts # yt-dlp facade
29
+ │ ├── utils/
30
+ │ │ ├── api/ # controller, response helpers
31
+ │ │ ├── infra/ # db (Turso), logger, queue, redis, trace
32
+ │ │ ├── media/ # format, fsm, metadata, spotify, stream, video
33
+ │ │ └── network/ # auth, cipher, cookie, proxy, secrets, security, sse, validation
34
+ │ └── types/ # shared TS types
35
+ ├── tests/ # Vitest suites + e2e helpers
36
+ ├── scripts/ # test orchestration, benchmarks, Termux shim
37
+ └── Dockerfile # container build (node:22-slim base)
38
+ ```
39
+
40
+ ## Routes
41
+
42
+ `video.routes.ts`:
43
+
44
+ | Method | Path | Purpose |
45
+ |---|---|---|
46
+ | `GET` | `/events` | SSE telemetry stream. |
47
+ | `GET` | `/info` | resolve a URL → metadata + format list. |
48
+ | `GET` | `/stream-urls` | refresh CDN URLs for a known video. |
49
+ | `POST` | `/telemetry` | frontend → backend telemetry ingest. |
50
+ | `ALL` | `/convert` | stream / download a chosen format. |
51
+ | `GET` | `/proxy` | authenticated stream proxy. |
52
+ | `GET` | `/seed-intelligence` | trigger the background catalog seeder. |
53
+
54
+ `remix.routes.ts` proxies the Python Remix Lab kernel and serves stem/chord/beat results. `keychanger.routes.ts` handles pitch-shift uploads. `/convert` and `/proxy` are gated by `concurrencyGuard(2)` (`utils/network/security.util.ts`) to keep memory bounded on Termux and free-tier hosts.
55
+
56
+ response shapes for `/info` and `/convert` are documented in [`../docs/api.md`](../docs/api.md).
57
+
58
+ ## Environment
59
+
60
+ configure via `backend/.env`. the full reference is in [`../docs/env-variables.md`](../docs/env-variables.md). the backend boots without most of these — they enable optional features and degrade gracefully when unset. minimum to get something useful:
61
+
62
+ - `REDIS_URL` — Redis for the metadata cache and BullMQ job queue (defaults to `redis://127.0.0.1:6379`).
63
+ - `GEMINI_API_KEY` and/or `GROQ_API_KEY` — at least one for the AI fallback in Spotify resolution.
64
+ - `SPOTIFY_CLIENT_ID` + `SPOTIFY_CLIENT_SECRET` — Spotify Web API for ISRC and metadata.
65
+ - `TURSO_URL` + `TURSO_AUTH_TOKEN` — global edge registry. unset is fine for local dev (falls back to an in-memory mock).
66
+ - `COOKIES_URL` — remote `yt-dlp` cookie sync. improves YouTube reliability.
67
+ - `API_ONLY=true` — disable serving the bundled `frontend/dist` (split deployments).
68
+
69
+ ## Running
70
+
71
+ ```bash
72
+ npm install
73
+ npm run dev # tsc + node, listens on :5000 (or $PORT)
74
+ ```
75
+
76
+ other scripts:
77
+
78
+ - `npm run build` — TypeScript compile to `dist/`.
79
+ - `npm test` — full Vitest suite (sequential, Termux-friendly).
80
+ - `npm run test:fast` — core regression tests only.
81
+ - `npm run test:lite` — node `--test` lite suite (no transpile).
82
+ - `npm run lint` — ESLint on changed files (`npm run lint:all` for the whole package).
83
+ - `npm run bench:convert` — convert-pipeline benchmark.
84
+
85
+ ## Requirements
86
+
87
+ - Node.js ≥ 22 — matches the Dockerfile and the project root.
88
+ - `yt-dlp` and `ffmpeg` on `PATH`. `yt-dlp` is the fallback for sources without a native extractor; `ffmpeg` 7.x or 8.x handles muxing and audio transcoding.
89
+ - Redis. an in-process mock is used in tests when none is reachable.
90
+
91
+ ## Notes
92
+
93
+ - **YouTube player clients**: `services/ytdlp/turbo-mux.ts` passes `--extractor-args youtube:player-client=...` to obtain DASH manifests and bypass the 360p limit on the default web client. the client list is chosen per request based on the requested format.
94
+ - **Concurrency**: `concurrencyGuard(limit)` (`utils/network/security.util.ts`) caps in-flight downloads at `limit=2` per process to prevent OOM on small hosts.
95
+ - **Cookie sync**: `utils/network/cookie.util.ts` pulls `youtube_cookies.txt` and `facebook_cookies.txt` from `COOKIES_URL` at boot when the variable is set.
96
+ - **MP3 transcoding**: `services/ytdlp/streamer.ts` pipes raw audio through `ffmpeg -vn -ab 192k -f mp3 pipe:1` straight to the client — no temp files.
97
+ - **MP4 finalization**: `-movflags +faststart` is set on finalized MP4 downloads so playback can start before the file finishes.
98
+ - **SSE**: `/events` is the canonical telemetry channel. resolvers, downloaders, and the seeder push progress through `utils/network/sse.util.ts`.
99
+
100
+ before putting an instance on the public internet, read [`../docs/protect-an-instance.md`](../docs/protect-an-instance.md).
backend/deno-wrapper ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ #!/data/data/com.termux/files/usr/bin/bash
2
+ /data/data/com.termux/files/usr/bin/deno "$@" --allow-all
backend/eslint.config.js ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js';
2
+ import globals from 'globals';
3
+ import tseslint from 'typescript-eslint';
4
+ import sonarjs from 'eslint-plugin-sonarjs';
5
+ import prettierConfig from 'eslint-config-prettier';
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import process from 'node:process';
9
+
10
+ const pluginPath = join(process.cwd(), '../scripts/eslint-plugin-nexstream.js');
11
+ const hasPlugin = existsSync(pluginPath);
12
+ const nexstreamPlugin = hasPlugin ? (await import('../scripts/eslint-plugin-nexstream.js')).default : null;
13
+
14
+ export default tseslint.config(
15
+ {
16
+ ignores: ['dist', 'node_modules', 'temp'],
17
+ },
18
+ js.configs.recommended,
19
+ ...tseslint.configs.recommended,
20
+ sonarjs.configs.recommended,
21
+ prettierConfig,
22
+ {
23
+ plugins: {
24
+ ...(nexstreamPlugin ? { nexstream: nexstreamPlugin } : {}),
25
+ },
26
+ languageOptions: {
27
+ ecmaVersion: 2022,
28
+ sourceType: 'module',
29
+ globals: {
30
+ ...globals.node,
31
+ },
32
+ },
33
+ rules: {
34
+ ...(nexstreamPlugin ? {
35
+ 'nexstream/nexstream-comments': 'error',
36
+ 'nexstream/no-raw-fetch': 'error',
37
+ 'nexstream/no-raw-spawn': 'error',
38
+ } : {}),
39
+ complexity: ['error', 30],
40
+ 'object-shorthand': ['error', 'always'],
41
+ 'no-extra-boolean-cast': 'error',
42
+ 'no-unneeded-ternary': 'error',
43
+ '@typescript-eslint/no-non-null-assertion': 'error',
44
+ 'id-length': [
45
+ 'error',
46
+ {
47
+ min: 2,
48
+ exceptions: ['i', 'j', '_', 'x', 'y', 'z', 'C', 'D', 'E', 'F', 'G', 'A', 'B', 'id', 'ip', 'cb', 'fs', 'db', 'ms', 'ok', 'err', 'req', 'res', 'url'],
49
+ },
50
+ ],
51
+ 'sonarjs/cognitive-complexity': 'off',
52
+ 'sonarjs/no-os-command-from-path': 'off',
53
+ 'sonarjs/regex-complexity': 'off',
54
+ 'sonarjs/slow-regex': 'off',
55
+ 'sonarjs/no-nested-conditional': 'off',
56
+ 'sonarjs/no-identical-expressions': 'off',
57
+ 'sonarjs/duplicates-in-character-class': 'off',
58
+ 'sonarjs/single-character-alternation': 'off',
59
+ 'sonarjs/pseudo-random': 'off',
60
+ 'sonarjs/content-length': 'off',
61
+ 'sonarjs/void-use': 'off',
62
+ 'sonarjs/unused-import': 'error',
63
+ 'no-unused-vars': 'off',
64
+ '@typescript-eslint/no-unused-vars': [
65
+ 'error',
66
+ {
67
+ varsIgnorePattern: '^[A-Z_]',
68
+ argsIgnorePattern: '^[A-Z_]',
69
+ caughtErrorsIgnorePattern: '^[A-Z_]',
70
+ },
71
+ ],
72
+ 'require-await': 'error',
73
+ 'prefer-template': 'error',
74
+ '@typescript-eslint/no-inferrable-types': 'error',
75
+ '@typescript-eslint/prefer-optional-chain': 'off',
76
+ '@typescript-eslint/no-explicit-any': 'error',
77
+ 'prefer-const': 'error',
78
+ 'no-template-curly-in-string': 'error',
79
+ 'no-restricted-syntax': [
80
+ 'error',
81
+ {
82
+ selector: 'ExportNamedDeclaration > VariableDeclaration[kind="let"]',
83
+ message: 'Use "const" instead of "let" for exports.',
84
+ },
85
+ {
86
+ selector: 'ExportNamedDeclaration > VariableDeclaration[kind="var"]',
87
+ message: 'Use "const" instead of "var" for exports.',
88
+ },
89
+ ],
90
+ 'spaced-comment': ['error', 'always'],
91
+ },
92
+ },
93
+ {
94
+ files: ['tests/**/*.ts', 'tests/**/*.js', 'src/temp/**/*.ts', 'src/instrument.ts', 'scripts/**/*.ts', '../scripts/**/*.js'],
95
+ rules: {
96
+ 'sonarjs/no-hardcoded-ip': 'off',
97
+ 'sonarjs/no-clear-text-protocols': 'off',
98
+ complexity: 'off',
99
+ 'sonarjs/no-duplicate-string': 'off',
100
+ 'sonarjs/no-ignored-exceptions': 'off',
101
+ 'sonarjs/no-commented-code': 'off',
102
+ 'sonarjs/cors': 'off',
103
+ 'sonarjs/x-powered-by': 'off',
104
+ 'sonarjs/no-all-duplicated-branches': 'off',
105
+ 'sonarjs/unused-import': 'off',
106
+ '@typescript-eslint/no-explicit-any': 'error',
107
+ ...(nexstreamPlugin ? {
108
+ 'nexstream/nexstream-comments': 'error',
109
+ 'nexstream/no-raw-fetch': 'error',
110
+ 'nexstream/no-raw-spawn': 'error',
111
+ } : {}),
112
+ },
113
+ }
114
+ );
backend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
backend/package.json ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "backend",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "src/app.ts",
6
+ "scripts": {
7
+ "build": "npx tsc && cp -f .env dist/backend/.env || true",
8
+ "dev": "npx tsc && node --import ./dist/backend/src/instrument.js scripts/termux-shim.js",
9
+ "start": "node --import ./dist/backend/src/instrument.js scripts/termux-shim.js",
10
+ "test": "./scripts/test-sequential.sh",
11
+ "test:fast": "./scripts/test-sequential.sh 'tests/core/*.test.ts'",
12
+ "test:phase1": "./scripts/test-sequential.sh 'tests/core/cipher_randomization.test.ts tests/core/client_rotation.test.ts tests/core/throttle_bypass_args.test.ts tests/core/streamer_args.test.ts tests/core/retry_stream_lifecycle.test.ts tests/core/smart_client_pick.test.ts tests/core/temp_file_flow.test.ts tests/core/cookie_bootstrap.test.ts tests/core/cookie_deduplication.test.ts tests/core/format_string_fallback.test.ts tests/api/convert_temp_file_e2e.test.ts'",
13
+ "test:lite": "npx tsc && node --test tests/lite/*.lite.test.js",
14
+ "test:ci": "./scripts/ci-test.sh",
15
+ "test:ci:keep": "./scripts/ci-test.sh --keep",
16
+ "test:single": "NODE_OPTIONS='--import ./scripts/termux-shim.js --max-old-space-size=512' npx vitest run",
17
+ "verify:chunked": "VITEST_INCLUDE_MANUAL=1 SMOKE_TEST=1 npx vitest run tests/manual/chunked_fetcher_smoke.test.ts --no-coverage",
18
+ "bench:convert": "node scripts/bench-convert.js",
19
+ "lint": "bash ../scripts/lint-changed.sh",
20
+ "lint:all": "eslint .",
21
+ "format": "prettier --write ."
22
+ },
23
+ "keywords": [],
24
+ "author": "ejjays",
25
+ "license": "AGPL-3.0-or-later",
26
+ "type": "module",
27
+ "dependencies": {
28
+ "@google/genai": "^1.35.0",
29
+ "@libsql/client": "^0.17.3",
30
+ "@sentry/node": "^10.53.1",
31
+ "@sentry/profiling-node": "^10.53.1",
32
+ "better-sse": "^0.16.1",
33
+ "bullmq": "^5.73.0",
34
+ "cheerio": "^1.2.0",
35
+ "compression": "^1.8.1",
36
+ "cors": "^2.8.5",
37
+ "dotenv": "^17.2.3",
38
+ "essentia.js": "^0.1.3",
39
+ "express": "^5.2.1",
40
+ "express-rate-limit": "^8.5.2",
41
+ "fluent-ffmpeg": "^2.1.3",
42
+ "form-data": "^4.0.5",
43
+ "fpcalc": "^1.3.0",
44
+ "got": "^11.8.6",
45
+ "helmet": "^8.2.0",
46
+ "ioredis": "^5.10.1",
47
+ "jiti": "^2.6.1",
48
+ "lru-cache": "^11.3.6",
49
+ "metascraper": "^5.50.3",
50
+ "metascraper-author": "^5.50.1",
51
+ "metascraper-description": "^5.50.1",
52
+ "metascraper-image": "^5.50.1",
53
+ "metascraper-instagram": "^5.50.3",
54
+ "metascraper-logo": "^5.50.1",
55
+ "metascraper-publisher": "^5.50.1",
56
+ "metascraper-soundcloud": "^5.50.1",
57
+ "metascraper-spotify": "^5.50.1",
58
+ "metascraper-tiktok": "^5.50.1",
59
+ "metascraper-title": "^5.50.1",
60
+ "metascraper-url": "^5.50.1",
61
+ "metascraper-youtube": "^5.50.1",
62
+ "multer": "^1.4.5-lts.1",
63
+ "node-shazam": "^1.2.7",
64
+ "pino": "^10.3.1",
65
+ "spotify-url-info": "^3.3.0",
66
+ "sucrase": "^3.35.1",
67
+ "ultimate-guitar": "^2.0.6",
68
+ "undici": "^8.3.0",
69
+ "wav-decoder": "^1.3.0",
70
+ "youtubei.js": "^17.0.1"
71
+ },
72
+ "devDependencies": {
73
+ "@eslint/js": "^10.0.1",
74
+ "@types/compression": "^1.8.1",
75
+ "@types/cors": "^2.8.17",
76
+ "@types/express": "^5.0.0",
77
+ "@types/fluent-ffmpeg": "^2.1.27",
78
+ "@types/metascraper": "^5.14.3",
79
+ "@types/metascraper-author": "^5.14.5",
80
+ "@types/metascraper-description": "^5.14.5",
81
+ "@types/metascraper-image": "^5.14.5",
82
+ "@types/metascraper-logo": "^5.14.4",
83
+ "@types/metascraper-publisher": "^5.14.5",
84
+ "@types/metascraper-title": "^5.14.5",
85
+ "@types/metascraper-url": "^5.14.5",
86
+ "@types/multer": "^1.4.11",
87
+ "@types/node": "^22.19.19",
88
+ "@types/supertest": "^7.2.0",
89
+ "@vitest/coverage-v8": "^4.1.7",
90
+ "eslint": "^10.4.0",
91
+ "eslint-config-prettier": "^10.1.8",
92
+ "eslint-plugin-sonarjs": "^4.0.3",
93
+ "globals": "^17.6.0",
94
+ "ioredis-mock": "^8.13.1",
95
+ "msw": "^2.13.6",
96
+ "pino-pretty": "^13.1.3",
97
+ "prettier": "^3.8.3",
98
+ "puppeteer-core": "^25.0.4",
99
+ "supertest": "^7.2.2",
100
+ "ts-node": "^10.9.2",
101
+ "tsx": "^4.21.0",
102
+ "typescript": "^5.4.0",
103
+ "typescript-eslint": "^8.59.4",
104
+ "vitest": "^4.1.5",
105
+ "zod": "^4.4.3"
106
+ },
107
+ "optionalDependencies": {
108
+ "@esbuild/android-arm64": "^0.28.0",
109
+ "@libsql/android-arm64": "npm:null@*",
110
+ "@rollup/rollup-android-arm64": "^4.60.3",
111
+ "@rollup/rollup-linux-x64-gnu": "^4.60.3"
112
+ }
113
+ }
backend/scripts/bench-convert.js ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * benchmark /convert endpoint performance
3
+ *
4
+ * usage:
5
+ * node scripts/bench-convert.js [URL]
6
+ *
7
+ * envs:
8
+ * BENCH_HOST: target server
9
+ * BENCH_URL: fallback URL
10
+ * BENCH_FORMAT: mp4|m4a|mp3
11
+ * BENCH_TIMEOUT_MS: request timeout
12
+ */
13
+
14
+ import { request as httpRequest } from 'node:http';
15
+ import { URL } from 'node:url';
16
+
17
+ const MB = 1024 * 1024;
18
+
19
+ const HOST = process.env.BENCH_HOST || 'http://localhost:5000';
20
+ const URL_ARG =
21
+ process.argv[2] ||
22
+ process.env.BENCH_URL ||
23
+ 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
24
+ const FORMAT = process.env.BENCH_FORMAT || 'mp4';
25
+ const TIMEOUT_MS = parseInt(process.env.BENCH_TIMEOUT_MS || '180000', 10);
26
+ const CLIENT_ID = `bench_${Date.now().toString(36)}`;
27
+
28
+ async function resolveFormats(target, urlArg, clientId) {
29
+ const infoUrl = `${target.origin}/info?url=${encodeURIComponent(urlArg)}&id=${clientId}`;
30
+ const startedAt = Date.now();
31
+ const maxWaitMs = 30_000;
32
+ let attempt = 0;
33
+ let lastBody = null;
34
+
35
+ while (Date.now() - startedAt < maxWaitMs) {
36
+ attempt += 1;
37
+ const callStart = Date.now();
38
+ const info = await fetchJson(infoUrl);
39
+ console.log(
40
+ `[bench] /info attempt ${attempt} status=${info.status} (${Date.now() - callStart}ms)`
41
+ );
42
+
43
+ if (info.status !== 200) {
44
+ throw new Error(
45
+ `/info failed: status=${info.status} body=${JSON.stringify(info.body).slice(0, 200)}`
46
+ );
47
+ }
48
+
49
+ lastBody = info.body;
50
+ const audioFormats = info.body.audioFormats || [];
51
+ const videoFormats = info.body.formats || [];
52
+ const totalFormats = audioFormats.length + videoFormats.length;
53
+
54
+ if (totalFormats > 0) {
55
+ console.log(
56
+ `[bench] formats ready after ${Date.now() - startedAt}ms (audio=${audioFormats.length}, video=${videoFormats.length}, isPartial=${Boolean(info.body.isPartial)})`
57
+ );
58
+ return info.body;
59
+ }
60
+
61
+ console.log(
62
+ `[bench] partial: isPartial=${Boolean(info.body.isPartial)}, retrying in 500ms`
63
+ );
64
+ await new Promise((resolve) => setTimeout(resolve, 500));
65
+ }
66
+
67
+ throw new Error(
68
+ `timed out waiting for formats after ${maxWaitMs}ms; last=${JSON.stringify(lastBody).slice(0, 200)}`
69
+ );
70
+ }
71
+
72
+ function fetchJson(url) {
73
+ return new Promise((resolve, reject) => {
74
+ const req = httpRequest(url, { method: 'GET' }, (res) => {
75
+ const chunks = [];
76
+ res.on('data', (chunk) => chunks.push(chunk));
77
+ res.on('end', () => {
78
+ try {
79
+ resolve({
80
+ status: res.statusCode,
81
+ body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
82
+ });
83
+ } catch (err) {
84
+ reject(err);
85
+ }
86
+ });
87
+ });
88
+ req.on('error', reject);
89
+ req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error('info timeout')));
90
+ req.end();
91
+ });
92
+ }
93
+
94
+ function streamDownload(url) {
95
+ return new Promise((resolve, reject) => {
96
+ const t0 = Date.now();
97
+ let firstByteAt = 0;
98
+ let lastByteAt = 0;
99
+ let totalBytes = 0;
100
+
101
+ const req = httpRequest(url, { method: 'GET' }, (res) => {
102
+ if (res.statusCode !== 200) {
103
+ reject(new Error(`status ${res.statusCode}`));
104
+ return;
105
+ }
106
+ console.log(
107
+ `[bench] response: ${res.statusCode} ${res.headers['content-type']}`
108
+ );
109
+ console.log(
110
+ `[bench] disposition: ${res.headers['content-disposition'] || '(none)'}`
111
+ );
112
+
113
+ res.on('data', (chunk) => {
114
+ if (firstByteAt === 0) firstByteAt = Date.now();
115
+ lastByteAt = Date.now();
116
+ totalBytes += chunk.length;
117
+ if (totalBytes % (4 * MB) < chunk.length) {
118
+ process.stdout.write(
119
+ `[bench] progress: ${(totalBytes / MB).toFixed(1)} MB\r`
120
+ );
121
+ }
122
+ });
123
+ res.on('end', () => {
124
+ const totalMs = Math.max(1, Date.now() - t0);
125
+ const ttfbMs = firstByteAt > 0 ? firstByteAt - t0 : -1;
126
+ const streamingMs = Math.max(1, lastByteAt - firstByteAt);
127
+ const wallMbps = totalBytes / MB / (totalMs / 1000);
128
+ const streamMbps = totalBytes / MB / (streamingMs / 1000);
129
+
130
+ process.stdout.write('\n');
131
+ resolve({
132
+ totalBytes,
133
+ totalMs,
134
+ ttfbMs,
135
+ streamingMs,
136
+ wallMbps,
137
+ streamMbps,
138
+ });
139
+ });
140
+ res.on('error', reject);
141
+ });
142
+ req.on('error', reject);
143
+ req.setTimeout(TIMEOUT_MS, () =>
144
+ req.destroy(new Error('download timeout'))
145
+ );
146
+ req.end();
147
+ });
148
+ }
149
+
150
+ async function main() {
151
+ const target = new URL(HOST);
152
+ console.log(`[bench] host: ${target.origin}`);
153
+ console.log(`[bench] url: ${URL_ARG}`);
154
+ console.log(`[bench] format: ${FORMAT}`);
155
+
156
+ console.log('[bench] resolving /info (will await prefetch if partial)...');
157
+ const body = await resolveFormats(target, URL_ARG, CLIENT_ID);
158
+
159
+ const audioFormats = body.audioFormats || [];
160
+ const videoFormats = body.formats || [];
161
+ const isAudioRequest = ['m4a', 'mp3', 'audio'].includes(FORMAT);
162
+
163
+ let pick = process.env.BENCH_FORMAT_ID;
164
+ if (!pick) {
165
+ if (isAudioRequest) {
166
+ pick = audioFormats[0]?.formatId || videoFormats[0]?.formatId;
167
+ } else {
168
+ // prefer 720p mp4 video
169
+ const candidates = videoFormats.filter(
170
+ (fmt) => fmt.height && fmt.height <= 1080 && fmt.height >= 360
171
+ );
172
+ pick =
173
+ candidates.find((fmt) => fmt.height === 720)?.formatId ||
174
+ candidates[0]?.formatId ||
175
+ videoFormats[0]?.formatId ||
176
+ audioFormats[0]?.formatId;
177
+ }
178
+ }
179
+
180
+ if (!pick) {
181
+ throw new Error(
182
+ `no format available even after wait (audio=${audioFormats.length}, video=${videoFormats.length})`
183
+ );
184
+ }
185
+ console.log(`[bench] selected formatId=${pick}`);
186
+
187
+ const params = new URLSearchParams({
188
+ url: URL_ARG,
189
+ format: FORMAT,
190
+ formatId: String(pick),
191
+ id: CLIENT_ID,
192
+ token: CLIENT_ID,
193
+ title: body.title || 'Bench',
194
+ artist: body.uploader || 'Bench',
195
+ });
196
+ const convertUrl = `${target.origin}/convert?${params.toString()}`;
197
+
198
+ console.log('[bench] hitting /convert...');
199
+ const result = await streamDownload(convertUrl);
200
+
201
+ console.log('');
202
+ const sizeMB = result.totalBytes / MB;
203
+ console.log(`[bench][/convert] size=${sizeMB.toFixed(2)}MB`);
204
+ console.log(`[bench][/convert] total=${result.totalMs}ms`);
205
+ console.log(`[bench][/convert] TTFB=${result.ttfbMs}ms`);
206
+ console.log(`[bench][/convert] streamingOnly=${result.streamingMs}ms`);
207
+ console.log('');
208
+ console.log(
209
+ `[bench][calc] wall = ${sizeMB.toFixed(2)}MB / ${(result.totalMs / 1000).toFixed(2)}s = ${result.wallMbps.toFixed(2)} MB/s (${(result.wallMbps * 8).toFixed(1)} Mbps)`
210
+ );
211
+ console.log(
212
+ `[bench][calc] stream= ${sizeMB.toFixed(2)}MB / ${(result.streamingMs / 1000).toFixed(2)}s = ${result.streamMbps.toFixed(2)} MB/s (${(result.streamMbps * 8).toFixed(1)} Mbps)`
213
+ );
214
+ }
215
+
216
+ main().catch((err) => {
217
+ console.error('[bench] failed:', err.message);
218
+ process.exitCode = 1;
219
+ });
backend/scripts/ci-test.sh ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # termux cant run vitest (Signal 9, phantom killing).
4
+ # so use circleci alternative, runs full test suite by creating temp branch,
5
+ # then fetch JUnit results back to ci-results/ locally.
6
+
7
+ # push to temp branch > circleci tests > download junit > cleanup
8
+
9
+ set -e
10
+ cd "$(dirname "$0")/.."
11
+
12
+ KEEP_BRANCH=false
13
+ POLL=true
14
+ for arg in "$@"; do
15
+ case $arg in
16
+ --keep) KEEP_BRANCH=true ;;
17
+ --no-poll) POLL=false ;;
18
+ esac
19
+ done
20
+
21
+ if [ -z "$CIRCLECI_TOKEN" ]; then
22
+ echo "error: CIRCLECI_TOKEN not set"
23
+ exit 1
24
+ fi
25
+
26
+ # circleci standalone project slug (found via /me/collaborations)
27
+ PROJECT_SLUG="circleci/9BjBRRbsXUjJueU2cq7uGg/YU36DWYQs3RevrR3a2o1CN"
28
+ ORG_SLUG="circleci/9BjBRRbsXUjJueU2cq7uGg"
29
+
30
+ ARTIFACT_DIR="$(pwd)/ci-results"
31
+ mkdir -p "$ARTIFACT_DIR"
32
+
33
+ api() { curl --noproxy '*' -s -H "Circle-Token: $CIRCLECI_TOKEN" "$1"; }
34
+
35
+ cleanup_branch() {
36
+ echo "cleaning up branch: $1"
37
+ git push origin --delete "$1" 2>/dev/null || true
38
+ git branch -D "$1" 2>/dev/null || true
39
+ }
40
+
41
+ BRANCH="ci-test/$(date +%Y%m%d-%H%M%S)"
42
+ ORIG_BRANCH=$(git rev-parse --abbrev-ref HEAD)
43
+ echo "branch: $BRANCH (from $ORIG_BRANCH)"
44
+
45
+ # commit all local changes to temp branch without losing them
46
+ git stash --include-untracked -q 2>/dev/null || true
47
+ git checkout -b "$BRANCH"
48
+ git stash pop -q 2>/dev/null || true
49
+ git add -A
50
+ git commit -m "ci-test: ephemeral" --no-verify --allow-empty
51
+
52
+ echo "pushing..."
53
+ PUSH_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
54
+ git push -u origin "$BRANCH" --no-verify 2>&1 | tail -3
55
+
56
+ # go back — restore working tree
57
+ git checkout "$ORIG_BRANCH" 2>/dev/null
58
+ git cherry-pick --no-commit "$BRANCH" 2>/dev/null || true
59
+ git reset HEAD 2>/dev/null || true
60
+
61
+ [ "$POLL" = false ] && { echo "check: https://app.circleci.com/pipelines/$ORG?branch=$BRANCH"; exit 0; }
62
+
63
+ echo "waiting for pipeline (2 min)..."
64
+ PIPELINE_ID=""
65
+ for _ in $(seq 1 60); do
66
+ # only match pipelines created AFTER our push
67
+ PIPELINE_ID=$(api "https://circleci.com/api/v2/pipeline?org-slug=$ORG_SLUG" \
68
+ | jq -r --arg ts "$PUSH_TIME" '[.items[] | select(.created_at > $ts)][0].id // empty' 2>/dev/null)
69
+ [ -n "$PIPELINE_ID" ] && [ "$PIPELINE_ID" != "null" ] && break
70
+ sleep 2
71
+ done
72
+
73
+ if [ -z "$PIPELINE_ID" ] || [ "$PIPELINE_ID" = "null" ]; then
74
+ echo "⏳ no pipeline yet — circleci may still be processing."
75
+ echo " check: https://app.circleci.com/pipelines/$ORG_SLUG"
76
+ echo " branch kept: $BRANCH (run 'git push origin --delete $BRANCH' to cleanup)"
77
+ exit 0
78
+ fi
79
+
80
+ echo "pipeline: $PIPELINE_ID"
81
+
82
+ # poll workflow
83
+ while true; do
84
+ WF=$(api "https://circleci.com/api/v2/pipeline/$PIPELINE_ID/workflow")
85
+ WF_ID=$(echo "$WF" | jq -r '.items[0].id // empty')
86
+ STATUS=$(echo "$WF" | jq -r '.items[0].status // "pending"')
87
+ case "$STATUS" in
88
+ success|failed|error|canceled) break ;;
89
+ *) printf " %s...\r" "$STATUS"; sleep 10 ;;
90
+ esac
91
+ done
92
+
93
+ echo ""
94
+ [ "$STATUS" = "success" ] && echo "✅ passed" || echo "❌ failed ($STATUS)"
95
+
96
+ # download artifacts
97
+ if [ -n "$WF_ID" ] && [ "$WF_ID" != "null" ]; then
98
+ JOBS=$(api "https://circleci.com/api/v2/workflow/$WF_ID/job")
99
+ for jn in $(echo "$JOBS" | jq -r '.items[] | select(.job_number != null) | .job_number'); do
100
+ JOB_NAME=$(echo "$JOBS" | jq -r ".items[] | select(.job_number == $jn) | .name")
101
+ JOB_STATUS=$(echo "$JOBS" | jq -r ".items[] | select(.job_number == $jn) | .status")
102
+ echo " $JOB_NAME: $JOB_STATUS"
103
+
104
+ # fetch test results
105
+ TESTS=$(api "https://circleci.com/api/v2/project/$PROJECT_SLUG/$jn/tests")
106
+ TOTAL=$(echo "$TESTS" | jq '.items | length')
107
+ FAILED=$(echo "$TESTS" | jq '[.items[] | select(.result == "failure")] | length')
108
+ echo " tests: $TOTAL | failed: $FAILED"
109
+
110
+ if [ "$FAILED" -gt 0 ] 2>/dev/null; then
111
+ echo " failures:"
112
+ echo "$TESTS" | jq -r '.items[] | select(.result == "failure") | " ✗ \(.name)"'
113
+ fi
114
+
115
+ # save test results as json
116
+ echo "$TESTS" | jq '.' > "$ARTIFACT_DIR/${JOB_NAME}-tests.json" 2>/dev/null
117
+
118
+ # download test-results.xml artifact if available
119
+ ARTS=$(api "https://circleci.com/api/v2/project/$PROJECT_SLUG/$jn/artifacts")
120
+ XML_URL=$(echo "$ARTS" | jq -r '.items[] | select(.path | test("test-results")) | .url' 2>/dev/null)
121
+ if [ -n "$XML_URL" ] && [ "$XML_URL" != "null" ]; then
122
+ curl --noproxy '*' -sL -H "Circle-Token: $CIRCLECI_TOKEN" "$XML_URL" -o "$ARTIFACT_DIR/${JOB_NAME}-test-results.xml"
123
+ echo " ${JOB_NAME}-test-results.xml"
124
+ fi
125
+ done
126
+ fi
127
+
128
+ [ "$KEEP_BRANCH" = false ] && cleanup_branch "$BRANCH"
129
+ [ "$STATUS" = "success" ] && exit 0 || exit 1
backend/scripts/termux-shim.js ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module, createRequire } from 'module';
2
+
3
+ createRequire(import.meta.url);
4
+
5
+ // mock native modules
6
+ const originalRequire = Module.prototype.require;
7
+ Module.prototype.require = function (name, ...args) {
8
+ if (name.includes('libsql') || name === '@libsql/client') {
9
+ try {
10
+ return originalRequire.apply(this, [name, ...args]);
11
+ } catch (err) {
12
+ // bypass native noise
13
+ const msg = err instanceof Error ? err.message : String(err);
14
+ console.debug('[System] LibSQL unavailable:', msg);
15
+ return {
16
+ createClient: () => ({
17
+ execute: () => Promise.resolve({ rows: [] }),
18
+ batch: () => Promise.resolve([]),
19
+ close: () => {
20
+ /* no-op */
21
+ },
22
+ }),
23
+ };
24
+ }
25
+ }
26
+ if (name === '@ffmpeg-installer/ffmpeg') {
27
+ return { path: 'ffmpeg', version: 'system', url: 'https://ffmpeg.org/' };
28
+ }
29
+ return originalRequire.apply(this, [name, ...args]);
30
+ };
31
+
32
+ console.log('[env] termux bypass active');
33
+
34
+ // load main app
35
+ await import('../dist/backend/src/app.js');
backend/scripts/test-sequential.sh ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Sequential test runner for low-memory environments (Termux)
3
+ # Runs each test file in isolation, freeing memory between them.
4
+ # Usage: ./scripts/test-sequential.sh [pattern]
5
+
6
+ set -e
7
+
8
+ cd "$(dirname "$0")/.."
9
+
10
+ PATTERN="${1:-tests/core/*.test.ts tests/api/*.test.ts}"
11
+ PASS=0
12
+ FAIL=0
13
+ FAILED_FILES=()
14
+
15
+ # expand glob pattern
16
+ shopt -s nullglob
17
+ FILES=()
18
+ for pat in $PATTERN; do
19
+ for file in $pat; do
20
+ [ -f "$file" ] && FILES+=("$file")
21
+ done
22
+ done
23
+
24
+ TOTAL=${#FILES[@]}
25
+ echo "Running $TOTAL test files sequentially..."
26
+ echo ""
27
+
28
+ for i in "${!FILES[@]}"; do
29
+ FILE="${FILES[$i]}"
30
+ IDX=$((i + 1))
31
+ printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$FILE"
32
+
33
+ if NODE_OPTIONS='--import ./scripts/termux-shim.js --max-old-space-size=384' \
34
+ npx vitest run "$FILE" --reporter=dot --no-coverage > /tmp/vitest-out.log 2>&1; then
35
+ echo "PASS"
36
+ PASS=$((PASS + 1))
37
+ else
38
+ echo "FAIL"
39
+ FAIL=$((FAIL + 1))
40
+ FAILED_FILES+=("$FILE")
41
+ tail -20 /tmp/vitest-out.log
42
+ fi
43
+
44
+ # let the OS reclaim memory between runs
45
+ sleep 1
46
+ done
47
+
48
+ echo ""
49
+ echo "=========================================="
50
+ echo "Total: $TOTAL | Passed: $PASS | Failed: $FAIL"
51
+ echo "=========================================="
52
+
53
+ if [ ${#FAILED_FILES[@]} -gt 0 ]; then
54
+ echo ""
55
+ echo "Failed files:"
56
+ printf ' %s\n' "${FAILED_FILES[@]}"
57
+ exit 1
58
+ fi
backend/src/app.ts ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import './instrument.js';
2
+ import 'dotenv/config';
3
+ import dns from 'node:dns';
4
+ import { startCipherRotation } from './utils/network/cipher.util.js';
5
+
6
+ startCipherRotation();
7
+ import express, { Request, Response, NextFunction } from 'express';
8
+ import compression from 'compression';
9
+ import helmet from 'helmet';
10
+ import { rateLimit } from 'express-rate-limit';
11
+ import * as Sentry from '@sentry/node'; // skipcq: JS-C1003
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { traceContext } from './utils/infra/trace.util.js';
16
+ import { randomUUID } from 'node:crypto';
17
+ import db from './utils/infra/db.util.js';
18
+ import videoRoutes from './routes/video.routes.js';
19
+ import keyChangerRoutes from './routes/keychanger.routes.js';
20
+ import remixRoutes from './routes/remix.routes.js';
21
+ import {
22
+ requireApiKey,
23
+ requireLocalOrApiKey,
24
+ assertProdConfig,
25
+ } from './utils/network/auth.util.js';
26
+ import { logger } from './utils/infra/logger.util.js';
27
+ import { setupGracefulShutdown } from './utils/infra/shutdown.util.js';
28
+ import { configureServerTimeouts } from './utils/infra/server-timeouts.util.js';
29
+ import { closeAllRedis } from './utils/infra/redis.util.js';
30
+ import {
31
+ metricsMiddleware,
32
+ getMetrics,
33
+ recordFailure,
34
+ } from './utils/infra/metrics.util.js';
35
+
36
+ const __filename = fileURLToPath(import.meta.url);
37
+ const __dirname = path.dirname(__filename);
38
+
39
+ const fsPromises = fs.promises;
40
+ const STEMS_BASE_DIR = path.join(__dirname, '../temp/remix_stems');
41
+
42
+ // termux bypass
43
+ if (process.platform === 'android') {
44
+ try {
45
+ const { Module } = await import('module');
46
+ const originalRequire = Module.prototype.require;
47
+ Module.prototype.require = function (
48
+ this: unknown,
49
+ name: string,
50
+ ...args: unknown[]
51
+ ) {
52
+ if (name === '@ffmpeg-installer/ffmpeg') {
53
+ return {
54
+ path: 'ffmpeg',
55
+ version: 'system',
56
+ url: 'https://ffmpeg.org/',
57
+ };
58
+ }
59
+ if (name.includes('libsql')) {
60
+ try {
61
+ return (
62
+ originalRequire as (...innerArgs: unknown[]) => unknown
63
+ ).apply(this, [name, ...args]);
64
+ } catch (_ERROR) {
65
+ console.debug('[System] LibSQL bypass error:', _ERROR);
66
+ console.warn(
67
+ `[System] LibSQL native library '${name}' not found, bypassing...`
68
+ );
69
+ return {
70
+ createClient: () => ({
71
+ execute: () => Promise.resolve({ rows: [] }),
72
+ batch: () => Promise.resolve([]),
73
+ close: () => {},
74
+ }),
75
+ };
76
+ }
77
+ }
78
+ if (name === 'msgpackr-extract' || name === 'cpu-features') {
79
+ return null;
80
+ }
81
+ return (originalRequire as (...innerArgs: unknown[]) => unknown).apply(
82
+ this,
83
+ [name, ...args]
84
+ );
85
+ };
86
+ console.log('[System] Mocked native modules for Termux compatibility');
87
+ } catch (error: unknown) {
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ console.warn(
90
+ `[System] Failed to mock @ffmpeg-installer/ffmpeg: ${message}`
91
+ );
92
+ }
93
+ }
94
+
95
+ const dnsModule = dns as unknown as {
96
+ setDefaultResultOrder?: (order: 'ipv4first' | 'ipv6first') => void;
97
+ };
98
+ if (dnsModule.setDefaultResultOrder) {
99
+ dnsModule.setDefaultResultOrder('ipv4first');
100
+ }
101
+
102
+ // global errors
103
+ process.on('unhandledRejection', (reason: unknown) => {
104
+ const message = reason instanceof Error ? reason.message : String(reason);
105
+ console.error(`[Unhandled] reason: ${message}`);
106
+ });
107
+ process.on('uncaughtException', (err: unknown) => {
108
+ const message = err instanceof Error ? err.message : String(err);
109
+ console.error(`[Uncaught] error: ${message}`);
110
+ if (err instanceof Error && err.stack) console.error(err.stack);
111
+ });
112
+
113
+ const app = express();
114
+ app.set('trust proxy', 1);
115
+
116
+ // observe latency + outcomes
117
+ app.use(metricsMiddleware);
118
+
119
+ const PORT = Number(process.env.PORT) || 5000;
120
+
121
+ app.use(
122
+ helmet({
123
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
124
+ contentSecurityPolicy: {
125
+ directives: {
126
+ defaultSrc: ["'self'"],
127
+ scriptSrc: ["'self'", "'unsafe-inline'"],
128
+ styleSrc: ["'self'", "'unsafe-inline'"],
129
+ imgSrc: ["'self'", 'data:', 'https:'],
130
+ connectSrc: ["'self'", 'https:', 'wss:'],
131
+ },
132
+ },
133
+ })
134
+ );
135
+
136
+ const globalLimiter = rateLimit({
137
+ windowMs: 15 * 60 * 1000,
138
+ max: 100,
139
+ standardHeaders: true,
140
+ legacyHeaders: false,
141
+ message: { error: 'Too many requests, please try again later.' },
142
+ });
143
+
144
+ app.use('/api/', globalLimiter);
145
+
146
+ const infoLimiter = rateLimit({
147
+ windowMs: 1 * 60 * 1000,
148
+ max: 15,
149
+ standardHeaders: true,
150
+ legacyHeaders: false,
151
+ message: { error: 'Extraction rate limit exceeded. Slow down!' },
152
+ });
153
+
154
+ app.use(['/info', '/stream-urls'], infoLimiter);
155
+
156
+ // disable SSE compression
157
+ app.use(
158
+ compression({
159
+ filter: (req, res) => {
160
+ if (req.path === '/events') return false;
161
+ if (res.getHeader('Content-Type') === 'text/event-stream') return false;
162
+ return compression.filter(req, res);
163
+ },
164
+ })
165
+ );
166
+
167
+ app.use((req: Request, res: Response, next: NextFunction) => {
168
+ const traceId =
169
+ (req.headers['x-correlation-id'] as string) ||
170
+ (req.query.id as string) ||
171
+ randomUUID().split('-')[0];
172
+
173
+ res.setHeader('X-Trace-Id', traceId);
174
+
175
+ Sentry.withIsolationScope((scope) => {
176
+ if (process.env.SENTRY_DSN) {
177
+ scope.setTag('traceId', traceId);
178
+ }
179
+
180
+ traceContext.run({ traceId }, () => {
181
+ next();
182
+ });
183
+ });
184
+ });
185
+
186
+ app.use((req: Request, res: Response, next: NextFunction) => {
187
+ if (req.path === '/ping' || req.method === 'OPTIONS') {
188
+ next();
189
+ return;
190
+ }
191
+ const timestamp = new Date().toLocaleTimeString('en-US', {
192
+ hour12: false,
193
+ hour: '2-digit',
194
+ minute: '2-digit',
195
+ second: '2-digit',
196
+ });
197
+ const traceId =
198
+ (traceContext.getStore() as { traceId?: string })?.traceId || 'global';
199
+ console.log(
200
+ `[${timestamp}] [${traceId}] ${req.method} ${req.originalUrl || req.url}`
201
+ );
202
+ next();
203
+ });
204
+
205
+ // cors middleware
206
+ app.use((req: Request, res: Response, next: NextFunction) => {
207
+ const origin = req.headers.origin as string | undefined;
208
+ const allowlist = (process.env.ALLOWED_ORIGINS || '')
209
+ .split(',')
210
+ .map((entry) => entry.trim())
211
+ .filter(Boolean);
212
+ if (origin) {
213
+ const openMode = allowlist.length === 0;
214
+ if (openMode || allowlist.includes(origin)) {
215
+ res.header('Access-Control-Allow-Origin', origin);
216
+ res.header('Vary', 'Origin');
217
+ // credentials unsafe with reflected origin
218
+ if (!openMode) res.header('Access-Control-Allow-Credentials', 'true');
219
+ }
220
+ }
221
+ res.header(
222
+ 'Access-Control-Allow-Methods',
223
+ 'GET, POST, PUT, DELETE, OPTIONS, PATCH'
224
+ );
225
+ res.header(
226
+ 'Access-Control-Allow-Headers',
227
+ 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control, Last-Event-ID, ngrok-skip-browser-warning, bypass-tunnel-reminder, sentry-trace, baggage'
228
+ );
229
+
230
+ if (req.method === 'OPTIONS') {
231
+ res.status(200).end();
232
+ return;
233
+ }
234
+ next();
235
+ });
236
+
237
+ console.log('--- Environment Check ---');
238
+ console.log(`PORT: ${PORT}`);
239
+ console.log(
240
+ `COOKIES_URL: ${process.env.COOKIES_URL ? '✅ LOADED' : '❌ MISSING'}`
241
+ );
242
+ console.log(
243
+ `GEMINI_API_KEY: ${process.env.GEMINI_API_KEY ? '✅ LOADED' : '❌ MISSING'}`
244
+ );
245
+ console.log(
246
+ `GROQ_API_KEY: ${process.env.GROQ_API_KEY ? '✅ LOADED' : '❌ MISSING'}`
247
+ );
248
+ console.log(
249
+ `INFO CACHE: ${process.env.DISABLE_INFO_CACHE && process.env.DISABLE_INFO_CACHE !== '0' ? '🚫 DISABLED (testing)' : '✅ ENABLED'}`
250
+ );
251
+
252
+ dns.lookup('google.com', { family: 4 }, (err, addr) => {
253
+ const status = err ? '❌ FAILED' : `✅ ${addr}`;
254
+ console.log(`DNS google.com: ${status}`);
255
+ });
256
+ dns.lookup('youtube.com', { family: 4 }, (err, addr) => {
257
+ const status = err ? '❌ FAILED' : `✅ ${addr}`;
258
+ console.log(`DNS youtube.com: ${status}`);
259
+ });
260
+ console.log('-------------------------');
261
+
262
+ app.use((_req: Request, res: Response, next: NextFunction) => {
263
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
264
+ res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless');
265
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
266
+ next();
267
+ });
268
+
269
+ app.use(express.json({ limit: '1mb' }));
270
+ app.use(express.urlencoded({ limit: '1mb', extended: true }));
271
+
272
+ // core routes
273
+ const TEMP_DIR = path.join(__dirname, 'temp');
274
+ const CACHE_DIR = path.join(TEMP_DIR, 'yt-dlp-cache');
275
+
276
+ [TEMP_DIR, CACHE_DIR].forEach((dir) => {
277
+ if (!fs.existsSync(dir)) {
278
+ fs.mkdirSync(dir, { recursive: true });
279
+ }
280
+ });
281
+
282
+ console.log('[System] Initializing routes...');
283
+ app.get('/ping', (_req: Request, res: Response) => {
284
+ res.status(200).send('pong');
285
+ });
286
+
287
+ app.get('/api/get-url', async (_req: Request, res: Response) => {
288
+ try {
289
+ if (db) {
290
+ const result = (await db.execute({
291
+ sql: "SELECT value FROM configs WHERE key = 'BACKEND_URL' LIMIT 1",
292
+ args: [],
293
+ })) as unknown as { rows: Array<{ value: string }> };
294
+ if (result.rows.length > 0) {
295
+ res.json({ url: result.rows[0].value });
296
+ return;
297
+ }
298
+ }
299
+ } catch (error) {
300
+ console.error('[Discovery] Error fetching URL:', error);
301
+ }
302
+ res.json({ url: null });
303
+ });
304
+
305
+ // opt-in auth; off unless API_KEY set
306
+ app.use(
307
+ [
308
+ '/info',
309
+ '/stream-urls',
310
+ '/convert',
311
+ '/proxy',
312
+ '/api/remix',
313
+ '/api/key-changer',
314
+ ],
315
+ requireApiKey
316
+ );
317
+ app.use('/', videoRoutes);
318
+ app.use('/api/key-changer', keyChangerRoutes);
319
+ app.use('/api/remix', remixRoutes);
320
+ console.log('[System] Routes ready');
321
+
322
+ if (process.env.SENTRY_DSN) {
323
+ Sentry.setupExpressErrorHandler(app);
324
+ }
325
+
326
+ app.get('/health', (_req: Request, res: Response) => {
327
+ res.status(200).json({
328
+ status: 'ok',
329
+ port: PORT,
330
+ });
331
+ });
332
+
333
+ // gated: localhost or API_KEY only
334
+ app.get('/metrics', requireLocalOrApiKey, (_req: Request, res: Response) => {
335
+ res.status(200).json(getMetrics());
336
+ });
337
+
338
+ // global error handler
339
+ app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
340
+ const reason = err instanceof Error ? err.name : 'UnknownError';
341
+ recordFailure(reason);
342
+ logger.error({ err, path: req.path, method: req.method }, 'request error');
343
+ if (!res.headersSent) {
344
+ // hide internals from clients in production
345
+ const details =
346
+ process.env.NODE_ENV === 'production'
347
+ ? undefined
348
+ : err instanceof Error
349
+ ? err.message
350
+ : typeof err === 'string'
351
+ ? err
352
+ : JSON.stringify(err);
353
+ res.status(500).json({ error: 'Internal Server Error', details });
354
+ }
355
+ });
356
+
357
+ const distPath = path.join(__dirname, '../../frontend/dist');
358
+
359
+ if (fs.existsSync(distPath) && process.env.API_ONLY !== 'true') {
360
+ app.use(express.static(distPath));
361
+ app.get(/.*/u, (req: Request, res: Response, next: NextFunction) => {
362
+ if (
363
+ req.path.startsWith('/events') ||
364
+ req.path.startsWith('/info') ||
365
+ req.path.startsWith('/convert') ||
366
+ req.path.startsWith('/stream-urls') ||
367
+ req.path.startsWith('/proxy') ||
368
+ req.path.startsWith('/api') ||
369
+ req.path.includes('/EME_STREAM_DOWNLOAD/')
370
+ ) {
371
+ next();
372
+ return;
373
+ }
374
+
375
+ res.sendFile(path.join(distPath, 'index.html'));
376
+ });
377
+ } else {
378
+ app.get('/', (_req: Request, res: Response) => {
379
+ res.send('YouTube to MP4 Backend is running!');
380
+ });
381
+ }
382
+
383
+ export default app;
384
+
385
+ if (process.env.NODE_ENV !== 'test') {
386
+ assertProdConfig();
387
+ const server = app.listen(PORT, '0.0.0.0', () => {
388
+ console.log(`Server is running on port ${PORT}`);
389
+
390
+ configureServerTimeouts(server);
391
+
392
+ setupGracefulShutdown(server, { onClose: closeAllRedis });
393
+
394
+ import('./services/ytdlp/config.js').then(({ bootstrapCookies }) =>
395
+ bootstrapCookies()
396
+ );
397
+
398
+ import('node:child_process').then(({ exec, spawn: spawnChild }) => {
399
+ exec('yt-dlp --version', (err, stdout) => {
400
+ if (err) console.error(`yt-dlp check failed: ${err.message}`);
401
+ else console.log(`yt-dlp: ${stdout.trim()}`);
402
+ });
403
+
404
+ exec('ffmpeg -version', (err, stdout) => {
405
+ if (err) console.error(`FFmpeg check failed: ${err.message}`);
406
+ else console.log(`FFmpeg: ${stdout.split('\n')[0]}`);
407
+ });
408
+
409
+ // pot opt-in; bgutil currently fails botguard
410
+ const potEnabled = process.env.ENABLE_POT_PLUGIN === '1';
411
+ if (!potEnabled) {
412
+ console.log('[PO Token] disabled; set ENABLE_POT_PLUGIN=1 to enable');
413
+ } else {
414
+ const potCandidates = [
415
+ process.env.HOME
416
+ ? path.resolve(
417
+ process.env.HOME,
418
+ 'bgutil-ytdlp-pot-provider/server/build/main.js'
419
+ )
420
+ : null,
421
+ '/root/bgutil-ytdlp-pot-provider/server/build/main.js',
422
+ '/data/data/com.termux/files/home/bgutil-ytdlp-pot-provider/server/build/main.js',
423
+ ].filter((candidate): candidate is string => Boolean(candidate));
424
+ try {
425
+ const potScript = potCandidates.find((candidate) =>
426
+ fs.existsSync(candidate)
427
+ );
428
+ if (potScript) {
429
+ const pot = spawnChild('node', [potScript], {
430
+ stdio: 'ignore',
431
+ detached: true,
432
+ });
433
+ pot.unref();
434
+ console.log(
435
+ `PO Token server started (pid: ${pot.pid}, port: 4416)`
436
+ );
437
+ } else {
438
+ console.log(
439
+ `[PO Token] Server script not found in: ${potCandidates.join(', ')}`
440
+ );
441
+ }
442
+ } catch (error: unknown) {
443
+ console.error('[PO Token] Spawn failed:', (error as Error).message);
444
+ }
445
+ }
446
+ });
447
+
448
+ // warm YT client
449
+ (async () => {
450
+ const warmStart = Date.now();
451
+ try {
452
+ const { getYoutubeClient } =
453
+ await import('./services/extractors/youtube/client.js');
454
+ await getYoutubeClient();
455
+ console.log(
456
+ `[Warmup] Innertube client ready in ${Date.now() - warmStart}ms`
457
+ );
458
+ } catch (error) {
459
+ console.warn(
460
+ '[Warmup] Innertube pre-warm failed (will retry on first request):',
461
+ error instanceof Error ? error.message : error
462
+ );
463
+ }
464
+ })();
465
+
466
+ // avoid first request lazy load delay
467
+ (async () => {
468
+ const warmStart = Date.now();
469
+ try {
470
+ await Promise.all([
471
+ import('./services/extractors/index.js'),
472
+ import('./utils/api/response.util.js'),
473
+ import('./services/ytdlp/config.js'),
474
+ import('./utils/network/cookie.util.js'),
475
+ ]);
476
+ console.log(
477
+ `[Warmup] Hot-path modules ready in ${Date.now() - warmStart}ms`
478
+ );
479
+ } catch (error) {
480
+ console.warn(
481
+ '[Warmup] Hot-path module prewarm failed:',
482
+ error instanceof Error ? error.message : error
483
+ );
484
+ }
485
+ })();
486
+ });
487
+ }
488
+
489
+ interface DBExecutor {
490
+ execute: (options: {
491
+ sql: string;
492
+ args: unknown[];
493
+ }) => Promise<{ rows: { id: string }[] }>;
494
+ }
495
+
496
+ async function cleanupTempFiles(): Promise<void> {
497
+ try {
498
+ const files: string[] = await fsPromises.readdir(TEMP_DIR);
499
+ const now: number = Date.now();
500
+
501
+ for (const file of files) {
502
+ const filePath: string = path.join(TEMP_DIR, file);
503
+ const stats: fs.Stats = await fsPromises.lstat(filePath);
504
+
505
+ if (stats.isFile() && now - stats.mtimeMs > 3600000) {
506
+ await fsPromises.unlink(filePath).catch(() => {
507
+ /* ignore */
508
+ });
509
+ }
510
+ }
511
+
512
+ const threeDaysMs: number = 3 * 24 * 60 * 60 * 1000;
513
+ if (db) {
514
+ const executor = db as unknown as DBExecutor;
515
+ const expired = await executor.execute({
516
+ sql: 'SELECT id FROM remix_history WHERE created_at < ?',
517
+ args: [now - threeDaysMs],
518
+ });
519
+
520
+ for (const row of expired.rows) {
521
+ const dirPath: string = path.join(STEMS_BASE_DIR, row.id);
522
+ if (fs.existsSync(dirPath)) {
523
+ await fsPromises
524
+ .rm(dirPath, { recursive: true, force: true })
525
+ .catch(() => {
526
+ /* ignore */
527
+ });
528
+ }
529
+ await executor.execute({
530
+ sql: 'DELETE FROM remix_history WHERE id = ?',
531
+ args: [row.id],
532
+ });
533
+ console.log(`[Janitor] Cleaned up expired remix: ${row.id}`);
534
+ }
535
+ }
536
+ } catch (err: unknown) {
537
+ const message = err instanceof Error ? err.message : String(err);
538
+ console.error(`[Cleanup] Error reading temp directory: ${message}`);
539
+ }
540
+ }
541
+
542
+ const tempCleanupInterval = setInterval(() => {
543
+ cleanupTempFiles().catch(() => {
544
+ /* ignore */
545
+ });
546
+ }, 3600000);
547
+ // allow process exit
548
+ tempCleanupInterval.unref?.();
backend/src/controllers/keychanger.controller.ts ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import multer from 'multer';
2
+ import ffmpeg from 'fluent-ffmpeg';
3
+ import { dirname, join, extname, basename } from 'node:path';
4
+ import { resolveWithin } from '../utils/network/security.util.js';
5
+ import { existsSync, mkdirSync, readFileSync, unlink } from 'node:fs';
6
+ import wav from 'wav-decoder';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { Request, Response } from 'express';
9
+ import { randomBytes } from 'node:crypto';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ const TEMP_DIR = join(__dirname, '../../temp');
15
+ const uploadDir = join(TEMP_DIR, 'uploads');
16
+ const processedDir = join(TEMP_DIR, 'processed');
17
+
18
+ import { ChordsResult } from '../types/index.js';
19
+
20
+ interface EssentiaVector {
21
+ delete: () => void;
22
+ }
23
+
24
+ interface EssentiaPCVVector extends EssentiaVector {
25
+ push_back: (val: EssentiaVector) => void;
26
+ }
27
+
28
+ interface EssentiaInstance {
29
+ arrayToVector: (arr: Float32Array) => EssentiaVector;
30
+ vectorToArray: (vec: EssentiaVector) => string[] | number[];
31
+ KeyExtractor: (vec: EssentiaVector) => { key: string; scale: string };
32
+ Windowing: (vec: EssentiaVector) => { frame: EssentiaVector };
33
+ Spectrum: (vec: EssentiaVector) => { spectrum: EssentiaVector };
34
+ SpectralPeaks: (vec: EssentiaVector) => {
35
+ frequencies: EssentiaVector;
36
+ magnitudes: EssentiaVector;
37
+ };
38
+ HPCP: (
39
+ freqs: EssentiaVector,
40
+ mags: EssentiaVector
41
+ ) => { hpcp: EssentiaVector };
42
+ ChordsDetection: (vec: EssentiaPCVVector) => { chords: EssentiaVector };
43
+ module: {
44
+ VectorVectorFloat: new () => EssentiaPCVVector;
45
+ };
46
+ }
47
+
48
+ let essentia: EssentiaInstance | null = null;
49
+
50
+ async function getEssentia(): Promise<EssentiaInstance | null> {
51
+ if (essentia) return essentia;
52
+ try {
53
+ const { default: EssentiaModule } =
54
+ await (import('essentia.js') as unknown as {
55
+ default: {
56
+ Essentia: new (wasm: unknown) => EssentiaInstance;
57
+ EssentiaWASM: unknown;
58
+ };
59
+ });
60
+ essentia = new EssentiaModule.Essentia(EssentiaModule.EssentiaWASM);
61
+ return essentia;
62
+ } catch (error: unknown) {
63
+ console.error('❌ Essentia WASM failed', (error as Error).message);
64
+ return null;
65
+ }
66
+ }
67
+
68
+ [uploadDir, processedDir].forEach((dir) => {
69
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
70
+ });
71
+
72
+ const storage = multer.diskStorage({
73
+ destination: (_req, _file, callback) => {
74
+ callback(null, uploadDir);
75
+ },
76
+ filename: (_req, file, callback) => {
77
+ const safeName = file.originalname.replace(/[^a-zA-Z0-9.-]/gu, '_');
78
+ callback(null, `${Date.now()}-${safeName}`);
79
+ },
80
+ });
81
+
82
+ export const upload = multer({
83
+ storage,
84
+ limits: { fileSize: 100 * 1024 * 1024 }, // max 100mb
85
+ });
86
+
87
+ const keyMap: Record<string, number> = {
88
+ C: 0,
89
+ 'C#': 1,
90
+ Db: 1,
91
+ D: 2,
92
+ 'D#': 3,
93
+ Eb: 3,
94
+ E: 4,
95
+ F: 5,
96
+ 'F#': 6,
97
+ Gb: 6,
98
+ G: 7,
99
+ 'G#': 8,
100
+ Ab: 8,
101
+ A: 9,
102
+ 'A#': 10,
103
+ Bb: 10,
104
+ B: 11,
105
+ };
106
+
107
+ const _generateChords = (
108
+ essentiaInstance: EssentiaInstance,
109
+ signal: Float32Array
110
+ ) => {
111
+ const frameSize = 4096;
112
+ const hopSize = 2048;
113
+ const pcpVector = new essentiaInstance.module.VectorVectorFloat();
114
+
115
+ for (let i = 0; i < Math.min(signal.length, 44100 * 30); i += hopSize) {
116
+ if (i + frameSize > signal.length) break;
117
+ const frame = signal.slice(i, i + frameSize);
118
+ const frameVec = essentiaInstance.arrayToVector(frame);
119
+ const windowed = essentiaInstance.Windowing(frameVec).frame;
120
+ const spectrum = essentiaInstance.Spectrum(windowed).spectrum;
121
+ const peaks = essentiaInstance.SpectralPeaks(spectrum);
122
+ const hpcp = essentiaInstance.HPCP(
123
+ peaks.frequencies,
124
+ peaks.magnitudes
125
+ ).hpcp;
126
+ pcpVector.push_back(hpcp);
127
+ frameVec.delete();
128
+ }
129
+
130
+ const chordsResult = essentiaInstance.ChordsDetection(pcpVector);
131
+ const uniqueChords: string[] = [];
132
+
133
+ if (chordsResult?.chords) {
134
+ const chordsArray = essentiaInstance.vectorToArray(
135
+ chordsResult.chords
136
+ ) as string[];
137
+ chordsArray.forEach((chord: string) => {
138
+ if (uniqueChords[uniqueChords.length - 1] !== chord) {
139
+ uniqueChords.push(chord);
140
+ }
141
+ });
142
+ }
143
+
144
+ pcpVector.delete();
145
+ return uniqueChords;
146
+ };
147
+
148
+ const _handleAudioDecoding = (
149
+ essentiaInstance: EssentiaInstance,
150
+ buffer: Buffer,
151
+ tempWavPath: string,
152
+ resolve: (value: ChordsResult | PromiseLike<ChordsResult>) => void,
153
+ reject: (reason?: unknown) => void
154
+ ) => {
155
+ wav
156
+ .decode(buffer)
157
+ .then((audioData: { channelData: Float32Array[] }) => {
158
+ const signal = audioData.channelData[0];
159
+ const audioVector = essentiaInstance.arrayToVector(signal);
160
+ const keyResult = essentiaInstance.KeyExtractor(audioVector);
161
+
162
+ const uniqueChords = _generateChords(essentiaInstance, signal);
163
+
164
+ audioVector.delete();
165
+ unlink(tempWavPath, (_error) => {});
166
+
167
+ resolve({
168
+ key: keyResult.key,
169
+ scale: keyResult.scale,
170
+ chords: uniqueChords.filter((chord) => chord !== 'N').slice(0, 12),
171
+ });
172
+ })
173
+ .catch((error: Error) => {
174
+ unlink(tempWavPath, (_error) => {});
175
+ reject(error);
176
+ });
177
+ };
178
+
179
+ const detectKeyFromFile = async (filePath: string): Promise<ChordsResult> => {
180
+ const essentiaInstance = await getEssentia();
181
+ if (!essentiaInstance) throw new Error('Essentia engine not available');
182
+
183
+ return new Promise((resolve, reject) => {
184
+ const randomId = randomBytes(4).toString('hex');
185
+ const tempWavPath = join(uploadDir, `temp-${Date.now()}-${randomId}.wav`);
186
+
187
+ ffmpeg(filePath)
188
+ .toFormat('wav')
189
+ .audioChannels(1)
190
+ .audioFrequency(44100)
191
+ .on('end', () => {
192
+ const buffer = readFileSync(tempWavPath);
193
+ _handleAudioDecoding(
194
+ essentiaInstance,
195
+ buffer,
196
+ tempWavPath,
197
+ resolve,
198
+ reject
199
+ );
200
+ })
201
+ .on('error', (error: Error) => {
202
+ unlink(tempWavPath, (_error) => {});
203
+ reject(error);
204
+ })
205
+ .save(tempWavPath);
206
+ });
207
+ };
208
+
209
+ export const detectKey = async (req: Request, res: Response): Promise<void> => {
210
+ if (!req.file) {
211
+ res.status(400).json({ error: 'No file uploaded' });
212
+ return;
213
+ }
214
+
215
+ try {
216
+ const result = await detectKeyFromFile(req.file.path);
217
+ res.json(result);
218
+ } catch (error) {
219
+ console.error('[KeyChanger] Detection Error:', error);
220
+ res.status(500).json({ error: 'Audio analysis failed' });
221
+ } finally {
222
+ unlink(req.file.path, (_error) => {});
223
+ }
224
+ };
225
+
226
+ export const detectProcessedKey = async (
227
+ req: Request,
228
+ res: Response
229
+ ): Promise<void> => {
230
+ const filename = String(req.params.filename);
231
+ const filePath = resolveWithin(processedDir, filename);
232
+
233
+ if (!filePath) {
234
+ res.status(400).json({ error: 'Invalid path' });
235
+ return;
236
+ }
237
+ if (!existsSync(filePath)) {
238
+ res.status(404).json({ error: 'File not found' });
239
+ return;
240
+ }
241
+
242
+ try {
243
+ const result = await detectKeyFromFile(filePath);
244
+ res.json(result);
245
+ } catch (error) {
246
+ console.error('[KeyChanger] Processed Detection Error:', error);
247
+ res.status(500).json({ error: 'Analysis failed' });
248
+ }
249
+ };
250
+
251
+ export const convertKey = (req: Request, res: Response): void => {
252
+ if (!req.file) {
253
+ res.status(400).json({ error: 'No file uploaded' });
254
+ return;
255
+ }
256
+
257
+ const { originalKey, targetKey } = req.body;
258
+
259
+ if (
260
+ !originalKey ||
261
+ !targetKey ||
262
+ keyMap[originalKey] === undefined ||
263
+ keyMap[targetKey] === undefined
264
+ ) {
265
+ unlink(req.file.path, (_error) => {});
266
+ res.status(400).json({ error: 'Invalid keys provided' });
267
+ return;
268
+ }
269
+
270
+ const originalVal = keyMap[originalKey];
271
+ const targetVal = keyMap[targetKey];
272
+ let semitones = targetVal - originalVal;
273
+ if (semitones > 6) semitones -= 12;
274
+ if (semitones < -6) semitones += 12;
275
+
276
+ const pitchScale = Math.pow(2, semitones / 12);
277
+ const inputPath = req.file.path;
278
+ const extension = extname(req.file.originalname);
279
+ const baseNamePart = basename(req.file.originalname, extension).replace(
280
+ /[^a-zA-Z0-9\s.-]/gu,
281
+ '_'
282
+ );
283
+ const outputFilename = `${Date.now()}__${targetKey}__${baseNamePart}${extension}`;
284
+ const outputPath = join(processedDir, outputFilename);
285
+
286
+ ffmpeg(inputPath)
287
+ .audioFilters(`rubberband=pitch=${pitchScale}`)
288
+ .on('end', () => {
289
+ const protocol =
290
+ (req.headers['x-forwarded-proto'] as string) || req.protocol;
291
+ const host = req.headers.host;
292
+ res.json({
293
+ success: true,
294
+ filename: outputFilename,
295
+ downloadUrl: `${protocol}://${host}/api/key-changer/download/${outputFilename}`,
296
+ });
297
+ unlink(inputPath, (_error) => {});
298
+ })
299
+ .on('error', (error: unknown) => {
300
+ const errorObj = error as Error;
301
+ console.error('[KeyChanger] Conversion Error:', errorObj.message);
302
+ if (!res.headersSent)
303
+ res
304
+ .status(500)
305
+ .json({ error: 'Conversion failed.', details: errorObj.message });
306
+ unlink(inputPath, (_error) => {});
307
+ })
308
+ .save(outputPath);
309
+ };
310
+
311
+ export const downloadFile = (req: Request, res: Response): void => {
312
+ const filename = String(req.params.filename);
313
+ const filePath = resolveWithin(processedDir, filename);
314
+
315
+ if (!filePath) {
316
+ res.status(400).json({ error: 'Invalid path' });
317
+ return;
318
+ }
319
+ if (existsSync(filePath)) {
320
+ let prettyName = filename;
321
+ const parts = filename.split('__');
322
+ if (parts.length >= 3) {
323
+ const key = parts[1];
324
+ const nameWithExt = parts.slice(2).join('__');
325
+ const extension = extname(nameWithExt);
326
+ const baseNamePart = basename(nameWithExt, extension);
327
+ const cleanName = baseNamePart.replace(/_+/gu, ' ').trim();
328
+ prettyName = `(${key}) ${cleanName}${extension}`;
329
+ }
330
+
331
+ res.download(filePath, prettyName, (error) => {
332
+ if (error) {
333
+ const errorWithCode = error as Error & { code?: string };
334
+ if (
335
+ errorWithCode.code === 'ECONNABORTED' ||
336
+ errorWithCode.code === 'EPIPE'
337
+ ) {
338
+ return;
339
+ }
340
+ console.error('[KeyChanger] Download Error:', error);
341
+ }
342
+ });
343
+ } else {
344
+ res.status(404).json({ error: 'File not found' });
345
+ }
346
+ };
backend/src/controllers/remix.controller.ts ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Request, Response } from 'express';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import db from '../utils/infra/db.util.js';
6
+ import { extractSongData } from '../services/extract.service.js';
7
+ import { spawn } from 'child_process';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { Readable } from 'node:stream';
10
+ import os from 'node:os';
11
+ import { z } from 'zod';
12
+ import { secureFetch, resolveWithin } from '../utils/network/security.util.js';
13
+
14
+ const EngineStartResponseSchema = z
15
+ .object({
16
+ task_id: z.string().optional(),
17
+ message: z.string().optional(),
18
+ })
19
+ .catchall(z.unknown());
20
+
21
+ const EngineStatusResponseSchema = z
22
+ .object({
23
+ status: z.string(),
24
+ message: z.string().optional(),
25
+ data: z
26
+ .object({
27
+ stems: z
28
+ .record(z.string(), z.string().nullable().optional())
29
+ .optional(),
30
+ package: z.string().nullable().optional(),
31
+ })
32
+ .catchall(z.unknown())
33
+ .optional(),
34
+ })
35
+ .catchall(z.unknown());
36
+
37
+ const __filename = fileURLToPath(import.meta.url);
38
+ const __dirname = path.dirname(__filename);
39
+
40
+ const STEMS_BASE_DIR = path.join(__dirname, '../../temp/remix_stems');
41
+ if (!fs.existsSync(STEMS_BASE_DIR)) {
42
+ fs.mkdirSync(STEMS_BASE_DIR, { recursive: true });
43
+ }
44
+
45
+ const sessionEngines = new Map<string, string>();
46
+
47
+ export const registerEngine = (req: Request, res: Response): void => {
48
+ const { url, session_id } = req.body || {};
49
+ console.log(
50
+ `[Engine] Registration attempt: session=${session_id} url=${url}`
51
+ );
52
+
53
+ if (!url || !session_id) {
54
+ console.error('[Engine] Registration failed: Missing url or session_id');
55
+ res.status(400).json({ error: 'URL and session_id required' });
56
+ return;
57
+ }
58
+ sessionEngines.set(session_id, url);
59
+ console.log(`[Engine] Successfully registered: ${session_id} -> ${url}`);
60
+ res.json({ success: true, url, session_id });
61
+ };
62
+
63
+ export const getEngineStatus = (req: Request, res: Response): void => {
64
+ const sessionId = req.query.session_id as string;
65
+ if (!sessionId) {
66
+ res.json({ url: null, error: 'session_id required' });
67
+ return;
68
+ }
69
+ const url = sessionEngines.get(sessionId) || null;
70
+ if (url)
71
+ console.log(`[Engine] Status check: session=${sessionId} found=${url}`);
72
+ res.json({ url });
73
+ };
74
+
75
+ export const processAudio = async (
76
+ req: Request,
77
+ res: Response
78
+ ): Promise<void> => {
79
+ const { engine, stems, session_id } = req.body;
80
+ console.log(
81
+ `[Process] Request received: engine=${engine} stems=${stems} session_id=${session_id}`
82
+ );
83
+
84
+ const engineUrlRaw = session_id ? sessionEngines.get(session_id) : null;
85
+ console.log(
86
+ `[Process] Engine lookup: session_id=${session_id} -> url=${engineUrlRaw}`
87
+ );
88
+
89
+ if (!engineUrlRaw) {
90
+ console.error(
91
+ `[Process] Failed: No engine mapped for session ${session_id}`
92
+ );
93
+ res.status(400).json({ error: 'Engine not connected or session expired' });
94
+ return;
95
+ }
96
+ if (!req.file) {
97
+ console.error('[Process] Failed: No file uploaded');
98
+ res.status(400).json({ error: 'No file uploaded' });
99
+ return;
100
+ }
101
+
102
+ try {
103
+ const form = new FormData();
104
+ const fileBuffer = fs.readFileSync(req.file.path);
105
+ const fileBlob = new Blob([fileBuffer], { type: req.file.mimetype });
106
+ form.append('file', fileBlob, req.file.originalname);
107
+ form.append('engine', engine || 'Demucs');
108
+ form.append('stems', stems || '4 Stems');
109
+
110
+ const engineUrl = engineUrlRaw.replace(/\/$/, '');
111
+ console.log(`[Process] Forwarding to engine: ${engineUrl}/process`);
112
+
113
+ const startRes = await secureFetch(`${engineUrl}/process`, {
114
+ method: 'POST',
115
+ body: form,
116
+ });
117
+
118
+ console.log(`[Process] Engine response status: ${startRes.status}`);
119
+
120
+ if (!startRes.ok) {
121
+ const rawErr = await startRes.json().catch(() => ({}));
122
+ console.error('[Process] Engine error response:', rawErr);
123
+ const startData = EngineStartResponseSchema.safeParse(rawErr);
124
+ const errMessage = startData.success
125
+ ? startData.data.message
126
+ : undefined;
127
+ throw new Error(errMessage || `Engine error ${startRes.status}`);
128
+ }
129
+
130
+ const rawStartData = await startRes.json();
131
+ const startData = EngineStartResponseSchema.safeParse(rawStartData);
132
+ if (!startData.success || !startData.data.task_id) {
133
+ throw new Error('Failed to get task_id from engine');
134
+ }
135
+ const task_id = startData.data.task_id;
136
+
137
+ let attempts = 0;
138
+ const maxAttempts = 240;
139
+ const poll = async () => {
140
+ attempts++;
141
+ try {
142
+ const statusRes = await secureFetch(`${engineUrl}/status/${task_id}`);
143
+ if (!statusRes.ok)
144
+ throw new Error(`Status check failed: ${statusRes.status}`);
145
+ const rawTaskData = await statusRes.json();
146
+ const taskParsed = EngineStatusResponseSchema.safeParse(rawTaskData);
147
+ if (!taskParsed.success) {
148
+ throw new Error(
149
+ `Engine status parse error: ${taskParsed.error.message}`
150
+ );
151
+ }
152
+ const task = taskParsed.data;
153
+ if (task.status === 'success') {
154
+ const finalData = task.data;
155
+ if (!finalData || !finalData.stems)
156
+ throw new Error('Missing stems data');
157
+ Object.keys(finalData.stems).forEach((key) => {
158
+ if (finalData.stems?.[key]) {
159
+ finalData.stems[key] =
160
+ `${engineUrl}/download?path=${encodeURIComponent(finalData.stems[key])}`;
161
+ }
162
+ });
163
+ if (finalData.package) {
164
+ finalData.package = `${engineUrl}/download?path=${encodeURIComponent(finalData.package)}`;
165
+ }
166
+ res.json(finalData);
167
+ return;
168
+ } else if (task.status === 'error') {
169
+ throw new Error(task.message || 'Unknown engine error');
170
+ }
171
+ if (attempts >= maxAttempts) throw new Error('Processing timed out');
172
+ setTimeout(poll, 5000);
173
+ } catch (error: unknown) {
174
+ if (!res.headersSent)
175
+ res.status(500).json({
176
+ error: `polling failed: ${error instanceof Error ? error.message : String(error)}`,
177
+ });
178
+ }
179
+ };
180
+ setTimeout(poll, 5000);
181
+ } catch (error: unknown) {
182
+ if (!res.headersSent)
183
+ res.status(500).json({
184
+ error: `engine failed: ${error instanceof Error ? error.message : String(error)}`,
185
+ });
186
+ } finally {
187
+ if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
188
+ }
189
+ };
190
+
191
+ export const wakeEngine = async (
192
+ req: Request,
193
+ res: Response
194
+ ): Promise<void> => {
195
+ const { kaggleUsername, kaggleKey } = req.body;
196
+ let { backendUrl } = req.body;
197
+
198
+ // fallback to DB
199
+ if (
200
+ !backendUrl ||
201
+ backendUrl.includes('localhost') ||
202
+ backendUrl.includes('127.0.0.1')
203
+ ) {
204
+ try {
205
+ const dbResult = await db?.execute({
206
+ sql: "SELECT value FROM configs WHERE key = 'BACKEND_URL' LIMIT 1",
207
+ args: [],
208
+ });
209
+ const rows = dbResult?.rows;
210
+ if (rows && rows.length > 0) {
211
+ console.log(
212
+ `[Engine] Overriding localhost with public URL from DB: ${rows[0].value}`
213
+ );
214
+ backendUrl = rows[0].value as string;
215
+ }
216
+ } catch (error) {
217
+ console.error('[Engine] DB URL lookup failed:', error);
218
+ }
219
+ }
220
+
221
+ console.log(
222
+ `[Engine] Wake-engine request: user=${kaggleUsername} backendUrl=${backendUrl}`
223
+ );
224
+
225
+ const finalUsername = kaggleUsername || process.env.KAGGLE_USERNAME;
226
+ const finalKey = kaggleKey || process.env.KAGGLE_KEY;
227
+
228
+ if (!finalUsername || !finalKey) {
229
+ res.status(401).json({ error: 'Kaggle credentials required' });
230
+ return;
231
+ }
232
+
233
+ const sessionId = randomUUID();
234
+ const tmpBase = os.tmpdir();
235
+ const KAGGLE_TMP_DIR = path.join(tmpBase, `.kaggle-${sessionId}`);
236
+ const WORKSPACE_DIR = path.join(tmpBase, `workspace-${sessionId}`);
237
+
238
+ try {
239
+ // isolate auth
240
+ if (!fs.existsSync(KAGGLE_TMP_DIR))
241
+ fs.mkdirSync(KAGGLE_TMP_DIR, { recursive: true });
242
+ fs.writeFileSync(
243
+ path.join(KAGGLE_TMP_DIR, 'kaggle.json'),
244
+ JSON.stringify({ username: finalUsername, key: finalKey })
245
+ );
246
+
247
+ // ephemeral metadata
248
+ if (!fs.existsSync(WORKSPACE_DIR))
249
+ fs.mkdirSync(WORKSPACE_DIR, { recursive: true });
250
+
251
+ const scriptsDir = path.join(__dirname, '../../../scripts');
252
+ const metadataPath = path.join(scriptsDir, 'kernel-metadata.json');
253
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
254
+
255
+ // update metadata
256
+ metadata.id = `${finalUsername}/nexstream-engine-${sessionId.substring(0, 8)}`;
257
+ metadata.title = `NexStream Engine ${sessionId.substring(0, 8)}`;
258
+ metadata.code_file = 'run_engine.py';
259
+ metadata.accelerator = 'NvidiaTeslaT4'; // set accelerator
260
+
261
+ fs.writeFileSync(
262
+ path.join(WORKSPACE_DIR, 'kernel-metadata.json'),
263
+ JSON.stringify(metadata)
264
+ );
265
+
266
+ // inject env
267
+ const engineScriptPath = path.join(scriptsDir, 'remix_lab_btc.py');
268
+ const baseScript = fs.readFileSync(engineScriptPath, 'utf-8');
269
+ const safeBackendUrl = JSON.stringify(backendUrl);
270
+ const safeSessionId = JSON.stringify(sessionId);
271
+ const injectedScript = `
272
+ import os
273
+ os.environ["NEXSTREAM_BACKEND_URL"] = ${safeBackendUrl}
274
+ os.environ["NEXSTREAM_SESSION_ID"] = ${safeSessionId}
275
+
276
+ ${baseScript}
277
+ `;
278
+ fs.writeFileSync(path.join(WORKSPACE_DIR, 'run_engine.py'), injectedScript);
279
+
280
+ // dispatch kaggle
281
+ const kaggleProcess = spawn(
282
+ 'kaggle',
283
+ [
284
+ 'kernels',
285
+ 'push',
286
+ '-p',
287
+ WORKSPACE_DIR,
288
+ '--accelerator',
289
+ 'NvidiaTeslaT4',
290
+ ],
291
+ {
292
+ detached: true,
293
+ env: { ...process.env, KAGGLE_CONFIG_DIR: KAGGLE_TMP_DIR },
294
+ }
295
+ );
296
+
297
+ kaggleProcess.on('error', (error) => {
298
+ console.error('Failed to spawn kaggle process:', error);
299
+ });
300
+
301
+ // cleanup dirs
302
+ setTimeout(() => {
303
+ try {
304
+ if (fs.existsSync(KAGGLE_TMP_DIR))
305
+ fs.rmSync(KAGGLE_TMP_DIR, { recursive: true, force: true });
306
+ if (fs.existsSync(WORKSPACE_DIR))
307
+ fs.rmSync(WORKSPACE_DIR, { recursive: true, force: true });
308
+ } catch (error) {
309
+ console.error('Cleanup failed:', error);
310
+ }
311
+ }, 30000); // wait for push
312
+
313
+ res.json({ success: true, status: 'waking', session_id: sessionId });
314
+ } catch (error) {
315
+ console.error('Kernel dispatch failed:', error);
316
+ res.status(500).json({ error: 'Kernel dispatch failed' });
317
+ }
318
+ };
319
+
320
+ async function downloadStem(
321
+ url: string,
322
+ id: string,
323
+ stemName: string
324
+ ): Promise<void> {
325
+ const dir = resolveWithin(STEMS_BASE_DIR, id);
326
+ if (!dir) throw new Error('Invalid stem path');
327
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
328
+ const localPath = path.join(dir, `${stemName}.wav`);
329
+ const writer = fs.createWriteStream(localPath);
330
+ const controller = new AbortController();
331
+ const timeoutId = setTimeout(() => controller.abort(), 300000);
332
+ try {
333
+ const response = await secureFetch(url, { signal: controller.signal });
334
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
335
+ if (!response.body) throw new Error('No response body');
336
+ const stream = Readable.fromWeb(
337
+ response.body as import('stream/web').ReadableStream
338
+ );
339
+ stream.pipe(writer);
340
+ return new Promise<void>((resolve, reject) => {
341
+ writer.on('finish', () => {
342
+ clearTimeout(timeoutId);
343
+ resolve();
344
+ });
345
+ writer.on('error', (error: Error) => {
346
+ clearTimeout(timeoutId);
347
+ reject(error);
348
+ });
349
+ });
350
+ } catch (error: unknown) {
351
+ clearTimeout(timeoutId);
352
+ throw error;
353
+ }
354
+ }
355
+
356
+ export const saveRemix = async (
357
+ req: Request<
358
+ Record<string, unknown>,
359
+ Record<string, unknown>,
360
+ {
361
+ id: string;
362
+ name: string;
363
+ stems: Record<string, string>;
364
+ chords: string[];
365
+ beats: number[];
366
+ tempo: number;
367
+ engine?: string;
368
+ }
369
+ >,
370
+ res: Response
371
+ ): Promise<void> => {
372
+ const { id, name, stems, chords, beats, tempo, engine } = req.body;
373
+ try {
374
+ const localStems: Record<string, string> = {};
375
+ const downloadTasks: Promise<void>[] = [];
376
+ for (const [key, url] of Object.entries(stems)) {
377
+ if (url) {
378
+ downloadTasks.push(
379
+ (async () => {
380
+ await downloadStem(url, id, key);
381
+ localStems[key] = `/api/remix/stems/${id}/${key}.wav`;
382
+ })()
383
+ );
384
+ }
385
+ }
386
+ await Promise.all(downloadTasks);
387
+ if (db) {
388
+ const database = db as {
389
+ execute: (options: {
390
+ sql: string;
391
+ args: (string | number)[];
392
+ }) => Promise<unknown>;
393
+ };
394
+ await database.execute({
395
+ sql: 'INSERT INTO remix_history (id, name, stems, chords, beats, tempo, engine, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
396
+ args: [
397
+ id,
398
+ name,
399
+ JSON.stringify(localStems),
400
+ JSON.stringify(chords),
401
+ JSON.stringify(beats),
402
+ tempo,
403
+ engine || 'Demucs',
404
+ Date.now(),
405
+ ],
406
+ });
407
+ }
408
+ res.json({ success: true, localStems });
409
+ } catch (error: unknown) {
410
+ console.error('[Save] Remix persistence failed:', error);
411
+ res.status(500).json({ error: 'Failed to persist remix data' });
412
+ }
413
+ };
414
+
415
+ export const serveStem = (req: Request, res: Response): void => {
416
+ const id = String(req.params.id);
417
+ const fileName = String(req.params.file);
418
+ const filePath = resolveWithin(STEMS_BASE_DIR, id, fileName);
419
+ if (!filePath) {
420
+ res.status(400).send('Invalid path');
421
+ return;
422
+ }
423
+ if (fs.existsSync(filePath)) {
424
+ const extension = path.extname(filePath).toLowerCase();
425
+ res.setHeader(
426
+ 'Content-Type',
427
+ extension === '.wav'
428
+ ? 'audio/wav'
429
+ : extension === '.ogg'
430
+ ? 'audio/ogg'
431
+ : 'audio/mpeg'
432
+ );
433
+ res.sendFile(filePath);
434
+ return;
435
+ }
436
+ res.status(404).send('Stem not found');
437
+ };
438
+
439
+ export const getHistory = async (
440
+ _req: Request,
441
+ res: Response
442
+ ): Promise<void> => {
443
+ if (!db) {
444
+ res.json([]);
445
+ return;
446
+ }
447
+ try {
448
+ const result = await (
449
+ db as unknown as {
450
+ execute(query: string): Promise<{
451
+ rows: {
452
+ id: number;
453
+ name: string;
454
+ stems: string;
455
+ chords: string;
456
+ beats: string;
457
+ tempo: number;
458
+ engine: string;
459
+ created_at: string;
460
+ }[];
461
+ }>;
462
+ }
463
+ ).execute('SELECT * FROM remix_history ORDER BY created_at DESC LIMIT 15');
464
+ const history = result.rows.map((row) => ({
465
+ id: row.id,
466
+ name: row.name,
467
+ stems: JSON.parse(row.stems),
468
+ chords: JSON.parse(row.chords),
469
+ beats: JSON.parse(row.beats),
470
+ tempo: row.tempo,
471
+ engine: row.engine,
472
+ date: new Date(row.created_at).toLocaleDateString(),
473
+ }));
474
+ res.json(history);
475
+ } catch (error) {
476
+ console.error('[History] Fetch failed:', error);
477
+ res.status(500).json({ error: 'Failed to fetch history' });
478
+ }
479
+ };
480
+
481
+ export const renameRemix = async (
482
+ req: Request,
483
+ res: Response
484
+ ): Promise<void> => {
485
+ const { id, name } = req.body as { id: string; name: string };
486
+ if (!db) {
487
+ res.status(500).json({ error: 'DB not initialized' });
488
+ return;
489
+ }
490
+ try {
491
+ await db.execute({
492
+ sql: 'UPDATE remix_history SET name = ? WHERE id = ?',
493
+ args: [name, id],
494
+ });
495
+ res.json({ success: true });
496
+ } catch (error) {
497
+ console.error('[Rename] Operation failed:', error);
498
+ res.status(500).json({ error: 'Failed to rename' });
499
+ }
500
+ };
501
+
502
+ export const deleteRemix = async (
503
+ req: Request,
504
+ res: Response
505
+ ): Promise<void> => {
506
+ const id = String(req.params.id);
507
+ if (!db) {
508
+ res.status(500).json({ error: 'DB not initialized' });
509
+ return;
510
+ }
511
+ try {
512
+ await db.execute({
513
+ sql: 'DELETE FROM remix_history WHERE id = ?',
514
+ args: [id],
515
+ });
516
+ const targetDir = resolveWithin(STEMS_BASE_DIR, id);
517
+ if (targetDir && fs.existsSync(targetDir))
518
+ fs.rmSync(targetDir, { recursive: true, force: true });
519
+ res.json({ success: true });
520
+ } catch (error: unknown) {
521
+ console.error('[Delete] Operation failed:', error);
522
+ res.status(500).json({ error: 'Failed to delete' });
523
+ }
524
+ };
525
+
526
+ export const exportRemix = async (
527
+ req: Request,
528
+ res: Response
529
+ ): Promise<void> => {
530
+ const id = String(req.params.id);
531
+ if (!db) {
532
+ res.status(500).json({ error: 'DB not initialized' });
533
+ return;
534
+ }
535
+ try {
536
+ const result = await (
537
+ db as unknown as {
538
+ execute: (options: { sql: string; args: string[] }) => Promise<{
539
+ rows: Array<{
540
+ id: string;
541
+ name: string;
542
+ stems: string;
543
+ chords: string;
544
+ beats: string;
545
+ tempo: number;
546
+ engine: string;
547
+ }>;
548
+ }>;
549
+ }
550
+ ).execute({ sql: 'SELECT * FROM remix_history WHERE id = ?', args: [id] });
551
+ if (result.rows.length === 0) {
552
+ res.status(404).send('Not found');
553
+ return;
554
+ }
555
+ const row = result.rows[0];
556
+ const targetDir = resolveWithin(STEMS_BASE_DIR, id);
557
+
558
+ if (!targetDir || !fs.existsSync(targetDir)) {
559
+ res.status(404).send('Project directory not found on server');
560
+ return;
561
+ }
562
+
563
+ const metadata = {
564
+ id: row.id,
565
+ name: row.name,
566
+ stems: JSON.parse(row.stems),
567
+ chords: JSON.parse(row.chords),
568
+ beats: JSON.parse(row.beats),
569
+ tempo: row.tempo,
570
+ engine: row.engine,
571
+ };
572
+
573
+ fs.writeFileSync(
574
+ path.join(targetDir, 'project.json'),
575
+ JSON.stringify(metadata, null, 2)
576
+ );
577
+
578
+ const safeName = (row.name || row.id).replace(/["\r\n]/gu, '_');
579
+ res.setHeader('Content-Type', 'application/zip');
580
+ res.setHeader(
581
+ 'Content-Disposition',
582
+ `attachment; filename="${safeName}.zip"`
583
+ );
584
+
585
+ const zipProcess = spawn('zip', ['-q', '-r', '-', '.'], {
586
+ cwd: targetDir,
587
+ detached: true,
588
+ });
589
+
590
+ req.on('close', () => {
591
+ if (zipProcess.pid) {
592
+ try {
593
+ process.kill(-zipProcess.pid, 'SIGKILL');
594
+ } catch (error) {
595
+ console.debug('[Export] Cleanup failed:', error);
596
+ }
597
+ }
598
+ });
599
+
600
+ zipProcess.stdout.pipe(res);
601
+
602
+ zipProcess.stderr.on('data', (data) => {
603
+ console.error(`zip stderr: ${data}`);
604
+ });
605
+
606
+ zipProcess.on('close', (code) => {
607
+ if (code !== 0) {
608
+ console.error(`zip process exited with code ${code}`);
609
+ if (!res.headersSent) res.status(500).send('Zip generation failed');
610
+ }
611
+ });
612
+ } catch (error) {
613
+ console.error('Export exception:', error);
614
+ if (!res.headersSent) res.status(500).send('Export failed');
615
+ }
616
+ };
617
+
618
+ export const extractRemix = async (
619
+ req: Request,
620
+ res: Response
621
+ ): Promise<void> => {
622
+ const id = String(req.params.id);
623
+ const projectDir = resolveWithin(STEMS_BASE_DIR, id);
624
+ if (!projectDir) {
625
+ res.status(400).json({ error: 'Invalid path' });
626
+ return;
627
+ }
628
+ const mixPath = path.join(projectDir, 'temp_mix.wav');
629
+ if (!fs.existsSync(mixPath)) {
630
+ const stemsToMix = ['vocals', 'drums', 'bass', 'other', 'guitar', 'piano']
631
+ .map((stemId) => path.join(projectDir, `${stemId}.wav`))
632
+ .filter((filePath) => fs.existsSync(filePath));
633
+ if (stemsToMix.length === 0) {
634
+ res.status(404).json({ error: 'Audio not found' });
635
+ return;
636
+ }
637
+ try {
638
+ await new Promise<void>((resolve, reject) => {
639
+ const ffmpegArgs: string[] = [];
640
+ stemsToMix.forEach((stemPath) => {
641
+ ffmpegArgs.push('-i', stemPath);
642
+ });
643
+ ffmpegArgs.push(
644
+ '-filter_complex',
645
+ `amix=inputs=${stemsToMix.length}:duration=longest`,
646
+ '-y',
647
+ mixPath
648
+ );
649
+ const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, { detached: true });
650
+
651
+ const cleanup = () => {
652
+ if (ffmpegProcess.pid) {
653
+ try {
654
+ process.kill(-ffmpegProcess.pid, 'SIGKILL');
655
+ } catch (error) {
656
+ console.debug('[Extract] Cleanup failed:', error);
657
+ }
658
+ }
659
+ };
660
+ req.on('close', cleanup);
661
+
662
+ ffmpegProcess.on('close', (code) => {
663
+ req.off('close', cleanup);
664
+ if (code === 0) {
665
+ resolve();
666
+ } else {
667
+ reject(new Error('FFmpeg failed'));
668
+ }
669
+ });
670
+ });
671
+ } catch (error: unknown) {
672
+ console.error('[Extract] Audio preparation failed:', error);
673
+ res.status(500).json({ error: 'Failed to prepare audio' });
674
+ return;
675
+ }
676
+ }
677
+ try {
678
+ type DbResult = { rows: { chords: string }[] };
679
+ type DbClient = {
680
+ execute(opts: { sql: string; args: string[] }): Promise<DbResult>;
681
+ };
682
+ const dbClient = db as unknown as DbClient;
683
+ let engineChords: string[] = [];
684
+ const dbResult = await dbClient.execute({
685
+ sql: 'SELECT chords FROM remix_history WHERE id = ?',
686
+ args: [id],
687
+ });
688
+ if (dbResult.rows.length > 0)
689
+ engineChords = JSON.parse(dbResult.rows[0].chords) as string[];
690
+ const songData = await extractSongData(
691
+ mixPath,
692
+ engineChords.map((chordStr) => ({
693
+ chord: String(chordStr),
694
+ is_passing: false,
695
+ }))
696
+ );
697
+ res.json(songData);
698
+ } catch (error: unknown) {
699
+ res
700
+ .status(500)
701
+ .json({ error: error instanceof Error ? error.message : String(error) });
702
+ }
703
+ };
backend/src/controllers/video.controller.ts ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Request, Response } from 'express';
2
+ import * as Sentry from '@sentry/node'; // skipcq: JS-C1003
3
+ import {
4
+ addClient,
5
+ removeClient,
6
+ sendEvent,
7
+ } from '../utils/network/sse.util.js';
8
+ import {
9
+ isSupportedUrl,
10
+ isValidSpotifyUrl,
11
+ decodeUrlIfNeeded,
12
+ } from '../utils/network/validation.util.js';
13
+ import { pipeWebStream } from '../utils/network/proxy.util.js';
14
+ import { verifyProxyParams } from '../utils/network/secrets.util.js';
15
+ import { recordFailure } from '../utils/infra/metrics.util.js';
16
+ import {
17
+ detectService,
18
+ getSanitizedFilename,
19
+ } from '../utils/media/video.util.js';
20
+ import { prepareFinalResponse } from '../utils/api/response.util.js';
21
+ import { getCookieArgs, initializeSession } from '../utils/api/controller.util.js';
22
+ import {
23
+ processBackgroundTracks,
24
+ resolveSeedTracks,
25
+ type SeedTrack,
26
+ } from '../services/seeder.service.js';
27
+ import {
28
+ fetchMediaInfo,
29
+ handleSpotifyRegistry,
30
+ } from '../services/video/info.js';
31
+ import {
32
+ parseRequestParams,
33
+ resolveManifests,
34
+ buildStreamResponse,
35
+ } from '../services/video/stream-resolver.js';
36
+ import {
37
+ executeDownload,
38
+ streamViaYtdlp,
39
+ } from '../services/video/download.js';
40
+ import { SpotifyMetadata } from '../types/index.js';
41
+
42
+ export const streamEvents = async (
43
+ req: Request,
44
+ res: Response
45
+ ): Promise<void> => {
46
+ const id = (req.query.id as string) || undefined;
47
+ console.log(`[SSE] Client connecting: ${id}`);
48
+ if (!id) {
49
+ res.status(400).end();
50
+ return;
51
+ }
52
+ await addClient(id, res);
53
+ };
54
+
55
+ export const getVideoInformation = async (
56
+ req: Request,
57
+ res: Response
58
+ ): Promise<void> => {
59
+ res.setHeader(
60
+ 'Cache-Control',
61
+ 'no-store, no-cache, must-revalidate, proxy-revalidate'
62
+ );
63
+ let videoURL = decodeUrlIfNeeded(req.query.url as string);
64
+ const clientId = (req.query.id as string) || undefined;
65
+
66
+ if (videoURL) {
67
+ videoURL = videoURL.split('&id=')[0].split('?id=')[0];
68
+ }
69
+
70
+ if (!videoURL || !isSupportedUrl(videoURL)) {
71
+ res.status(400).json({ error: 'No valid URL provided' });
72
+ return;
73
+ }
74
+
75
+ const serviceName = detectService(videoURL);
76
+ await initializeSession(clientId);
77
+
78
+ try {
79
+ const cookieArgs = await getCookieArgs(videoURL, clientId);
80
+ const isSpotify = videoURL.includes('spotify.com');
81
+
82
+ const info = await fetchMediaInfo(
83
+ videoURL,
84
+ clientId,
85
+ serviceName,
86
+ cookieArgs
87
+ );
88
+
89
+ // fast partial hit
90
+ if (!info || (!info.formats?.length && !info.isPartial)) {
91
+ res.json({
92
+ title: info?.title || 'Unknown',
93
+ thumbnail: info?.thumbnail || '',
94
+ formats: [],
95
+ audioFormats: [],
96
+ });
97
+ return;
98
+ }
99
+
100
+ const spotifyData = isSpotify
101
+ ? ({ ...info, type: 'spotify' } as unknown as SpotifyMetadata)
102
+ : null;
103
+ const targetURL = isSpotify ? info.targetUrl || videoURL : videoURL;
104
+
105
+ const finalResponse = await prepareFinalResponse(
106
+ info,
107
+ isSpotify,
108
+ spotifyData,
109
+ videoURL
110
+ );
111
+ if (isSpotify) {
112
+ handleSpotifyRegistry(info, finalResponse, videoURL, targetURL);
113
+ }
114
+
115
+ res.json(finalResponse);
116
+ } catch (error: unknown) {
117
+ recordFailure('info');
118
+ console.error('[VideoInfo] Error:', (error as Error).message);
119
+ Sentry.captureException(error);
120
+ if (clientId) removeClient(clientId);
121
+ if (!res.headersSent)
122
+ res.status(500).json({ error: 'Failed to fetch video info' });
123
+ }
124
+ };
125
+
126
+ export const getStreamUrls = async (
127
+ req: Request,
128
+ res: Response
129
+ ): Promise<void> => {
130
+ const { videoURL, clientId, formatId } = parseRequestParams(req);
131
+ const timestamp = new Date().toLocaleTimeString('en-US', {
132
+ hour12: true,
133
+ hour: 'numeric',
134
+ minute: '2-digit',
135
+ second: '2-digit',
136
+ });
137
+
138
+ if (!videoURL || !isSupportedUrl(videoURL)) {
139
+ res.status(400).json({ error: 'No valid URL provided' });
140
+ return;
141
+ }
142
+
143
+ console.log(`[${timestamp}] [EME] Resolving manifests for Edge Muxing...`);
144
+
145
+ try {
146
+ const {
147
+ info,
148
+ videoTunnel,
149
+ audioTunnel,
150
+ isAudioOnly,
151
+ filename,
152
+ totalSize,
153
+ outputMeta,
154
+ } = await resolveManifests(req, videoURL, clientId, formatId);
155
+ res.json(
156
+ buildStreamResponse(
157
+ info,
158
+ videoTunnel,
159
+ audioTunnel,
160
+ isAudioOnly,
161
+ filename,
162
+ totalSize,
163
+ outputMeta as Record<string, unknown>
164
+ )
165
+ );
166
+ } catch (error: unknown) {
167
+ recordFailure('stream_urls');
168
+ console.error('[StreamUrls] Error:', (error as Error).message);
169
+ Sentry.captureException(error);
170
+ if (!res.headersSent)
171
+ res.status(500).json({ error: 'Failed to resolve stream URLs' });
172
+ }
173
+ };
174
+
175
+ export const proxyStream = async (
176
+ req: Request,
177
+ res: Response
178
+ ): Promise<void> => {
179
+ const queryData = req.query as Record<string, string | string[] | undefined>;
180
+ let { targetUrl, formatId, url: rawFallbackUrl, filename } = queryData;
181
+ if (Array.isArray(targetUrl)) targetUrl = targetUrl[0];
182
+ if (Array.isArray(formatId)) formatId = formatId[0];
183
+ if (Array.isArray(rawFallbackUrl)) rawFallbackUrl = rawFallbackUrl[0];
184
+ if (Array.isArray(filename)) filename = filename[0];
185
+
186
+ let rawUrl = queryData.rawUrl;
187
+ if (Array.isArray(rawUrl)) rawUrl = rawUrl[0];
188
+
189
+ // refuse forged or expired signed links
190
+ if (
191
+ !verifyProxyParams({
192
+ targetUrl: targetUrl as string | undefined,
193
+ rawUrl: rawUrl as string | undefined,
194
+ formatId: formatId as string | undefined,
195
+ exp: Number(req.query.exp),
196
+ sig: req.query.sig as string | undefined,
197
+ })
198
+ ) {
199
+ res.status(403).json({ error: 'Invalid or expired proxy signature' });
200
+ return;
201
+ }
202
+
203
+ const urlToFetch = rawFallbackUrl || (req.query.rawUrl as string);
204
+
205
+ if (urlToFetch) {
206
+ const abortController = new AbortController();
207
+ req.on('close', () => abortController.abort());
208
+ try {
209
+ await pipeWebStream(
210
+ urlToFetch,
211
+ res,
212
+ filename as string,
213
+ req.headers as unknown as Record<string, string | undefined>,
214
+ 0,
215
+ abortController.signal
216
+ );
217
+ return;
218
+ } catch (error: unknown) {
219
+ recordFailure('proxy');
220
+ console.error('[Proxy] Raw Pipe Error:', (error as Error).message);
221
+ Sentry.captureException(error);
222
+ if (!res.headersSent)
223
+ res.status(500).json({ error: 'Proxy fetch failed' });
224
+ res.end();
225
+ return;
226
+ }
227
+ }
228
+
229
+ if (targetUrl && formatId) {
230
+ await streamViaYtdlp(req, res, targetUrl, formatId, filename);
231
+ return;
232
+ }
233
+
234
+ res.status(400).end();
235
+ };
236
+
237
+ export const reportTelemetry = (req: Request, res: Response): void => {
238
+ const { event } = req.body;
239
+ const timestamp = new Date().toLocaleTimeString('en-US', {
240
+ hour12: true,
241
+ hour: 'numeric',
242
+ minute: '2-digit',
243
+ second: '2-digit',
244
+ });
245
+ const safeEvent = String(event || 'unknown').replaceAll(/[^\w]/gu, '_');
246
+ console.log(`[${timestamp}] [EME] Client-Side Handshake: ${safeEvent}`);
247
+ res.status(204).end();
248
+ };
249
+
250
+ export const convertVideo = (req: Request, res: Response): void => {
251
+ if (req.method === 'HEAD') {
252
+ res.status(200).end();
253
+ return;
254
+ }
255
+ res.setHeader(
256
+ 'Cache-Control',
257
+ 'no-store, no-cache, must-revalidate, proxy-revalidate'
258
+ );
259
+
260
+ const requestData = { ...req.query, ...req.body } as {
261
+ url?: string;
262
+ id?: string;
263
+ format?: string;
264
+ formatId?: string;
265
+ token?: string;
266
+ title?: string;
267
+ artist?: string;
268
+ targetUrl?: string;
269
+ };
270
+
271
+ const {
272
+ url: videoURL,
273
+ id: clientIdStr,
274
+ format = 'mp4',
275
+ formatId,
276
+ } = requestData;
277
+ const clientId = clientIdStr || undefined;
278
+
279
+ console.log(`[Convert] Request received for: ${videoURL} (ID: ${clientId})`);
280
+
281
+ if (!videoURL || !isSupportedUrl(videoURL)) {
282
+ console.warn(`[Convert] Invalid URL: ${videoURL}`);
283
+ res.status(400).json({ error: 'No valid URL provided' });
284
+ return;
285
+ }
286
+
287
+ const token = requestData.token || clientId;
288
+ if (token) {
289
+ res.setHeader('Set-Cookie', `download_token=${token}; Path=/; Max-Age=60`);
290
+ }
291
+
292
+ const isSpotifyRequest = videoURL.includes('spotify.com');
293
+ const timestamp = new Date().toLocaleTimeString('en-US', {
294
+ hour12: true,
295
+ hour: 'numeric',
296
+ minute: '2-digit',
297
+ second: '2-digit',
298
+ });
299
+
300
+ let finalFormat = format;
301
+ if (formatId?.startsWith('photo')) finalFormat = 'jpg';
302
+
303
+ const filename = getSanitizedFilename(
304
+ requestData.title || 'video',
305
+ requestData.artist,
306
+ finalFormat,
307
+ isSpotifyRequest
308
+ );
309
+
310
+ console.log(
311
+ `[${timestamp}] [Turbo] Starting Server-Side muxing for: ${filename}`
312
+ );
313
+
314
+ if (clientId) {
315
+ sendEvent(clientId, {
316
+ status: 'initializing',
317
+ progress: 5,
318
+ subStatus: 'syncing core...',
319
+ text: 'initiating jump',
320
+ });
321
+ }
322
+
323
+ (async () => {
324
+ const result = await executeDownload(
325
+ res,
326
+ clientId,
327
+ videoURL as string,
328
+ requestData,
329
+ timestamp,
330
+ filename,
331
+ format,
332
+ formatId,
333
+ isSpotifyRequest
334
+ );
335
+ if (result) {
336
+ const { videoProcess, hardTimeoutId } = result;
337
+ req.on('close', () => {
338
+ clearTimeout(hardTimeoutId);
339
+ if (typeof videoProcess.kill === 'function') {
340
+ console.log(
341
+ `[${timestamp}] [Turbo] Client disconnected. Cleaning up stream for: ${clientId}`
342
+ );
343
+ videoProcess.kill();
344
+ }
345
+ if (!res.writableEnded) res.end();
346
+ });
347
+ }
348
+ })().catch((error) => {
349
+ console.error(
350
+ `[${timestamp}] [ConvertVideo] Unhandled exception in download workflow:`,
351
+ error
352
+ );
353
+ Sentry.captureException(error);
354
+ if (!res.headersSent)
355
+ res
356
+ .status(500)
357
+ .json({ error: 'Internal server error during processing' });
358
+ if (!res.writableEnded) res.end();
359
+ });
360
+ };
361
+
362
+ export const seedIntelligence = async (
363
+ req: Request,
364
+ res: Response
365
+ ): Promise<void> => {
366
+ const { url, id: clientIdStr } = req.query as { url: string; id: string };
367
+ const clientId = clientIdStr || 'admin-seeder';
368
+ if (!url || !isValidSpotifyUrl(url)) {
369
+ res
370
+ .status(400)
371
+ .json({ error: 'Invalid Spotify Artist/Album URL provided' });
372
+ return;
373
+ }
374
+
375
+ try {
376
+ const tracks = await resolveSeedTracks(url);
377
+
378
+ if (!tracks || tracks.length === 0) throw new Error('No tracks found.');
379
+
380
+ res.json({
381
+ message: 'Intelligence Gathering Started in Background',
382
+ trackCount: tracks.length,
383
+ target: url,
384
+ });
385
+ processBackgroundTracks(tracks as SeedTrack[], clientId).catch(
386
+ (error: Error) => {
387
+ console.error('[Seeder] Background Process Crashed:', error.message);
388
+ Sentry.captureException(error);
389
+ }
390
+ );
391
+ } catch (error: unknown) {
392
+ if (!res.headersSent) {
393
+ Sentry.captureException(error);
394
+ res
395
+ .status(500)
396
+ .json({
397
+ error: (error as Error).message || 'An unknown error occurred',
398
+ });
399
+ }
400
+ }
401
+ };
backend/src/instrument.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as Sentry from '@sentry/node'; // skipcq: JS-C1003
2
+
3
+ type ProfilingIntegration = ReturnType<
4
+ (typeof import('@sentry/profiling-node'))['nodeProfilingIntegration']
5
+ >;
6
+
7
+ // skip profiler if native binding missing
8
+ const integrations: ProfilingIntegration[] = [];
9
+ try {
10
+ const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
11
+ integrations.push(nodeProfilingIntegration());
12
+ } catch (error) {
13
+ console.warn(
14
+ '[Sentry] CPU profiler unavailable:',
15
+ error instanceof Error ? error.message : String(error)
16
+ );
17
+ }
18
+
19
+ Sentry.init({
20
+ dsn: process.env.SENTRY_DSN,
21
+ integrations,
22
+ tracesSampleRate: 0.1,
23
+ profilesSampleRate: 0.1,
24
+ sendDefaultPii: false,
25
+ });
26
+
27
+ process.on('uncaughtException', async (error) => {
28
+ console.error('[Uncaught Exception]', error);
29
+ Sentry.captureException(error);
30
+ await Sentry.close(2000);
31
+ throw error;
32
+ });
33
+
34
+ process.on('unhandledRejection', (reason) => {
35
+ console.error('[Unhandled Rejection]', reason);
36
+ Sentry.captureException(reason);
37
+ });
backend/src/routes/keychanger.routes.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import {
3
+ upload,
4
+ detectKey,
5
+ detectProcessedKey,
6
+ convertKey,
7
+ downloadFile,
8
+ } from '../controllers/keychanger.controller.js';
9
+
10
+ const router = Router();
11
+
12
+ router.post('/detect', upload.single('song'), detectKey);
13
+ router.get('/detect-processed/:filename', detectProcessedKey);
14
+ router.post('/convert', upload.single('song'), convertKey);
15
+ router.get('/download/:filename', downloadFile);
16
+
17
+ export default router;
backend/src/routes/remix.routes.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import multer from 'multer';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import * as remixController from '../controllers/remix.controller.js';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ const router = express.Router();
11
+
12
+ const upload = multer({
13
+ dest: path.join(__dirname, '../../temp/uploads'),
14
+ limits: { fileSize: 100 * 1024 * 1024 }, // 100mb limit
15
+ });
16
+
17
+ router.post('/register-engine', remixController.registerEngine);
18
+ router.get('/engine-status', remixController.getEngineStatus);
19
+ router.post('/process', upload.single('file'), remixController.processAudio);
20
+ router.post('/wake-engine', remixController.wakeEngine);
21
+ router.post('/save', remixController.saveRemix);
22
+ router.get('/stems/:id/:file', remixController.serveStem);
23
+ router.get('/history', remixController.getHistory);
24
+ router.post('/rename', remixController.renameRemix);
25
+ router.delete('/delete/:id', remixController.deleteRemix);
26
+ router.get('/export/:id', remixController.exportRemix);
27
+ router.get('/extract/:id', remixController.extractRemix);
28
+
29
+ export default router;
backend/src/routes/video.routes.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import {
3
+ streamEvents,
4
+ getVideoInformation,
5
+ getStreamUrls,
6
+ reportTelemetry,
7
+ convertVideo,
8
+ proxyStream,
9
+ seedIntelligence,
10
+ } from '../controllers/video.controller.js';
11
+ import {
12
+ concurrencyGuard,
13
+ globalMediaGuard,
14
+ } from '../utils/network/security.util.js';
15
+
16
+ const router = Router();
17
+
18
+ router.get('/events', streamEvents);
19
+ router.get('/info', getVideoInformation);
20
+ router.get('/stream-urls', getStreamUrls);
21
+ router.post('/telemetry', reportTelemetry);
22
+ router.all('/convert', globalMediaGuard(), concurrencyGuard(2), convertVideo);
23
+ router.get('/proxy', globalMediaGuard(), concurrencyGuard(2), proxyStream);
24
+ router.get('/seed-intelligence', seedIntelligence);
25
+
26
+ export default router;
backend/src/services/extract.service.ts ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fpcalc from 'fpcalc';
2
+ import * as Sentry from '@sentry/node'; // skipcq: JS-C1003
3
+ import { Shazam } from 'node-shazam';
4
+ import { getUgChords } from './ug-grounding.service.js';
5
+ import { z } from 'zod';
6
+ import { secureFetch } from '../utils/network/security.util.js';
7
+
8
+ const ACOUSTID_API_KEY = 'vdzQhu1sWI';
9
+
10
+ const AcoustidResponseSchema = z
11
+ .object({
12
+ results: z
13
+ .array(
14
+ z.object({
15
+ recordings: z
16
+ .array(
17
+ z.object({
18
+ id: z.string(),
19
+ })
20
+ )
21
+ .optional(),
22
+ })
23
+ )
24
+ .optional(),
25
+ })
26
+ .catchall(z.unknown());
27
+
28
+ const MusicBrainzResponseSchema = z
29
+ .object({
30
+ isrcs: z.array(z.string()).optional(),
31
+ })
32
+ .catchall(z.unknown());
33
+
34
+ const DeezerResponseSchema = z
35
+ .object({
36
+ error: z
37
+ .object({
38
+ type: z.string(),
39
+ message: z.string(),
40
+ code: z.number(),
41
+ })
42
+ .optional(),
43
+ artist: z.object({ name: z.string() }).optional(),
44
+ title: z.string().optional(),
45
+ })
46
+ .catchall(z.unknown());
47
+
48
+ type LyricsData = {
49
+ plainLyrics?: string;
50
+ syncedLyrics?: string;
51
+ [key: string]: unknown;
52
+ };
53
+
54
+ type SongData = {
55
+ artist: string;
56
+ title: string;
57
+ isrc: string | null;
58
+ lyrics: string | null;
59
+ chordsSheet: string | null;
60
+ chordsLink: string;
61
+ grounded: boolean;
62
+ };
63
+
64
+ async function getGeminiChords(
65
+ artist: string,
66
+ title: string,
67
+ syncedLyrics: string | null,
68
+ engineChords: { is_passing: boolean; time: number; chord: string }[]
69
+ ): Promise<string | null> {
70
+ const apiKey = process.env.GEMINI_API_KEY || process.env.VERTEX_API_KEY;
71
+ if (!apiKey) return null;
72
+
73
+ try {
74
+ const { GoogleGenAI } = await import('@google/genai');
75
+
76
+ interface IGoogleGenAI {
77
+ getGenerativeModel: (options: { model: string }) => {
78
+ generateContent: (prompt: string) => Promise<{
79
+ response: { text: () => string };
80
+ }>;
81
+ };
82
+ }
83
+
84
+ type IGoogleGenAIConstructor = new (key: string) => IGoogleGenAI;
85
+
86
+ const GenAIClass = GoogleGenAI as unknown as IGoogleGenAIConstructor;
87
+ const genAIInstance = new GenAIClass(apiKey);
88
+
89
+ let prompt = `Act as an expert music transcriber. Your task is to merge raw audio-extracted chords with synchronized lyrics to create a highly accurate Ultimate-Guitar style chord sheet.\n\nSong: "${title}" by "${artist}"\n\n`;
90
+
91
+ if (syncedLyrics && engineChords?.length > 0) {
92
+ prompt += `Here are the timestamped lyrics (in [mm:ss.xx] format):\n${syncedLyrics}\n\n`;
93
+
94
+ const chordsSummary = engineChords
95
+ .filter((chord) => !chord.is_passing)
96
+ .map((chord) => `[${formatTime(chord.time)}] ${chord.chord}`)
97
+ .join('\n');
98
+
99
+ prompt += `Here are the exact chords detected in the audio by our engine at specific timestamps:\n${chordsSummary}\n\n`;
100
+ prompt +=
101
+ 'CRITICAL INSTRUCTIONS:\n1. You MUST format this EXACTLY like an Ultimate Guitar text file.\n2. Chords MUST be placed on their own dedicated line directly ABOVE the lyrics, NOT inline with the text.\n3. Example of correct formatting:\n[ch]C[/ch] [ch]G[/ch]\nLet it be, let it be\n4. You MUST wrap every single chord in [ch] brackets (e.g., [ch]Am7[/ch]).\n5. Use the timestamps provided to figure out exactly which word the chord falls on, and space the chords out on the top line accordingly.\n6. Ignore long repetitive blocks of chords at the end (outros) if there are no lyrics. Keep it clean.\n7. ONLY output the final formatted chord sheet text. No markdown blocks, no conversational text.';
102
+ } else {
103
+ prompt +=
104
+ 'Since exact audio data is missing, return the most highly-rated guitar chords and lyrics available for this song. \nCRITICAL: Chords MUST be placed on their own line directly ABOVE the lyrics. Wrap every single chord in [ch] brackets (e.g., [ch]Am7[/ch]).\nProvide ONLY the song text with chords. No markdown code blocks or extra text.';
105
+ }
106
+
107
+ const model = genAIInstance.getGenerativeModel({
108
+ model: 'gemini-3-flash-preview',
109
+ });
110
+ const result = await model.generateContent(prompt);
111
+ const text = result.response.text();
112
+ return text || null;
113
+ } catch (error: unknown) {
114
+ const errorObj = error as Error;
115
+ console.error('Gemini Chords Error:', errorObj.message);
116
+ Sentry.captureException(error);
117
+ return null;
118
+ }
119
+ }
120
+
121
+ function formatTime(seconds: number): string {
122
+ const minutes = Math.floor(seconds / 60);
123
+ const remainingSeconds = (seconds % 60).toFixed(2);
124
+ return `${minutes < 10 ? '0' : ''}${minutes}:${remainingSeconds.padStart(5, '0')}`;
125
+ }
126
+
127
+ async function getLyrics(
128
+ artist: string,
129
+ title: string
130
+ ): Promise<LyricsData | null> {
131
+ const cleanTitle = title.split(/[([]/u)[0].trim();
132
+
133
+ const fetchExact = async (trackName: string): Promise<LyricsData | null> => {
134
+ try {
135
+ const url = `https://lrclib.net/api/get?artist_name=${encodeURIComponent(artist)}&track_name=${encodeURIComponent(trackName)}`;
136
+ const response = await secureFetch(url);
137
+ if (!response.ok) return null;
138
+ const json = (await response.json()) as LyricsData;
139
+ return json;
140
+ } catch (error) {
141
+ console.debug('[ExtractService] lyrics fetch exact failed:', error);
142
+ return null;
143
+ }
144
+ };
145
+
146
+ const fetchSearch = async (trackName: string): Promise<LyricsData | null> => {
147
+ try {
148
+ const query = encodeURIComponent(`${artist} ${trackName}`);
149
+ const url = `https://lrclib.net/api/search?q=${query}`;
150
+ const response = await secureFetch(url);
151
+ if (!response.ok) return null;
152
+ const data = (await response.json()) as LyricsData[];
153
+ if (data.length > 0) {
154
+ return (
155
+ data.find(
156
+ (result) =>
157
+ result.plainLyrics !== undefined ||
158
+ result.syncedLyrics !== undefined
159
+ ) || null
160
+ );
161
+ }
162
+ } catch (error) {
163
+ console.debug('[ExtractService] lyrics fetch search failed:', error);
164
+ return null;
165
+ }
166
+ return null;
167
+ };
168
+
169
+ let data = await fetchExact(title);
170
+ if (data) return data;
171
+ if (title !== cleanTitle) {
172
+ data = await fetchExact(cleanTitle);
173
+ if (data) return data;
174
+ }
175
+ data = await fetchSearch(title);
176
+ if (data) return data;
177
+ if (title !== cleanTitle) {
178
+ data = await fetchSearch(cleanTitle);
179
+ if (data) return data;
180
+ }
181
+ return null;
182
+ }
183
+
184
+ async function processSong(
185
+ artist: string,
186
+ title: string,
187
+ isrc: string | null,
188
+ engineChords: Array<{ chord: string; is_passing: boolean; time?: number }>
189
+ ): Promise<SongData> {
190
+ const lrclibData = await getLyrics(artist, title);
191
+ const plainLyrics = lrclibData?.plainLyrics || null;
192
+ const syncedLyrics = lrclibData?.syncedLyrics || null;
193
+
194
+ const groundingRes = await getUgChords(artist, title);
195
+ let chordsSheet = groundingRes?.chordsSheet || null;
196
+ const usedGrounding = Boolean(chordsSheet);
197
+
198
+ if (!chordsSheet) {
199
+ const validChords = engineChords.filter(
200
+ (chord): chord is { chord: string; is_passing: boolean; time: number } =>
201
+ typeof chord.time === 'number'
202
+ );
203
+ chordsSheet = await getGeminiChords(
204
+ artist,
205
+ title,
206
+ syncedLyrics,
207
+ validChords
208
+ );
209
+ }
210
+
211
+ const cleanTitle = title.split('(')[0].trim();
212
+ const query = `${artist} ${cleanTitle}`;
213
+ const ugLink = `https://www.ultimate-guitar.com/search.php?search_type=title&value=${encodeURIComponent(query)}`;
214
+
215
+ return {
216
+ artist,
217
+ title,
218
+ isrc: isrc || null,
219
+ lyrics: plainLyrics,
220
+ chordsSheet,
221
+ chordsLink: ugLink,
222
+ grounded: usedGrounding,
223
+ };
224
+ }
225
+
226
+ async function fallbackToShazam(
227
+ filePath: string,
228
+ engineChords: Array<{ chord: string; is_passing: boolean }>
229
+ ): Promise<SongData> {
230
+ try {
231
+ const shazam = new Shazam();
232
+ const response = (await shazam.recognise(filePath, 'en-US')) as {
233
+ track?: { subtitle: string; title: string; isrc: string };
234
+ };
235
+ if (response?.track) {
236
+ const track = response.track;
237
+
238
+ const artist = track.subtitle;
239
+ const title = track.title;
240
+ const isrc = track.isrc;
241
+ if (artist && title)
242
+ return await processSong(artist, title, isrc, engineChords);
243
+ }
244
+ throw new Error('Shazam failed');
245
+ } catch (error: unknown) {
246
+ const err = error as Error;
247
+ throw new Error(`Shazam error: ${err.message}`, { cause: error });
248
+ }
249
+ }
250
+
251
+ export function extractSongData(
252
+ filePath: string,
253
+ engineChords: Array<{ chord: string; is_passing: boolean }> = []
254
+ ): Promise<SongData> {
255
+ return new Promise((resolve, reject) => {
256
+ fpcalc(
257
+ filePath,
258
+ async (
259
+ error: Error | null,
260
+ result: { fingerprint: string; duration: number } | undefined
261
+ ) => {
262
+ if (error || !result) {
263
+ try {
264
+ const fallbackResult = await fallbackToShazam(
265
+ filePath,
266
+ engineChords
267
+ );
268
+ resolve(fallbackResult);
269
+ return;
270
+ } catch (fallbackErr) {
271
+ reject(fallbackErr);
272
+ return;
273
+ }
274
+ }
275
+
276
+ const acoustidUrl = `https://api.acoustid.org/v2/lookup?client=${ACOUSTID_API_KEY}&meta=recordingids&fingerprint=${result.fingerprint}&duration=${result.duration}`;
277
+
278
+ try {
279
+ const response = await secureFetch(acoustidUrl);
280
+ const rawAcoustidData = await response.json();
281
+ const parsedAcoustid =
282
+ AcoustidResponseSchema.safeParse(rawAcoustidData);
283
+ if (!parsedAcoustid.success) {
284
+ console.debug(
285
+ '[ExtractService] Acoustid validation failed:',
286
+ parsedAcoustid.error.message
287
+ );
288
+ const fallbackResult = await fallbackToShazam(
289
+ filePath,
290
+ engineChords
291
+ );
292
+ resolve(fallbackResult);
293
+ return;
294
+ }
295
+ const data = parsedAcoustid.data;
296
+ const recording = data.results?.[0]?.recordings?.[0];
297
+
298
+ if (!recording) {
299
+ const fallbackResult = await fallbackToShazam(
300
+ filePath,
301
+ engineChords
302
+ );
303
+ resolve(fallbackResult);
304
+ return;
305
+ }
306
+
307
+ const mbid = recording.id;
308
+ const mbUrl = `https://musicbrainz.org/ws/2/recording/${mbid}?inc=isrcs&fmt=json`;
309
+ const mbRes = await secureFetch(mbUrl, {
310
+ headers: { 'User-Agent': 'ISRC_Finder/1.0' },
311
+ });
312
+ const rawMbData = await mbRes.json();
313
+ const parsedMb = MusicBrainzResponseSchema.safeParse(rawMbData);
314
+ if (!parsedMb.success) {
315
+ console.debug(
316
+ '[ExtractService] MusicBrainz validation failed:',
317
+ parsedMb.error.message
318
+ );
319
+ const fallbackResult = await fallbackToShazam(
320
+ filePath,
321
+ engineChords
322
+ );
323
+ resolve(fallbackResult);
324
+ return;
325
+ }
326
+ const mbData = parsedMb.data;
327
+ const isrc = mbData.isrcs?.[0];
328
+
329
+ if (!isrc) {
330
+ const fallbackResult = await fallbackToShazam(
331
+ filePath,
332
+ engineChords
333
+ );
334
+ resolve(fallbackResult);
335
+ return;
336
+ }
337
+
338
+ const deezerRes = await secureFetch(
339
+ `https://api.deezer.com/track/isrc:${isrc}`
340
+ );
341
+ const rawDeezerData = await deezerRes.json();
342
+ const parsedDeezer = DeezerResponseSchema.safeParse(rawDeezerData);
343
+ if (!parsedDeezer.success) {
344
+ console.debug(
345
+ '[ExtractService] Deezer validation failed:',
346
+ parsedDeezer.error.message
347
+ );
348
+ const fallbackResult = await fallbackToShazam(
349
+ filePath,
350
+ engineChords
351
+ );
352
+ resolve(fallbackResult);
353
+ return;
354
+ }
355
+ const deezerData = parsedDeezer.data;
356
+ if (deezerData.error || !deezerData.artist || !deezerData.title) {
357
+ const fallbackResult = await fallbackToShazam(
358
+ filePath,
359
+ engineChords
360
+ );
361
+ resolve(fallbackResult);
362
+ return;
363
+ }
364
+
365
+ const finalResult = await processSong(
366
+ deezerData.artist.name,
367
+ deezerData.title,
368
+ isrc,
369
+ engineChords
370
+ );
371
+ resolve(finalResult);
372
+ return;
373
+ } catch (error) {
374
+ console.debug('[ExtractService] Acoustid match failed, falling back:', error);
375
+ try {
376
+ const fallbackResult = await fallbackToShazam(
377
+ filePath,
378
+ engineChords
379
+ );
380
+ resolve(fallbackResult);
381
+ return;
382
+ } catch (fallbackErr) {
383
+ reject(fallbackErr);
384
+ return;
385
+ }
386
+ }
387
+ }
388
+ );
389
+ });
390
+ }
backend/src/services/extractors/bluesky.ts ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { secureFetch } from '../../utils/network/security.util.js';
2
+ import { getQuantumStream } from '../../utils/network/proxy.util.js';
3
+ import { VideoInfo, Format, ExtractorOptions } from '../../types/index.js';
4
+ import { Readable } from 'node:stream';
5
+ import { normalizeTitle, normalizeArtist } from '../social.service.js';
6
+
7
+ const APPVIEW = 'https://public.api.bsky.app/xrpc';
8
+
9
+ interface BlobRef {
10
+ ref?: { $link?: string };
11
+ size?: number;
12
+ }
13
+ interface AspectRatio {
14
+ width?: number;
15
+ height?: number;
16
+ }
17
+ interface BskyEmbed {
18
+ video?: BlobRef;
19
+ aspectRatio?: AspectRatio;
20
+ media?: { video?: BlobRef; aspectRatio?: AspectRatio };
21
+ }
22
+ interface BskyView {
23
+ thumbnail?: string;
24
+ aspectRatio?: AspectRatio;
25
+ media?: { thumbnail?: string; aspectRatio?: AspectRatio };
26
+ }
27
+ interface BskyPost {
28
+ record?: { text?: string; embed?: BskyEmbed };
29
+ embed?: BskyView;
30
+ author?: { displayName?: string; handle?: string };
31
+ }
32
+ interface VideoData {
33
+ cid: string;
34
+ size?: number;
35
+ width?: number;
36
+ height?: number;
37
+ thumbnail?: string;
38
+ }
39
+
40
+ async function fetchJson<T>(url: string): Promise<T | null> {
41
+ const res = await secureFetch(url);
42
+ if (!res.ok) return null;
43
+ return (await res.json()) as T;
44
+ }
45
+
46
+ // resolve the user's PDS from DID
47
+ async function resolvePds(did: string): Promise<string | null> {
48
+ let docUrl: string | null = null;
49
+ if (did.startsWith('did:plc:')) docUrl = `https://plc.directory/${did}`;
50
+ else if (did.startsWith('did:web:'))
51
+ docUrl = `https://${did.slice(8).replace(/:/gu, '/')}/.well-known/did.json`;
52
+ if (!docUrl) return null;
53
+
54
+ const doc = await fetchJson<{
55
+ service?: { type?: string; serviceEndpoint?: string }[];
56
+ }>(docUrl);
57
+ const svc = (doc?.service || []).find(
58
+ (entry) => entry.type === 'AtprotoPersonalDataServer'
59
+ );
60
+ return svc?.serviceEndpoint ?? null;
61
+ }
62
+
63
+ function pickVideo(post: BskyPost | undefined): VideoData | null {
64
+ const embed = post?.record?.embed;
65
+ const blob = embed?.video ?? embed?.media?.video;
66
+ const cid = blob?.ref?.$link;
67
+ if (!cid) return null;
68
+
69
+ const aspect =
70
+ embed?.aspectRatio ??
71
+ embed?.media?.aspectRatio ??
72
+ post?.embed?.aspectRatio ??
73
+ post?.embed?.media?.aspectRatio;
74
+ return {
75
+ cid,
76
+ size: blob?.size,
77
+ width: aspect?.width,
78
+ height: aspect?.height,
79
+ thumbnail: post?.embed?.thumbnail ?? post?.embed?.media?.thumbnail,
80
+ };
81
+ }
82
+
83
+ function buildFormat(blobUrl: string, video: VideoData): Format {
84
+ const { width, height } = video;
85
+ const short = width && height ? Math.min(width, height) : undefined;
86
+ return {
87
+ formatId: short ? `${short}p` : 'source',
88
+ url: blobUrl,
89
+ extension: 'mp4',
90
+ width,
91
+ height,
92
+ resolution: width && height ? `${width}x${height}` : undefined,
93
+ quality: short ? `${short}p` : 'Source',
94
+ vcodec: 'h264',
95
+ acodec: 'aac',
96
+ filesize: typeof video.size === 'number' ? video.size : undefined,
97
+ isMuxed: true,
98
+ isVideo: true,
99
+ isAudio: false,
100
+ };
101
+ }
102
+
103
+ export async function getInfo(
104
+ url: string,
105
+ _options: ExtractorOptions = {}
106
+ ): Promise<VideoInfo | null> {
107
+ try {
108
+ const match = url.match(/profile\/([^/]+)\/post\/([^/?#]+)/u);
109
+ if (!match) return null;
110
+ const [, handle, rkey] = match;
111
+
112
+ const resolved = await fetchJson<{ did?: string }>(
113
+ `${APPVIEW}/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`
114
+ );
115
+ const did = resolved?.did;
116
+ if (!did) return null;
117
+
118
+ const uri = `at://${did}/app.bsky.feed.post/${rkey}`;
119
+ const thread = await fetchJson<{ thread?: { post?: BskyPost } }>(
120
+ `${APPVIEW}/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}`
121
+ );
122
+ const post = thread?.thread?.post;
123
+
124
+ const video = pickVideo(post);
125
+ if (!video) return null; // no video -> yt-dlp fallback
126
+
127
+ const pds = await resolvePds(did);
128
+ if (!pds) return null;
129
+ const blobUrl = `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${video.cid}`;
130
+
131
+ const info: VideoInfo = {
132
+ type: 'video',
133
+ id: rkey,
134
+ title: post?.record?.text || 'Bluesky Video',
135
+ uploader:
136
+ post?.author?.displayName || post?.author?.handle || 'Bluesky User',
137
+ webpageUrl: url,
138
+ thumbnail: video.thumbnail,
139
+ formats: [buildFormat(blobUrl, video)],
140
+ extractorKey: 'bluesky',
141
+ isJsInfo: true,
142
+ fromBrain: false,
143
+ isPartial: false,
144
+ isIsrcMatch: false,
145
+ isFullData: true,
146
+ };
147
+
148
+ info.title = normalizeTitle(info as unknown as Record<string, unknown>);
149
+ info.uploader = normalizeArtist(info as unknown as Record<string, unknown>);
150
+
151
+ return info;
152
+ } catch (error: unknown) {
153
+ const message = error instanceof Error ? error.message : String(error);
154
+ console.error(`[JS-Bluesky] Error extracting ${url}: ${message}`);
155
+ return null;
156
+ }
157
+ }
158
+
159
+ export function getStream(
160
+ videoInfo: VideoInfo,
161
+ options: ExtractorOptions = {}
162
+ ): Promise<Readable> {
163
+ const selected =
164
+ videoInfo.formats.find(
165
+ (format) => String(format.formatId) === String(options.formatId)
166
+ ) || videoInfo.formats[0];
167
+ if (!selected?.url) throw new Error('No stream URL found');
168
+
169
+ return Promise.resolve(getQuantumStream(selected.url, {}));
170
+ }
backend/src/services/extractors/facebook/constants.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const DESKTOP_UA =
2
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36';
3
+
4
+ export const HEADERS = {
5
+ 'User-Agent': DESKTOP_UA,
6
+ Accept:
7
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
8
+ 'Accept-Language': 'en-US,en;q=0.5',
9
+ 'Sec-Fetch-Mode': 'navigate',
10
+ 'Sec-Fetch-Site': 'none',
11
+ 'Sec-Fetch-Dest': 'document',
12
+ 'Sec-Fetch-User': '?1',
13
+ 'Upgrade-Insecure-Requests': '1',
14
+ };
15
+
16
+ export const ID_REGEX =
17
+ /(?:v=|fbid=|videos\/|reel\/|reels\/|share\/r\/|stories\/)([a-zA-Z0-9_-]+)/u;
18
+
19
+ export const STORY_PATTERNS = [
20
+ /"unified_video_url"\s*:\s*"([^"]+)"/u,
21
+ /"playable_url"\s*:\s*"([^"]+)"/u,
22
+ /"playable_url_quality_hd"\s*:\s*"([^"]+)"/u,
23
+ ];
24
+
25
+ export const PHOTO_PATTERNS = [
26
+ /"media"\s*:\s*\{"__typename"\s*:\s*"Photo",.*?"image"\s*:\s*\{"uri"\s*:\s*"([^"]+)"\}/u,
27
+ /"story_card_info"\s*:\s*\{.*?"story_thumbnail"\s*:\s*\{"uri"\s*:\s*"([^"]+)"\}/u,
28
+ /"image"\s*:\s*\{"uri"\s*:\s*"([^"]+)"\}/u,
29
+ ];
30
+
31
+ export const THUMB_PATTERNS = [
32
+ /"preferred_thumbnail"\s*:\s*\{"image"\s*:\s*\{"uri"\s*:\s*"([^"]+)"\}/u,
33
+ /"preview_image"\s*:\s*\{"uri"\s*:\s*"([^"]+)"\}/u,
34
+ ];
35
+
36
+ export const DASH_PATTERNS = [
37
+ /"browser_native_hd_url":\s*"([^"]+)"[^{}]*?"audioUrl":\s*"([^"]+)"/gu,
38
+ /"audioUrl":\s*"([^"]+)"[^{}]*?"browser_native_hd_url":\s*"([^"]+)"/gu,
39
+ /FBQualityClass=\\"hd\\"[^>]*BaseURL>(.*?)</gsu,
40
+ /representation_id=\\"\d+v\\"[^>]*base_url\\":\\"(.*?)\\"/gsu,
41
+ ];
42
+
43
+ export const HD_FALLBACK_PATTERNS = [
44
+ /"browser_native_hd_url"\s*:\s*"([^"]+)"/u,
45
+ /"playable_url_quality_hd"\s*:\s*"([^"]+)"/u,
46
+ /hd_src\s*:\s*"([^"]+)"/u,
47
+ ];
48
+
49
+ export const SD_FALLBACK_PATTERNS = [
50
+ /"browser_native_sd_url"\s*:\s*"([^"]+)"/u,
51
+ /"playable_url"\s*:\s*"([^"]+)"/u,
52
+ /sd_src\s*:\s*"([^"]+)"/u,
53
+ /"video_url"\s*:\s*"([^"]+)"/u,
54
+ ];
55
+
56
+ export const RECOVERY_PATTERNS = [
57
+ {
58
+ type: 'author',
59
+ pattern:
60
+ /"(?:owner|author)":\{"__typename":"(?:User|Page)","name":"([^"]+)"/u,
61
+ },
62
+ {
63
+ type: 'author',
64
+ pattern: /"(?:story_bucket_owner_name|ownerName|author_name)":"([^"]+)"/u,
65
+ },
66
+ { type: 'author', pattern: /"story_bucket_owner":\{"name":"([^"]+)"/u },
67
+ { type: 'author', pattern: /"owner_as_page":\{"name":"([^"]+)"/u },
68
+ {
69
+ type: 'author',
70
+ pattern: /"comet_sections":\{"title":\{"text":"([^"]+)"\}/u,
71
+ },
72
+ { type: 'title', pattern: /"message":\s*\{"text":"([^"]+)"\}/u },
73
+ { type: 'title', pattern: /"video_title":"([^"]+)"/u },
74
+ { type: 'title', pattern: /"accessibility_caption":"([^"]+)"/u },
75
+ {
76
+ type: 'title',
77
+ pattern:
78
+ /"(?:message|node|accessibility_caption)":\s*\{"text":"([^"]+)"\}/u,
79
+ },
80
+ ];
backend/src/services/extractors/facebook/fetcher.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HEADERS, DESKTOP_UA } from './constants.js';
2
+ import { secureFetch } from '../../../utils/network/security.util.js';
3
+
4
+ type FetchHtmlOptions = {
5
+ cookie?: string;
6
+ };
7
+
8
+ export async function fetchHtml(
9
+ url: string,
10
+ options: FetchHtmlOptions
11
+ ): Promise<{ html: string; targetUrl: string; res: Response } | null> {
12
+ const cookie = typeof options.cookie === 'string' ? options.cookie : null;
13
+ const response = await secureFetch(url, {
14
+ headers: {
15
+ ...HEADERS,
16
+ ...(cookie && { Cookie: cookie }),
17
+ },
18
+ signal: AbortSignal.timeout(10000),
19
+ });
20
+
21
+ if (!response.ok) return null;
22
+ const targetUrl = response.url;
23
+ const html = await response.text();
24
+ return { html, targetUrl, res: response as unknown as Response };
25
+ }
26
+
27
+ export async function fetchFileSize(url: string): Promise<number | undefined> {
28
+ try {
29
+ const headResponse = await secureFetch(url, {
30
+ method: 'HEAD',
31
+ headers: { 'User-Agent': DESKTOP_UA },
32
+ signal: AbortSignal.timeout(5000),
33
+ });
34
+ if (headResponse.ok) {
35
+ const contentLength = headResponse.headers.get('content-length');
36
+ if (contentLength) return parseInt(contentLength, 10);
37
+ }
38
+ } catch (error: unknown) {
39
+ if (error instanceof Error) {
40
+ console.debug('[FacebookExtractor] Size fetch error:', error.message);
41
+ } else {
42
+ console.debug('[FacebookExtractor] Size fetch error:', String(error));
43
+ }
44
+ }
45
+ return undefined;
46
+ }
backend/src/services/extractors/facebook/index.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getQuantumStream } from '../../../utils/network/proxy.util.js';
2
+ import { VideoInfo, Format, ExtractorOptions } from '../../../types/index.js';
3
+ import { Readable } from 'node:stream';
4
+ import { DESKTOP_UA } from './constants.js';
5
+ import { fetchHtml, fetchFileSize } from './fetcher.js';
6
+ import { parseHtml } from './parser.js';
7
+ import { normalizeVideoInfo } from './normalizer.js';
8
+
9
+ export async function getInfo(
10
+ url: string,
11
+ _options: ExtractorOptions = {}
12
+ ): Promise<VideoInfo | null> {
13
+ try {
14
+ const fetchResult = await fetchHtml(url, _options);
15
+ if (!fetchResult) return null;
16
+
17
+ const { html, targetUrl } = fetchResult;
18
+
19
+ const parsedData = parseHtml(html, targetUrl);
20
+
21
+ let videoInfo = normalizeVideoInfo(targetUrl, parsedData);
22
+ if (!videoInfo) return null;
23
+
24
+ // retry lean page for caption
25
+ for (
26
+ let attempt = 0;
27
+ attempt < 2 && videoInfo.title === videoInfo.uploader;
28
+ attempt += 1
29
+ ) {
30
+ const retry = await fetchHtml(url, _options);
31
+ const alt = retry
32
+ ? normalizeVideoInfo(
33
+ retry.targetUrl,
34
+ parseHtml(retry.html, retry.targetUrl)
35
+ )
36
+ : null;
37
+ if (!alt || alt.formats.length === 0) break;
38
+ videoInfo = alt;
39
+ }
40
+
41
+ // fetch size
42
+ for (let i = 0; i < videoInfo.formats.length; i += 3) {
43
+ const batch = videoInfo.formats.slice(i, i + 3);
44
+ await Promise.all(
45
+ batch.map(async (format: Format) => {
46
+ if (format.url) {
47
+ const size = await fetchFileSize(format.url);
48
+ if (size) format.filesize = size;
49
+ }
50
+ })
51
+ );
52
+ }
53
+
54
+ return videoInfo;
55
+ } catch (error: unknown) {
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ console.error(`[JS-FB] Error extracting ${url}: ${message}`);
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export function getStream(
63
+ videoInfo: VideoInfo,
64
+ options: ExtractorOptions = {}
65
+ ): Promise<Readable> {
66
+ const targetFormat =
67
+ videoInfo.formats.find(
68
+ (formatItem: Format) =>
69
+ String(formatItem.formatId) === String(options.formatId)
70
+ ) || videoInfo.formats[0];
71
+ if (!targetFormat?.url) throw new Error('No stream URL found');
72
+
73
+ return Promise.resolve(
74
+ getQuantumStream(targetFormat.url, {
75
+ 'User-Agent': DESKTOP_UA,
76
+ Referer: 'https://www.facebook.com/',
77
+ Accept: '*/*',
78
+ 'Accept-Language': 'en-US,en;q=0.9',
79
+ Range: 'bytes=0-',
80
+ Origin: 'https://www.facebook.com',
81
+ 'Sec-Fetch-Dest': 'video',
82
+ 'Sec-Fetch-Mode': 'cors',
83
+ 'Sec-Fetch-Site': 'cross-site',
84
+ })
85
+ );
86
+ }
backend/src/services/extractors/facebook/json-extractor.ts ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // json-block parse; regex stays as fallback
2
+ import { FbRawFormat, FbParsed } from './types.js';
3
+
4
+ type Obj = Record<string, unknown>;
5
+
6
+ // no id here; parser adds it
7
+ type FbJsonResult = Omit<FbParsed, 'id'>;
8
+
9
+ const str = (value: unknown): string | undefined =>
10
+ typeof value === 'string' && value ? value : undefined;
11
+
12
+ // walk every object node in parsed json
13
+ function walk(node: unknown, visit: (obj: Obj) => void): void {
14
+ if (!node || typeof node !== 'object') return;
15
+ if (Array.isArray(node)) {
16
+ for (const item of node) walk(item, visit);
17
+ return;
18
+ }
19
+ visit(node as Obj);
20
+ for (const value of Object.values(node)) walk(value, visit);
21
+ }
22
+
23
+ // pull text/uri from nested {text}/{uri} node
24
+ function nestedText(value: unknown, key: 'text' | 'uri'): string | undefined {
25
+ if (value && typeof value === 'object') {
26
+ return str((value as Obj)[key]);
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ export function extractFromJson(html: string): FbJsonResult | null {
32
+ const blocks = html.matchAll(
33
+ /<script[^>]+type="application\/json"[^>]*>([\s\S]*?)<\/script>/gu
34
+ );
35
+
36
+ const formats: FbRawFormat[] = [];
37
+ const seen = new Set<string>();
38
+ let title = '';
39
+ let uploader = '';
40
+ let thumbnail = '';
41
+ const photos = new Set<string>();
42
+
43
+ const addUrl = (url: string | undefined, id: string) => {
44
+ if (!url || seen.has(url)) return;
45
+ if (formats.some((format) => format.format_id === id)) return;
46
+ seen.add(url);
47
+ formats.push({ url, format_id: id, ext: 'mp4', vcodec: 'h264', acodec: 'aac' });
48
+ };
49
+
50
+ for (const block of blocks) {
51
+ let data: unknown;
52
+ try {
53
+ data = JSON.parse(block[1]);
54
+ } catch {
55
+ continue;
56
+ }
57
+ walk(data, (obj) => {
58
+ addUrl(str(obj.browser_native_hd_url) ?? str(obj.playable_url_quality_hd), 'hd');
59
+ addUrl(str(obj.browser_native_sd_url) ?? str(obj.playable_url), 'sd');
60
+ if (!title) title = nestedText(obj.message, 'text') ?? str(obj.video_title) ?? '';
61
+ if (!uploader) {
62
+ const owner = (obj.owner ?? obj.owner_as_page) as Obj | undefined;
63
+ uploader = str(owner?.name) ?? str(obj.ownerName) ?? '';
64
+ }
65
+ if (!thumbnail) {
66
+ const thumb = obj.preferred_thumbnail as Obj | undefined;
67
+ thumbnail = nestedText(thumb?.image, 'uri') ?? '';
68
+ }
69
+ const photoUri = nestedText(obj.viewer_image, 'uri');
70
+ if (photoUri) photos.add(photoUri);
71
+ });
72
+ }
73
+
74
+ // photo post: no video found, use images
75
+ if (formats.length === 0 && photos.size > 0) {
76
+ const single = photos.size === 1;
77
+ let index = 0;
78
+ for (const uri of photos) {
79
+ formats.push({
80
+ url: uri,
81
+ format_id: single ? 'photo' : `photo_${index}`,
82
+ ext: 'jpeg',
83
+ });
84
+ index += 1;
85
+ }
86
+ }
87
+
88
+ if (formats.length === 0) return null;
89
+ return { formats, title, uploader, thumbnail };
90
+ }
backend/src/services/extractors/facebook/normalizer.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { VideoInfo, Format } from '../../../types/index.js';
2
+ import { normalizeTitle, normalizeArtist } from '../../social.service.js';
3
+ import { FbParsed } from './types.js';
4
+
5
+ export function normalizeVideoInfo(
6
+ url: string,
7
+ parsedData: FbParsed | null
8
+ ): VideoInfo | null {
9
+ if (!parsedData) return null;
10
+
11
+ const formats: Format[] = parsedData.formats.map((formatItem, index) => {
12
+ const vcodec =
13
+ formatItem.vcodec ?? (formatItem.ext === 'mp4' ? 'h264' : undefined);
14
+ const acodec =
15
+ formatItem.acodec ??
16
+ (formatItem.ext === 'mp4' || formatItem.ext === 'm4a'
17
+ ? 'aac'
18
+ : undefined);
19
+
20
+ const tier =
21
+ formatItem.format_id === 'hd'
22
+ ? 'HD'
23
+ : formatItem.format_id === 'sd'
24
+ ? 'SD'
25
+ : formatItem.format_id?.startsWith('photo')
26
+ ? 'Photo'
27
+ : undefined;
28
+
29
+ return {
30
+ formatId: formatItem.format_id || `fb_${index}`,
31
+ url: formatItem.url,
32
+ extension: formatItem.ext ?? 'mp4',
33
+ resolution: tier ?? 'Source',
34
+ quality: tier,
35
+ acodec,
36
+ vcodec,
37
+ isAudio: Boolean(acodec && acodec !== 'none'),
38
+ isVideo: Boolean(vcodec && vcodec !== 'none'),
39
+ isMuxed: Boolean(
40
+ acodec && acodec !== 'none' && vcodec && vcodec !== 'none'
41
+ ),
42
+ };
43
+ });
44
+
45
+ if (formats.length === 0) return null;
46
+
47
+ const info: VideoInfo = {
48
+ type: 'video',
49
+ id: parsedData.id || url,
50
+ title: parsedData.title || 'Facebook Video',
51
+ uploader: parsedData.uploader || 'Facebook User',
52
+ webpageUrl: url,
53
+ formats,
54
+ extractorKey: 'facebook',
55
+ isJsInfo: true,
56
+ fromBrain: false,
57
+ isPartial: false,
58
+ isIsrcMatch: false,
59
+ isFullData: false,
60
+ };
61
+
62
+ // make caption authoritative over og:title
63
+ if (parsedData.title) {
64
+ info.metascraper = { title: parsedData.title };
65
+ }
66
+
67
+ info.title = normalizeTitle(info as unknown as Record<string, unknown>);
68
+ info.uploader = normalizeArtist(info as unknown as Record<string, unknown>);
69
+
70
+ return info;
71
+ }
backend/src/services/extractors/facebook/parser.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ ID_REGEX,
3
+ THUMB_PATTERNS,
4
+ DASH_PATTERNS,
5
+ RECOVERY_PATTERNS,
6
+ STORY_PATTERNS,
7
+ PHOTO_PATTERNS,
8
+ HD_FALLBACK_PATTERNS,
9
+ SD_FALLBACK_PATTERNS,
10
+ } from './constants.js';
11
+ import { decode, decodeHtmlEntities } from './utils.js';
12
+ import { extractFromJson } from './json-extractor.js';
13
+ import { FbRawFormat, FbParsed } from './types.js';
14
+
15
+ // first decoded capture across patterns
16
+ function firstCapture(html: string, patterns: RegExp[]): string | null {
17
+ for (const pattern of patterns) {
18
+ const match = html.match(pattern);
19
+ if (match) return decode(match[1]);
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function extractMeta(html: string): { title: string; uploader: string } {
25
+ let title = '';
26
+ let uploader = '';
27
+ for (const recovery of RECOVERY_PATTERNS) {
28
+ const match = html.match(recovery.pattern);
29
+ if (!match) continue;
30
+ if (recovery.type === 'title' && !title) title = decode(match[1]);
31
+ if (recovery.type === 'author' && !uploader) uploader = decode(match[1]);
32
+ }
33
+ return { title, uploader };
34
+ }
35
+
36
+ function extractDashFormats(html: string): FbRawFormat[] {
37
+ const formats: FbRawFormat[] = [];
38
+ for (const pattern of DASH_PATTERNS) {
39
+ for (const match of html.matchAll(pattern)) {
40
+ if (match[1] && match[2]) {
41
+ formats.push({ url: decode(match[1]), format_id: 'hd', ext: 'mp4' });
42
+ formats.push({
43
+ url: decode(match[2]),
44
+ format_id: 'audio',
45
+ ext: 'm4a',
46
+ acodec: 'aac',
47
+ });
48
+ } else if (match[1]) {
49
+ formats.push({ url: decode(match[1]), format_id: 'sd', ext: 'mp4' });
50
+ }
51
+ }
52
+ }
53
+ return formats;
54
+ }
55
+
56
+ function extractFallbackFormats(html: string): FbRawFormat[] {
57
+ const formats: FbRawFormat[] = [];
58
+ // hd: browser_native first, else story
59
+ const hd =
60
+ firstCapture(html, HD_FALLBACK_PATTERNS) ??
61
+ firstCapture(html, STORY_PATTERNS);
62
+ if (hd) formats.push({ url: hd, format_id: 'hd', ext: 'mp4' });
63
+
64
+ // sd also covers story playable_url
65
+ const sd = firstCapture(html, SD_FALLBACK_PATTERNS);
66
+ if (sd) formats.push({ url: sd, format_id: 'sd', ext: 'mp4' });
67
+
68
+ // photo only when no video found
69
+ if (formats.length === 0) {
70
+ const photo = firstCapture(html, PHOTO_PATTERNS);
71
+ if (photo) formats.push({ url: photo, format_id: 'photo', ext: 'jpeg' });
72
+ }
73
+ return formats;
74
+ }
75
+
76
+ // parse caption/author from og:title
77
+ function parseOgTitle(html: string): { caption?: string; author?: string } {
78
+ const match = html.match(/<meta property="og:title" content="([^"]*)"/u);
79
+ if (!match) return {};
80
+ const parts = decodeHtmlEntities(match[1])
81
+ .split('|')
82
+ .map((part) => part.trim())
83
+ .filter(Boolean);
84
+ if (parts.length === 0) return {};
85
+ const author = parts[parts.length - 1].replace(
86
+ /^(?:Reel|Video)\s+by\s+/iu,
87
+ ''
88
+ );
89
+ const caption = parts
90
+ .slice(0, -1)
91
+ .find(
92
+ (part) =>
93
+ !/\b(?:views?|reactions?|likes?|shares?|comments?)\b/iu.test(part)
94
+ );
95
+ return { caption, author };
96
+ }
97
+
98
+ function parseOgImage(html: string): string | undefined {
99
+ const match = html.match(/<meta property="og:image" content="([^"]+)"/u);
100
+ return match ? decodeHtmlEntities(match[1]) : undefined;
101
+ }
102
+
103
+ // fb auto-generated alt-text, not a caption
104
+ function isAltText(text: string): boolean {
105
+ return /^May be a/iu.test(text) || /^No photo description/iu.test(text);
106
+ }
107
+
108
+ // og:description holds the post caption
109
+ function parseOgDescription(html: string): string {
110
+ const match = html.match(
111
+ /<meta property="og:description" content="([^"]*)"/u
112
+ );
113
+ if (!match) return '';
114
+ const text = decodeHtmlEntities(match[1]).trim();
115
+ if (isAltText(text)) return '';
116
+ if (text.length <= 120) return text;
117
+ return text.slice(0, 120).replace(/\s+\S*$/u, '');
118
+ }
119
+
120
+ export function parseHtml(html: string, url: string): FbParsed {
121
+ const idMatch = url.match(ID_REGEX);
122
+ const videoId = idMatch ? idMatch[1] : null;
123
+ const og = parseOgTitle(html);
124
+ const ogDesc = parseOgDescription(html);
125
+
126
+ // json-first; regex fallback
127
+ const json = extractFromJson(html);
128
+ if (json) {
129
+ const meta = extractMeta(html);
130
+ return {
131
+ id: videoId,
132
+ title: json.title || og.caption || ogDesc || (isAltText(meta.title) ? '' : meta.title),
133
+ uploader: json.uploader || og.author || meta.uploader,
134
+ thumbnail: json.thumbnail || firstCapture(html, THUMB_PATTERNS) || '',
135
+ formats: json.formats,
136
+ };
137
+ }
138
+
139
+ const { title, uploader } = extractMeta(html);
140
+ const thumbnail = firstCapture(html, THUMB_PATTERNS) ?? '';
141
+
142
+ let formats = extractDashFormats(html);
143
+ if (formats.length === 0) formats = extractFallbackFormats(html);
144
+
145
+ // gated page: og:image fallback
146
+ if (formats.length === 0) {
147
+ const ogImage = parseOgImage(html);
148
+ if (ogImage) formats = [{ url: ogImage, format_id: 'photo', ext: 'jpeg' }];
149
+ }
150
+
151
+ return {
152
+ id: videoId,
153
+ title: og.caption || ogDesc || (isAltText(title) ? '' : title) || '',
154
+ uploader: uploader || og.author || '',
155
+ thumbnail,
156
+ formats,
157
+ };
158
+ }
backend/src/services/extractors/facebook/types.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // shared contract across parse + normalize
2
+
3
+ // snake_case mirrors fb wire keys
4
+ export interface FbRawFormat {
5
+ url: string;
6
+ format_id?: string;
7
+ ext?: string;
8
+ vcodec?: string;
9
+ acodec?: string;
10
+ }
11
+
12
+ // parsed media and page meta
13
+ export interface FbParsed {
14
+ id: string | null;
15
+ title: string;
16
+ uploader: string;
17
+ thumbnail: string;
18
+ formats: FbRawFormat[];
19
+ }
backend/src/services/extractors/facebook/utils.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // decode numeric + named HTML entities
2
+ export function decodeHtmlEntities(text: string): string {
3
+ return text
4
+ .replace(/&#x([0-9a-fA-F]+);/gu, (_match, hex) =>
5
+ String.fromCodePoint(parseInt(hex, 16))
6
+ )
7
+ .replace(/&#(\d+);/gu, (_match, dec) =>
8
+ String.fromCodePoint(parseInt(dec, 10))
9
+ )
10
+ .replace(/&amp;/gu, '&')
11
+ .replace(/&quot;/gu, '"')
12
+ .replace(/&lt;/gu, '<')
13
+ .replace(/&gt;/gu, '>');
14
+ }
15
+
16
+ // decode js-escaped json-in-html capture
17
+ export function decode(text: string): string {
18
+ try {
19
+ if (text.startsWith('"') && text.endsWith('"')) return JSON.parse(text);
20
+ const unescaped = text
21
+ .replace(/\\u([0-9a-fA-F]{4})/gu, (_match, group) =>
22
+ String.fromCharCode(parseInt(group, 16))
23
+ )
24
+ .replace(/\\\//gu, '/')
25
+ .replace(/\\"/gu, '"')
26
+ .replace(/\\\\/gu, '\\');
27
+ return decodeHtmlEntities(unescaped);
28
+ } catch (error) {
29
+ console.debug('Ignored:', (error as Error).message);
30
+ return text.replace(/\\/gu, '').replace(/&amp;/gu, '&');
31
+ }
32
+ }
backend/src/services/extractors/index.ts ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getInfo as ytGetInfo,
3
+ getStream as ytGetStream,
4
+ } from './youtube/index.js';
5
+ import {
6
+ getInfo as igGetInfo,
7
+ getStream as igGetStream,
8
+ } from './instagram/index.js';
9
+ import {
10
+ getInfo as fbGetInfo,
11
+ getStream as fbGetStream,
12
+ } from './facebook/index.js';
13
+ import { getInfo as tkGetInfo, getStream as tkGetStream } from './tiktok.js';
14
+ import { getInfo as spGetInfo, getStream as spGetStream } from './spotify.js';
15
+ import {
16
+ getInfo as scGetInfo,
17
+ getStream as scGetStream,
18
+ } from './soundcloud.js';
19
+ import { getInfo as xGetInfo, getStream as xGetStream } from './x.js';
20
+ import {
21
+ getInfo as bsGetInfo,
22
+ getStream as bsGetStream,
23
+ } from './bluesky.js';
24
+ import {
25
+ getInfo as thGetInfo,
26
+ getStream as thGetStream,
27
+ } from './threads/index.js';
28
+ import { Extractor, ExtractorOptions, VideoInfo } from '../../types/index.js';
29
+ import {
30
+ fetchMetadata,
31
+ fetchYoutubeOEmbed,
32
+ } from '../../utils/media/metadata.util.js';
33
+ import { recordFailure } from '../../utils/infra/metrics.util.js';
34
+
35
+ const youtube: Extractor = { getInfo: ytGetInfo, getStream: ytGetStream };
36
+ const instagram: Extractor = { getInfo: igGetInfo, getStream: igGetStream };
37
+ const facebook: Extractor = { getInfo: fbGetInfo, getStream: fbGetStream };
38
+ const tiktok: Extractor = { getInfo: tkGetInfo, getStream: tkGetStream };
39
+ const spotify: Extractor = { getInfo: spGetInfo, getStream: spGetStream };
40
+ const soundcloud: Extractor = { getInfo: scGetInfo, getStream: scGetStream };
41
+ const x: Extractor = { getInfo: xGetInfo, getStream: xGetStream };
42
+ const bluesky: Extractor = { getInfo: bsGetInfo, getStream: bsGetStream };
43
+ const threads: Extractor = { getInfo: thGetInfo, getStream: thGetStream };
44
+
45
+ // reverse lookup for failure labels
46
+ const extractorNames = new Map<Extractor, string>([
47
+ [youtube, 'youtube'],
48
+ [instagram, 'instagram'],
49
+ [facebook, 'facebook'],
50
+ [tiktok, 'tiktok'],
51
+ [spotify, 'spotify'],
52
+ [soundcloud, 'soundcloud'],
53
+ [x, 'x'],
54
+ [bluesky, 'bluesky'],
55
+ [threads, 'threads'],
56
+ ]);
57
+
58
+ // map in-flight JS
59
+ const inFlightJsTasks = new Map<string, Promise<VideoInfo | null>>();
60
+
61
+ /**
62
+ * Returns the in-flight (or recently completed) JS extraction promise for a URL.
63
+ * Used by handleYoutubeTiktokInfo to skip yt-dlp deep-scan when JS already has formats.
64
+ */
65
+ export function getInFlightJsResult(
66
+ url: string
67
+ ): Promise<VideoInfo | null> | undefined {
68
+ return inFlightJsTasks.get(url);
69
+ }
70
+
71
+ const genericExtractor: Extractor = {
72
+ getInfo: async (url: string) => {
73
+ const meta = await fetchMetadata(url);
74
+ if (!meta) return null;
75
+ return {
76
+ type: 'video',
77
+ id: `gen_${Buffer.from(url).toString('base64').substring(0, 10)}`,
78
+ title: meta.title || 'Unknown Video',
79
+ uploader: meta.author || meta.publisher || 'Unknown',
80
+ thumbnail: meta.image || undefined,
81
+ webpageUrl: url,
82
+ formats: [],
83
+ metascraper: meta,
84
+ fromBrain: false,
85
+ isPartial: false,
86
+ isIsrcMatch: false,
87
+ isJsInfo: true,
88
+ isFullData: false,
89
+ };
90
+ },
91
+ getStream: () => {
92
+ throw new Error(
93
+ 'Streaming not supported for generic URLs. Please provide a supported platform link.'
94
+ );
95
+ },
96
+ };
97
+
98
+ export function getExtractor(url: string): Extractor | null {
99
+ if (url.includes('youtube.com') || url.includes('youtu.be')) return youtube;
100
+ if (url.includes('instagram.com')) return instagram;
101
+ if (url.includes('facebook.com') || url.includes('fb.watch')) return facebook;
102
+ if (url.includes('threads.net') || url.includes('threads.com'))
103
+ return threads;
104
+ if (url.includes('tiktok.com')) return tiktok;
105
+ if (url.includes('spotify.com')) return spotify;
106
+ if (url.includes('soundcloud.com')) return soundcloud;
107
+ if (url.includes('twitter.com') || /\/\/(?:www\.|mobile\.)?x\.com\//u.test(url))
108
+ return x;
109
+ if (url.includes('bsky.app')) return bluesky;
110
+ return genericExtractor;
111
+ }
112
+
113
+ // platform label, not a real author
114
+ function isLowValueEarlyAuthor(name: string | undefined): boolean {
115
+ if (!name) return true;
116
+ const value = name.trim().toLowerCase();
117
+ return [
118
+ 'facebook',
119
+ 'instagram',
120
+ 'threads',
121
+ 'tiktok',
122
+ 'x',
123
+ 'twitter',
124
+ 'bluesky',
125
+ 'social media',
126
+ 'make your day',
127
+ 'unknown',
128
+ ].includes(value);
129
+ }
130
+
131
+ export async function getInfo(
132
+ url: string,
133
+ options: ExtractorOptions = {}
134
+ ): Promise<VideoInfo | null> {
135
+ const extractor = getExtractor(url);
136
+ if (!extractor) return null;
137
+
138
+ const getInfoStart = Date.now();
139
+ const isYouTube = url.includes('youtube.com') || url.includes('youtu.be');
140
+
141
+ // use oEmbed/metascraper
142
+ const metaFetcher = isYouTube ? fetchYoutubeOEmbed : fetchMetadata;
143
+
144
+ // define metascraper promise
145
+ const metaFetchStart = Date.now();
146
+ const metascraperTask = metaFetcher(url)
147
+ .catch(() => null)
148
+ .then(async (meta) => {
149
+ const metaFetchMs = Date.now() - metaFetchStart;
150
+ console.log(
151
+ `[Timing] ${isYouTube ? 'oEmbed' : 'metascraper'} fetch took ${metaFetchMs}ms (returned ${meta ? 'data' : 'null'})`
152
+ );
153
+
154
+ if (meta && (meta.author || meta.publisher) && options.onProgress) {
155
+ try {
156
+ const dispatchStart = Date.now();
157
+ const { prepareFinalResponse } =
158
+ await import('../../utils/api/response.util.js');
159
+ const earlyInfo: VideoInfo = {
160
+ type: 'video',
161
+ id: `early_${Buffer.from(url).toString('base64').substring(0, 10)}`,
162
+ title: meta.title || 'Unknown Video',
163
+ uploader: meta.author || meta.publisher || 'Unknown',
164
+ thumbnail: meta.image || undefined,
165
+ webpageUrl: url,
166
+ formats: [],
167
+ metascraper: meta,
168
+ fromBrain: false,
169
+ isPartial: true,
170
+ isIsrcMatch: false,
171
+ isJsInfo: true,
172
+ isFullData: false,
173
+ };
174
+
175
+ const finalEarlyData = await prepareFinalResponse(
176
+ earlyInfo,
177
+ false,
178
+ null,
179
+ url
180
+ );
181
+ finalEarlyData.isPartial = true;
182
+
183
+ // skip flickery paint for platform labels
184
+ if (isLowValueEarlyAuthor(finalEarlyData.artist)) {
185
+ console.log(
186
+ `[Metadata] Skipped low-value early hit (author "${finalEarlyData.artist}")`
187
+ );
188
+ return meta;
189
+ }
190
+
191
+ const totalEarlyHitMs = Date.now() - getInfoStart;
192
+ const wallClockMs = options.requestT0
193
+ ? Date.now() - options.requestT0
194
+ : null;
195
+ // track total request execution time
196
+ const wallClockSuffix =
197
+ wallClockMs !== null ? `, wall-clock ${wallClockMs}ms` : '';
198
+ console.log(
199
+ `[Metadata] Early hit: "${finalEarlyData.title}" by "${finalEarlyData.artist}" (T+${totalEarlyHitMs}ms from getInfo start, dispatch prep ${Date.now() - dispatchStart}ms${wallClockSuffix})`
200
+ );
201
+
202
+ options.onProgress(
203
+ 'extracting',
204
+ 45,
205
+ 'Metadata found',
206
+ JSON.stringify({ early_metadata: finalEarlyData })
207
+ );
208
+ } catch (err) {
209
+ console.error('[Metadata] Early dispatch failed:', err);
210
+ }
211
+ }
212
+ return meta;
213
+ });
214
+
215
+ // js extraction
216
+ const jsTask = (async () => {
217
+ try {
218
+ const res = await extractor.getInfo(url, options);
219
+ return res;
220
+ } catch {
221
+ recordFailure(`extract:${extractorNames.get(extractor) ?? 'generic'}`);
222
+ return null;
223
+ }
224
+ })();
225
+
226
+ // cache JS task
227
+ inFlightJsTasks.set(url, jsTask);
228
+ jsTask.finally(() => {
229
+ const cleanupTimer = setTimeout(() => {
230
+ if (inFlightJsTasks.get(url) === jsTask) {
231
+ inFlightJsTasks.delete(url);
232
+ }
233
+ }, 30000);
234
+ // allow process exit
235
+ cleanupTimer.unref?.();
236
+ });
237
+
238
+ const fastResult = await Promise.race([
239
+ jsTask.then((res) => ({
240
+ type: 'js' as const,
241
+ data: res as VideoInfo | null,
242
+ })),
243
+ metascraperTask.then((meta) => ({ type: 'meta' as const, data: meta })),
244
+ new Promise<{ type: 'timeout'; data: null }>((resolve) =>
245
+ setTimeout(() => resolve({ type: 'timeout', data: null }), 8000)
246
+ ),
247
+ ]);
248
+
249
+ if (
250
+ fastResult.type === 'js' &&
251
+ fastResult.data &&
252
+ Array.isArray(fastResult.data.formats) &&
253
+ fastResult.data.formats.length > 0
254
+ ) {
255
+ const meta = await metascraperTask;
256
+ // extractor thumbnail wins; metascraper only fills gaps
257
+ if (meta && !fastResult.data.thumbnail) {
258
+ fastResult.data.metascraper = { image: meta.image };
259
+ }
260
+ return fastResult.data as VideoInfo;
261
+ }
262
+
263
+ // js slow/empty: metascraper fallback
264
+ if (fastResult.type === 'meta' && fastResult.data) {
265
+ const meta = fastResult.data;
266
+ return {
267
+ type: 'video',
268
+ id: `meta_${Buffer.from(url).toString('base64').substring(0, 10)}`,
269
+ title: meta.title || 'Unknown Video',
270
+ uploader: meta.author || meta.publisher || 'Unknown',
271
+ webpageUrl: url,
272
+ formats: [],
273
+ thumbnail: meta.image || undefined,
274
+ metascraper: { image: meta.image },
275
+ fromBrain: false,
276
+ isPartial: true,
277
+ isIsrcMatch: false,
278
+ isJsInfo: false,
279
+ isFullData: false,
280
+ } as VideoInfo;
281
+ }
282
+
283
+ // fallback to js
284
+ return await jsTask;
285
+ }
286
+
287
+ export function shouldJSStream(url: string, quality: string, format: string) {
288
+ if (url.includes('youtube.com') || url.includes('youtu.be')) {
289
+ return false;
290
+ }
291
+
292
+ if (
293
+ url.includes('facebook.com') ||
294
+ url.includes('instagram.com') ||
295
+ url.includes('threads.net') ||
296
+ url.includes('threads.com') ||
297
+ url.includes('spotify.com') ||
298
+ url.includes('soundcloud.com')
299
+ )
300
+ return true;
301
+
302
+ if (url.includes('tiktok.com')) return false; // download issues
303
+
304
+ if (['mp3', 'm4a', 'audio'].includes(format)) return true;
305
+
306
+ const res = parseInt(quality);
307
+ return !isNaN(res) && res <= 720;
308
+ }
309
+
310
+ export {
311
+ youtube,
312
+ instagram,
313
+ facebook,
314
+ tiktok,
315
+ spotify,
316
+ soundcloud,
317
+ x,
318
+ bluesky,
319
+ threads,
320
+ };
backend/src/services/extractors/instagram/fetcher.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { secureFetch } from '../../../utils/network/security.util.js';
2
+
3
+ export async function fetchJson(
4
+ url: string,
5
+ options: { cookie?: string } = {}
6
+ ): Promise<unknown> {
7
+ const cookie = typeof options.cookie === 'string' ? options.cookie : null;
8
+ const response = await secureFetch(url, {
9
+ headers: {
10
+ 'User-Agent':
11
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1',
12
+ Accept: '*/*',
13
+ 'Accept-Language': 'en-US,en;q=0.5',
14
+ 'X-Requested-With': 'XMLHttpRequest',
15
+ ...(cookie && { Cookie: cookie }),
16
+ },
17
+ signal: AbortSignal.timeout(10000),
18
+ });
19
+
20
+ if (!response.ok) return null;
21
+ return await response.json();
22
+ }
23
+
24
+ export async function fetchEmbed(
25
+ url: string,
26
+ options: { cookie?: string } = {}
27
+ ): Promise<string | null> {
28
+ const cookie = typeof options.cookie === 'string' ? options.cookie : null;
29
+ const response = await secureFetch(`${url}embed/captioned/`, {
30
+ headers: {
31
+ 'User-Agent':
32
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
33
+ ...(cookie && { Cookie: cookie }),
34
+ },
35
+ signal: AbortSignal.timeout(10000),
36
+ });
37
+
38
+ if (!response.ok) return null;
39
+ return await response.text();
40
+ }
41
+
42
+ export async function fetchFileSize(url: string): Promise<number | undefined> {
43
+ try {
44
+ const headResponse = await secureFetch(url, {
45
+ method: 'HEAD',
46
+ headers: {
47
+ 'User-Agent':
48
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
49
+ },
50
+ signal: AbortSignal.timeout(5000),
51
+ });
52
+ if (headResponse.ok) {
53
+ const contentLengthHeader = headResponse.headers.get('content-length');
54
+ if (contentLengthHeader) return parseInt(contentLengthHeader, 10);
55
+ }
56
+ } catch (error: unknown) {
57
+ const errorObj = error as Error;
58
+ console.debug(`[Instagram] Size fetch failed: ${errorObj.message}`);
59
+ }
60
+ return undefined;
61
+ }
backend/src/services/extractors/instagram/index.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getQuantumStream } from '../../../utils/network/proxy.util.js';
2
+ import { VideoInfo, Format, ExtractorOptions } from '../../../types/index.js';
3
+ import { Readable } from 'node:stream';
4
+ import { fetchJson, fetchEmbed, fetchFileSize } from './fetcher.js';
5
+ import { parseJson, parseEmbed } from './parser.js';
6
+ import { normalizeVideoInfo } from './normalizer.js';
7
+
8
+ export async function getInfo(
9
+ url: string,
10
+ options: ExtractorOptions = {}
11
+ ): Promise<VideoInfo | null> {
12
+ try {
13
+ // try JSON
14
+ let rawData = null;
15
+ const jsonUrl = url.includes('?')
16
+ ? `${url.split('?')[0]}?__a=1&__d=dis`
17
+ : `${url}?__a=1&__d=dis`;
18
+ const jsonData = await fetchJson(jsonUrl, options);
19
+ if (jsonData) {
20
+ rawData = parseJson(jsonData);
21
+ }
22
+
23
+ if (!rawData) {
24
+ const embedHtml = await fetchEmbed(url, options);
25
+ if (embedHtml) {
26
+ rawData = parseEmbed(embedHtml);
27
+ }
28
+ }
29
+
30
+ if (!rawData) return null;
31
+
32
+ const videoInfo = normalizeVideoInfo(url, rawData);
33
+ if (!videoInfo) return null;
34
+
35
+ // fetch size
36
+ for (let index = 0; index < videoInfo.formats.length; index += 2) {
37
+ const batch = videoInfo.formats.slice(index, index + 2);
38
+ await Promise.all(
39
+ batch.map(async (formatItem: Format) => {
40
+ if (formatItem.url) {
41
+ const size = await fetchFileSize(formatItem.url);
42
+ if (size) formatItem.filesize = size;
43
+ }
44
+ })
45
+ );
46
+ }
47
+
48
+ return videoInfo;
49
+ } catch (error: unknown) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ console.error(`[JS-IG] Error extracting ${url}: ${message}`);
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function getStream(
57
+ videoInfo: VideoInfo,
58
+ options: ExtractorOptions = {}
59
+ ): Promise<Readable> {
60
+ const targetFormat =
61
+ videoInfo.formats.find(
62
+ (formatItem: Format) =>
63
+ String(formatItem.formatId) === String(options.formatId)
64
+ ) || videoInfo.formats[0];
65
+ if (!targetFormat?.url) throw new Error('No stream URL found');
66
+
67
+ return Promise.resolve(
68
+ getQuantumStream(targetFormat.url, {
69
+ 'User-Agent':
70
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
71
+ Referer: 'https://www.instagram.com/',
72
+ Accept: '*/*',
73
+ })
74
+ );
75
+ }
backend/src/services/extractors/instagram/normalizer.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { VideoInfo } from '../../../types/index.js';
2
+ import { normalizeTitle, normalizeArtist } from '../../social.service.js';
3
+
4
+ /* eslint-disable @typescript-eslint/no-explicit-any */
5
+ export function normalizeVideoInfo(
6
+ url: string,
7
+ parsedData: any
8
+ ): VideoInfo | null {
9
+ if (!parsedData) return null;
10
+
11
+ const rawFormats = (parsedData.formats || []) as any[];
12
+ const formats = rawFormats.map((formatItem) => {
13
+ return {
14
+ formatId: formatItem.id || `ig_${Math.random().toString(36).slice(2, 7)}`,
15
+ url: formatItem.video_url || formatItem.display_url,
16
+ extension: 'mp4',
17
+ resolution: formatItem.width
18
+ ? `${formatItem.width}x${formatItem.height}`
19
+ : 'Source',
20
+ width: formatItem.width,
21
+ height: formatItem.height,
22
+ filesize: formatItem.filesize,
23
+ acodec: 'aac',
24
+ vcodec: 'h264',
25
+ isAudio: true,
26
+ isVideo: true,
27
+ isMuxed: true,
28
+ };
29
+ });
30
+
31
+ const info: VideoInfo = {
32
+ type: 'video',
33
+ id: parsedData.id || url,
34
+ title: parsedData.title || 'Instagram Video',
35
+ uploader: parsedData.uploader || 'Instagram User',
36
+ webpageUrl: url,
37
+ formats,
38
+ extractorKey: 'instagram',
39
+ isJsInfo: true,
40
+ fromBrain: false,
41
+ isPartial: false,
42
+ isIsrcMatch: false,
43
+ isFullData: false,
44
+ };
45
+
46
+ info.title = normalizeTitle(info as unknown as Record<string, unknown>);
47
+ info.uploader = normalizeArtist(info as unknown as Record<string, unknown>);
48
+
49
+ return info;
50
+ }
backend/src/services/extractors/instagram/parser.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { load, CheerioAPI } from 'cheerio';
2
+ import { decode } from '../facebook/utils.js';
3
+
4
+ /* eslint-disable @typescript-eslint/no-explicit-any */
5
+ export function parseJson(jsonData: any): unknown {
6
+ const items = (jsonData?.items || []) as any[];
7
+ if (items.length === 0) return null;
8
+
9
+ const item = items[0];
10
+ const videoVersions = (item.video_versions || []) as any[];
11
+ const user = item.user;
12
+
13
+ return {
14
+ id: item.pk || item.id,
15
+ title: item.caption?.text || 'Instagram Post',
16
+ uploader: user?.full_name || user?.username,
17
+ thumbnail: item.image_versions2?.candidates?.[0]?.url,
18
+ formats: videoVersions.map((version) => ({
19
+ id: version.id,
20
+ video_url: version.url,
21
+ width: version.width,
22
+ height: version.height,
23
+ })),
24
+ };
25
+ }
26
+
27
+ const _extractThumbnailFromCheerio = (cheerioInstance: CheerioAPI) => {
28
+ return (
29
+ cheerioInstance('meta[property="og:image"]').attr('content') ||
30
+ cheerioInstance('img.EmbeddedMediaImage').attr('src')
31
+ );
32
+ };
33
+
34
+ const _extractVideoUrlFromHtml = (html: string) => {
35
+ const videoMatch = html.match(/"video_url"\s*:\s*"([^"]+)"/u);
36
+ return videoMatch ? decode(videoMatch[1]) : null;
37
+ };
38
+
39
+ export function parseEmbed(html: string): unknown {
40
+ const cheerioInstance = load(html);
41
+ const title =
42
+ cheerioInstance('meta[property="og:title"]').attr('content') ||
43
+ cheerioInstance('.Caption').text() ||
44
+ 'Instagram Video';
45
+
46
+ const uploader =
47
+ cheerioInstance('.Username').text() ||
48
+ cheerioInstance('.UsernameText').text() ||
49
+ 'Instagram User';
50
+
51
+ const thumbnail = _extractThumbnailFromCheerio(cheerioInstance);
52
+ const videoUrl = _extractVideoUrlFromHtml(html);
53
+
54
+ const formats = videoUrl
55
+ ? [
56
+ {
57
+ id: 'hd',
58
+ video_url: videoUrl,
59
+ },
60
+ ]
61
+ : [];
62
+
63
+ return {
64
+ id: null,
65
+ title,
66
+ uploader,
67
+ thumbnail,
68
+ formats,
69
+ };
70
+ }
backend/src/services/extractors/soundcloud.ts ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Readable } from 'node:stream';
2
+ import { VideoInfo, ExtractorOptions } from '../../types/index.js';
3
+ import { secureFetch } from '../../utils/network/security.util.js';
4
+
5
+ let cachedClientId: string | null = null;
6
+ let lastClientIdFetch = 0;
7
+ const CLIENT_ID_EXPIRY = 3600000; // 1 hour
8
+
9
+ async function getClientId(): Promise<string | null> {
10
+ if (cachedClientId && Date.now() - lastClientIdFetch < CLIENT_ID_EXPIRY) {
11
+ return cachedClientId;
12
+ }
13
+
14
+ try {
15
+ console.log('[SoundCloud] Fetching fresh client_id...');
16
+ const response = await secureFetch('https://soundcloud.com', {
17
+ headers: {
18
+ 'User-Agent':
19
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
20
+ },
21
+ });
22
+ const html = await response.text();
23
+ const scriptUrls = html.match(/src="([^"]+\/assets\/[^"]+\.js)"/g) || [];
24
+
25
+ for (const scriptTag of scriptUrls.reverse()) {
26
+ const match = scriptTag.match(/src="([^"]+)"/u);
27
+ if (!match) continue;
28
+ const url = match[1];
29
+ const scriptRes = await secureFetch(url);
30
+ const scriptBody = await scriptRes.text();
31
+ const idMatch = scriptBody.match(/client_id:"([a-zA-Z0-9]{32})"/u);
32
+ if (idMatch) {
33
+ cachedClientId = idMatch[1];
34
+ lastClientIdFetch = Date.now();
35
+ console.log(`[SoundCloud] Found client_id: ${cachedClientId}`);
36
+ return cachedClientId;
37
+ }
38
+ }
39
+ } catch (error: unknown) {
40
+ console.error(
41
+ '[SoundCloud] Failed to fetch client_id:',
42
+ error instanceof Error ? error.message : String(error)
43
+ );
44
+ }
45
+ return cachedClientId;
46
+ }
47
+
48
+ export async function search(query: string): Promise<unknown[]> {
49
+ const clientId = await getClientId();
50
+ if (!clientId) return [];
51
+
52
+ try {
53
+ const url = `https://api-v2.soundcloud.com/search/tracks?q=${encodeURIComponent(query)}&client_id=${clientId}&limit=5`;
54
+ const response = await secureFetch(url);
55
+ const { collection } = (await response.json()) as {
56
+ collection?: unknown[];
57
+ };
58
+ return collection ?? [];
59
+ } catch (error: unknown) {
60
+ if (error instanceof Error) {
61
+ console.error('[SoundCloud] Search error:', error.message);
62
+ } else {
63
+ console.error('[SoundCloud] Search error:', error);
64
+ }
65
+ return [];
66
+ }
67
+ }
68
+
69
+ interface SoundCloudTranscoding {
70
+ url: string;
71
+ format: {
72
+ protocol: string;
73
+ mime_type: string;
74
+ };
75
+ }
76
+
77
+ interface SoundCloudTrack {
78
+ policy: string;
79
+ duration: number;
80
+ full_duration: number;
81
+ title: string;
82
+ media?: {
83
+ transcodings?: SoundCloudTranscoding[];
84
+ };
85
+ id: string | number;
86
+ user?: {
87
+ username: string;
88
+ avatar_url?: string;
89
+ };
90
+ artwork_url?: string;
91
+ }
92
+
93
+ export async function getInfo(
94
+ url: string,
95
+ _options: ExtractorOptions = {}
96
+ ): Promise<VideoInfo> {
97
+ const clientId = await getClientId();
98
+ if (!clientId) throw new Error('Could not obtain SoundCloud client_id');
99
+ console.log(
100
+ `[Metadata] Engine: Pure-JS | Platform: SoundCloud | URL: ${url}`
101
+ );
102
+
103
+ try {
104
+ const resolveUrl = `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(url)}&client_id=${clientId}`;
105
+ const response = await secureFetch(resolveUrl);
106
+ if (!response.ok)
107
+ throw new Error(`Failed to resolve SoundCloud URL: ${response.status}`);
108
+ const track = (await response.json()) as SoundCloudTrack;
109
+
110
+ const isSnippet =
111
+ track.policy === 'SNIPPET' ||
112
+ (track.duration < 60000 && track.full_duration > 60000);
113
+ if (isSnippet) {
114
+ console.warn(
115
+ `[SoundCloud] Rejected snippet: ${track.title} (${(track.duration / 1000).toFixed(1)}s)`
116
+ );
117
+ throw new Error('This track is a preview snippet only.');
118
+ }
119
+
120
+ const transcoding =
121
+ track.media?.transcodings?.find(
122
+ (transcodingItem) => transcodingItem.format.protocol === 'progressive'
123
+ ) ||
124
+ track.media?.transcodings?.find(
125
+ (transcodingItem) => transcodingItem.format.protocol === 'hls'
126
+ );
127
+
128
+ if (!transcoding)
129
+ throw new Error('No supported stream found for this track');
130
+
131
+ return {
132
+ type: 'video',
133
+ id: track.id.toString(),
134
+ extractorKey: 'soundcloud',
135
+ isJsInfo: true,
136
+ title: track.title,
137
+ author: track.user?.username || 'Unknown',
138
+ uploader: track.user?.username || 'Unknown',
139
+ duration: track.duration / 1000,
140
+ thumbnail: track.artwork_url || track.user?.avatar_url || '',
141
+ webpageUrl: url,
142
+ formats: [
143
+ {
144
+ formatId: 'audio',
145
+ url,
146
+ extension: 'mp3',
147
+ resolution: 'Audio',
148
+ acodec: 'mp3',
149
+ abr: 128,
150
+ isAudio: true,
151
+ isVideo: false,
152
+ isMuxed: false,
153
+ },
154
+ ],
155
+ fromBrain: false,
156
+ isPartial: false,
157
+ isIsrcMatch: false,
158
+ isFullData: false,
159
+ };
160
+ } catch (error: unknown) {
161
+ console.error(
162
+ '[SoundCloud] getInfo error:',
163
+ error instanceof Error ? error.message : String(error)
164
+ );
165
+ throw error;
166
+ }
167
+ }
168
+
169
+ export async function getStream(
170
+ info: VideoInfo,
171
+ _options: ExtractorOptions = {}
172
+ ): Promise<Readable> {
173
+ const clientId = await getClientId();
174
+ if (!clientId) throw new Error('Missing client_id');
175
+
176
+ const format = info.formats[0];
177
+ const streamAuthUrl = `${format.url}?client_id=${clientId}`;
178
+ const response = await secureFetch(streamAuthUrl);
179
+ const data = (await response.json()) as { url: string };
180
+ const directUrl = data.url;
181
+
182
+ const streamResponse = await secureFetch(directUrl);
183
+ if (!streamResponse.body) throw new Error('No stream body');
184
+ return Readable.fromWeb(
185
+ streamResponse.body as import('node:stream/web').ReadableStream
186
+ );
187
+ }
backend/src/services/extractors/spotify.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getInfo as getYtInfo,
3
+ getStream as getYtStream,
4
+ } from './youtube/index.js';
5
+ import { VideoInfo, ExtractorOptions, Format } from '../../types/index.js';
6
+ import { Readable } from 'node:stream';
7
+
8
+ type SpotifyData = {
9
+ targetUrl: string;
10
+ youtubeUrl?: string;
11
+ fromBrain?: boolean;
12
+ formats?: Format[];
13
+ imageUrl?: string;
14
+ cover?: string;
15
+ thumbnail?: string;
16
+ duration?: number;
17
+ isrc?: string;
18
+ title?: string;
19
+ artist?: string;
20
+ album?: string;
21
+ previewUrl?: string;
22
+ [key: string]: unknown;
23
+ };
24
+
25
+ interface SpotifyService {
26
+ resolveSpotifyToYoutube(
27
+ url: string,
28
+ ids: string[],
29
+ onProgress: (status: string, progress: number, extra: unknown) => void
30
+ ): Promise<SpotifyData>;
31
+ }
32
+
33
+ async function getSpotifyService(): Promise<SpotifyService> {
34
+ const spotifyModule =
35
+ (await import('../spotify/index.js')) as unknown as SpotifyService;
36
+
37
+ if (
38
+ !spotifyModule ||
39
+ typeof spotifyModule.resolveSpotifyToYoutube !== 'function'
40
+ ) {
41
+ console.error('[JS-Spotify] Circular dependency error');
42
+ throw new Error('Service initialization error.');
43
+ }
44
+
45
+ return spotifyModule as SpotifyService;
46
+ }
47
+
48
+ function mapToBrainResult(spotifyData: SpotifyData): VideoInfo {
49
+ const resolvedYoutubeUrl = spotifyData.targetUrl || spotifyData.youtubeUrl;
50
+
51
+ return {
52
+ ...(spotifyData as unknown as VideoInfo),
53
+ type: 'video',
54
+ targetUrl: resolvedYoutubeUrl,
55
+ cover: spotifyData.imageUrl || (spotifyData.cover as string),
56
+ thumbnail: (spotifyData.imageUrl || spotifyData.thumbnail || '') as string,
57
+
58
+ duration: (spotifyData.duration || 0) / 1000,
59
+ extractorKey: 'spotify',
60
+ isJsInfo: true,
61
+ fromBrain: true,
62
+ isPartial: false,
63
+ isIsrcMatch: true,
64
+ isFullData: true,
65
+ };
66
+ }
67
+
68
+ function mapToJsResult(
69
+ url: string,
70
+ spotifyData: SpotifyData,
71
+ ytInfo: VideoInfo
72
+ ): VideoInfo {
73
+ return {
74
+ ...ytInfo,
75
+ type: 'video',
76
+ id: ytInfo.id,
77
+ isrc: spotifyData.isrc || undefined,
78
+ extractorKey: 'spotify',
79
+ title: spotifyData.title || ytInfo.title,
80
+ artist: spotifyData.artist || ytInfo.artist || 'Unknown Artist',
81
+ uploader: spotifyData.artist || ytInfo.uploader || 'Unknown Uploader',
82
+ album: spotifyData.album || '',
83
+ imageUrl: spotifyData.cover || spotifyData.imageUrl || ytInfo.thumbnail,
84
+ cover: spotifyData.cover || spotifyData.imageUrl || ytInfo.thumbnail,
85
+ thumbnail: spotifyData.cover || spotifyData.imageUrl || ytInfo.thumbnail,
86
+ previewUrl: spotifyData.previewUrl || null,
87
+ webpageUrl: url,
88
+ targetUrl: spotifyData.targetUrl,
89
+ isJsInfo: true,
90
+ fromBrain: false,
91
+ isPartial: false,
92
+ isIsrcMatch: false,
93
+ isFullData: false,
94
+ };
95
+ }
96
+
97
+ // spotify js extractor
98
+ export async function getInfo(
99
+ url: string,
100
+ options: ExtractorOptions = {}
101
+ ): Promise<VideoInfo | null> {
102
+ try {
103
+ const spotifyService = await getSpotifyService();
104
+
105
+ const spotifyData: SpotifyData = await spotifyService.resolveSpotifyToYoutube(
106
+ url,
107
+ [],
108
+ (status: string, progress: number, extra: unknown) => {
109
+ if (options.onProgress)
110
+ options.onProgress(
111
+ status,
112
+ progress,
113
+ typeof extra === 'string' ? extra : undefined
114
+ );
115
+ }
116
+ );
117
+
118
+ if (!spotifyData?.targetUrl) {
119
+ return null;
120
+ }
121
+
122
+ if (spotifyData.fromBrain && (spotifyData.formats?.length ?? 0) > 0) {
123
+ return mapToBrainResult(spotifyData);
124
+ }
125
+
126
+ const ytInfo = await getYtInfo(spotifyData.targetUrl);
127
+ if (!ytInfo) return null;
128
+ return mapToJsResult(url, spotifyData, ytInfo);
129
+ } catch (error: unknown) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ console.error(`[JS-Spotify] Error extracting ${url}: ${message}`);
132
+ return null;
133
+ }
134
+ }
135
+
136
+ export async function getStream(
137
+ videoInfo: VideoInfo,
138
+ options: ExtractorOptions = {}
139
+ ): Promise<Readable> {
140
+ // refresh expired urls
141
+ if (videoInfo.fromBrain) {
142
+ const liveYtInfo = await getYtInfo(videoInfo.targetUrl || '');
143
+ if (!liveYtInfo) throw new Error('Failed to refresh stream URL');
144
+ return getYtStream(liveYtInfo, options);
145
+ }
146
+ return getYtStream(videoInfo, options);
147
+ }
backend/src/services/extractors/threads/constants.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const DESKTOP_UA =
2
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36';
3
+
4
+ // cdn authorizes media against this origin
5
+ export const STREAM_REFERER = 'https://www.threads.com/';
6
+
7
+ export const HEADERS = {
8
+ 'User-Agent': DESKTOP_UA,
9
+ Accept:
10
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
11
+ 'Accept-Language': 'en-US,en;q=0.5',
12
+ 'Sec-Fetch-Mode': 'navigate',
13
+ 'Sec-Fetch-Site': 'none',
14
+ 'Sec-Fetch-Dest': 'document',
15
+ 'Sec-Fetch-User': '?1',
16
+ 'Upgrade-Insecure-Requests': '1',
17
+ };
18
+
19
+ // post|t shortcode from permalink
20
+ export const ID_REGEX = /\/(?:post|t)\/([A-Za-z0-9_-]+)/u;
backend/src/services/extractors/threads/fetcher.ts ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HEADERS, DESKTOP_UA } from './constants.js';
2
+ import { secureFetch } from '../../../utils/network/security.util.js';
3
+
4
+ type FetchOptions = {
5
+ cookie?: string;
6
+ };
7
+
8
+ type FetchResult = { html: string; targetUrl: string };
9
+
10
+ // public embed endpoint, often ungated
11
+ function buildEmbedUrl(url: string): string {
12
+ const clean = url.split('?')[0].replace(/\/+$/u, '');
13
+ return `${clean}/embed`;
14
+ }
15
+
16
+ async function fetchPage(
17
+ target: string,
18
+ options: FetchOptions
19
+ ): Promise<FetchResult | null> {
20
+ const cookie = typeof options.cookie === 'string' ? options.cookie : null;
21
+ const response = await secureFetch(target, {
22
+ headers: {
23
+ ...HEADERS,
24
+ ...(cookie && { Cookie: cookie }),
25
+ },
26
+ signal: AbortSignal.timeout(10000),
27
+ });
28
+ if (!response.ok) return null;
29
+ return { html: await response.text(), targetUrl: response.url };
30
+ }
31
+
32
+ export function fetchHtml(
33
+ url: string,
34
+ options: FetchOptions
35
+ ): Promise<FetchResult | null> {
36
+ return fetchPage(url, options);
37
+ }
38
+
39
+ export function fetchEmbed(
40
+ url: string,
41
+ options: FetchOptions
42
+ ): Promise<FetchResult | null> {
43
+ return fetchPage(buildEmbedUrl(url), options);
44
+ }
45
+
46
+ export async function fetchFileSize(url: string): Promise<number | undefined> {
47
+ try {
48
+ const headResponse = await secureFetch(url, {
49
+ method: 'HEAD',
50
+ headers: { 'User-Agent': DESKTOP_UA },
51
+ signal: AbortSignal.timeout(5000),
52
+ });
53
+ if (headResponse.ok) {
54
+ const contentLength = headResponse.headers.get('content-length');
55
+ if (contentLength) return parseInt(contentLength, 10);
56
+ }
57
+ } catch (error: unknown) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ console.debug('[ThreadsExtractor] Size fetch error:', message);
60
+ }
61
+ return undefined;
62
+ }
backend/src/services/extractors/threads/index.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getQuantumStream } from '../../../utils/network/proxy.util.js';
2
+ import { VideoInfo, Format, ExtractorOptions } from '../../../types/index.js';
3
+ import { Readable } from 'node:stream';
4
+ import { DESKTOP_UA, STREAM_REFERER } from './constants.js';
5
+ import { fetchHtml, fetchEmbed, fetchFileSize } from './fetcher.js';
6
+ import { parseHtml } from './parser.js';
7
+ import { normalizeVideoInfo } from './normalizer.js';
8
+
9
+ function extract(html: string, targetUrl: string): VideoInfo | null {
10
+ return normalizeVideoInfo(targetUrl, parseHtml(html, targetUrl));
11
+ }
12
+
13
+ export async function getInfo(
14
+ url: string,
15
+ options: ExtractorOptions = {}
16
+ ): Promise<VideoInfo | null> {
17
+ try {
18
+ const primary = await fetchHtml(url, options);
19
+ let videoInfo = primary ? extract(primary.html, primary.targetUrl) : null;
20
+
21
+ // walled/empty page: try public embed
22
+ if (!videoInfo || videoInfo.formats.length === 0) {
23
+ const embed = await fetchEmbed(url, options);
24
+ const alt = embed ? extract(embed.html, embed.targetUrl) : null;
25
+ if (alt && alt.formats.length > 0) videoInfo = alt;
26
+ }
27
+
28
+ if (!videoInfo || videoInfo.formats.length === 0) return null;
29
+
30
+ // fetch size
31
+ for (let i = 0; i < videoInfo.formats.length; i += 3) {
32
+ const batch = videoInfo.formats.slice(i, i + 3);
33
+ await Promise.all(
34
+ batch.map(async (format: Format) => {
35
+ if (format.url) {
36
+ const size = await fetchFileSize(format.url);
37
+ if (size) format.filesize = size;
38
+ }
39
+ })
40
+ );
41
+ }
42
+
43
+ return videoInfo;
44
+ } catch (error: unknown) {
45
+ const message = error instanceof Error ? error.message : String(error);
46
+ console.error(`[JS-Threads] Error extracting ${url}: ${message}`);
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function getStream(
52
+ videoInfo: VideoInfo,
53
+ options: ExtractorOptions = {}
54
+ ): Promise<Readable> {
55
+ const targetFormat =
56
+ videoInfo.formats.find(
57
+ (formatItem: Format) =>
58
+ String(formatItem.formatId) === String(options.formatId)
59
+ ) || videoInfo.formats[0];
60
+ if (!targetFormat?.url) throw new Error('No stream URL found');
61
+
62
+ return Promise.resolve(
63
+ getQuantumStream(targetFormat.url, {
64
+ 'User-Agent': DESKTOP_UA,
65
+ Referer: STREAM_REFERER,
66
+ Origin: 'https://www.threads.com',
67
+ Accept: '*/*',
68
+ 'Accept-Language': 'en-US,en;q=0.9',
69
+ Range: 'bytes=0-',
70
+ 'Sec-Fetch-Dest': 'video',
71
+ 'Sec-Fetch-Mode': 'cors',
72
+ 'Sec-Fetch-Site': 'cross-site',
73
+ })
74
+ );
75
+ }
backend/src/services/extractors/threads/json-extractor.ts ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // json-block walk; regex/og stays as fallback
2
+ import { ThreadsRawFormat, ThreadsParsed } from './types.js';
3
+
4
+ type Obj = Record<string, unknown>;
5
+
6
+ // no id here; parser adds it
7
+ type ThreadsJsonResult = Omit<ThreadsParsed, 'id'>;
8
+
9
+ const str = (value: unknown): string | undefined =>
10
+ typeof value === 'string' && value ? value : undefined;
11
+
12
+ const num = (value: unknown): number | undefined =>
13
+ typeof value === 'number' ? value : undefined;
14
+
15
+ // walk every object node in parsed json
16
+ function walk(node: unknown, visit: (obj: Obj) => void): void {
17
+ if (!node || typeof node !== 'object') return;
18
+ if (Array.isArray(node)) {
19
+ for (const item of node) walk(item, visit);
20
+ return;
21
+ }
22
+ visit(node as Obj);
23
+ for (const value of Object.values(node)) walk(value, visit);
24
+ }
25
+
26
+ // highest-resolution entry from a versions array
27
+ function bestVideo(
28
+ versions: unknown
29
+ ): { url: string; width?: number; height?: number } | null {
30
+ if (!Array.isArray(versions)) return null;
31
+ let best: { url: string; width?: number; height?: number } | null = null;
32
+ for (const entry of versions) {
33
+ if (!entry || typeof entry !== 'object') continue;
34
+ const url = str((entry as Obj).url);
35
+ if (!url) continue;
36
+ const width = num((entry as Obj).width);
37
+ if (!best || (width ?? 0) > (best.width ?? 0)) {
38
+ best = { url, width, height: num((entry as Obj).height) };
39
+ }
40
+ }
41
+ return best;
42
+ }
43
+
44
+ // highest-resolution image candidate url
45
+ function bestImage(imageVersions: unknown): string | undefined {
46
+ if (!imageVersions || typeof imageVersions !== 'object') return undefined;
47
+ const candidates = (imageVersions as Obj).candidates;
48
+ if (!Array.isArray(candidates)) return undefined;
49
+ let best: { url: string; width: number } | undefined;
50
+ for (const candidate of candidates) {
51
+ if (!candidate || typeof candidate !== 'object') continue;
52
+ const url = str((candidate as Obj).url);
53
+ const width = num((candidate as Obj).width) ?? 0;
54
+ if (url && (!best || width > best.width)) best = { url, width };
55
+ }
56
+ return best?.url;
57
+ }
58
+
59
+ function captionText(value: unknown): string | undefined {
60
+ if (value && typeof value === 'object') return str((value as Obj).text);
61
+ return str(value);
62
+ }
63
+
64
+ // pull uri from {image:{uri}} or {uri} node
65
+ function nestedUri(value: unknown): string | undefined {
66
+ if (!value || typeof value !== 'object') return undefined;
67
+ const node = value as Obj;
68
+ const image = node.image as Obj | undefined;
69
+ return str(image?.uri) ?? str(node.uri);
70
+ }
71
+
72
+ export function extractFromJson(html: string): ThreadsJsonResult | null {
73
+ const blocks = html.matchAll(
74
+ /<script[^>]+type="application\/json"[^>]*>([\s\S]*?)<\/script>/gu
75
+ );
76
+
77
+ const formats: ThreadsRawFormat[] = [];
78
+ const seen = new Set<string>();
79
+ const photos: string[] = [];
80
+ let title = '';
81
+ let uploader = '';
82
+ let thumbnail = '';
83
+
84
+ const addVideo = (
85
+ url: string | undefined,
86
+ id: string,
87
+ width?: number,
88
+ height?: number
89
+ ) => {
90
+ if (!url || seen.has(url)) return;
91
+ if (formats.some((format) => format.format_id === id)) return;
92
+ seen.add(url);
93
+ formats.push({
94
+ url,
95
+ format_id: id,
96
+ ext: 'mp4',
97
+ vcodec: 'h264',
98
+ acodec: 'aac',
99
+ width,
100
+ height,
101
+ });
102
+ };
103
+
104
+ for (const block of blocks) {
105
+ let data: unknown;
106
+ try {
107
+ data = JSON.parse(block[1]);
108
+ } catch {
109
+ continue;
110
+ }
111
+ walk(data, (obj) => {
112
+ // ig/threads adaptive media
113
+ const video = bestVideo(obj.video_versions);
114
+ if (video) addVideo(video.url, 'hd', video.width, video.height);
115
+
116
+ // fb-style direct urls; dims co-located on node
117
+ const dimW = num(obj.original_width);
118
+ const dimH = num(obj.original_height);
119
+ addVideo(
120
+ str(obj.browser_native_hd_url) ?? str(obj.playable_url_quality_hd),
121
+ 'hd',
122
+ dimW,
123
+ dimH
124
+ );
125
+ addVideo(
126
+ str(obj.browser_native_sd_url) ?? str(obj.playable_url),
127
+ 'sd',
128
+ dimW,
129
+ dimH
130
+ );
131
+
132
+ if (!title) title = captionText(obj.caption) ?? str(obj.title) ?? '';
133
+ if (!uploader) {
134
+ const user = obj.user as Obj | undefined;
135
+ uploader =
136
+ str(user?.full_name) ??
137
+ str(user?.username) ??
138
+ str(obj.username) ??
139
+ '';
140
+ }
141
+ const image = bestImage(obj.image_versions2);
142
+ if (image) {
143
+ if (!thumbnail) thumbnail = image;
144
+ if (!photos.includes(image)) photos.push(image);
145
+ }
146
+ // fb-style poster for video posts
147
+ if (!thumbnail) {
148
+ thumbnail =
149
+ nestedUri(obj.preferred_thumbnail) ??
150
+ str(obj.thumbnail_url) ??
151
+ str(obj.cover_photo_url) ??
152
+ '';
153
+ }
154
+ });
155
+ }
156
+
157
+ // image post: no video found, use candidates
158
+ if (formats.length === 0 && photos.length > 0) {
159
+ const single = photos.length === 1;
160
+ photos.forEach((url, index) => {
161
+ formats.push({
162
+ url,
163
+ format_id: single ? 'photo' : `photo_${index}`,
164
+ ext: 'jpeg',
165
+ });
166
+ });
167
+ }
168
+
169
+ if (formats.length === 0) return null;
170
+ return { formats, title, uploader, thumbnail };
171
+ }
backend/src/services/extractors/threads/normalizer.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { VideoInfo, Format } from '../../../types/index.js';
2
+ import { normalizeTitle, normalizeArtist } from '../../social.service.js';
3
+ import { ThreadsParsed } from './types.js';
4
+
5
+ export function normalizeVideoInfo(
6
+ url: string,
7
+ parsedData: ThreadsParsed | null
8
+ ): VideoInfo | null {
9
+ if (!parsedData) return null;
10
+
11
+ const formats: Format[] = parsedData.formats.map((formatItem, index) => {
12
+ const isPhoto = formatItem.format_id?.startsWith('photo') ?? false;
13
+ const vcodec = formatItem.vcodec ?? (isPhoto ? undefined : 'h264');
14
+ const acodec = formatItem.acodec ?? (isPhoto ? undefined : 'aac');
15
+ const { width, height } = formatItem;
16
+
17
+ // label when dimensions are unavailable
18
+ const tier =
19
+ formatItem.format_id === 'hd'
20
+ ? 'HD'
21
+ : formatItem.format_id === 'sd'
22
+ ? 'SD'
23
+ : isPhoto
24
+ ? 'Photo'
25
+ : undefined;
26
+
27
+ const quality = height ? `${height}p` : tier;
28
+
29
+ return {
30
+ formatId: formatItem.format_id || `th_${index}`,
31
+ url: formatItem.url,
32
+ extension: formatItem.ext ?? 'mp4',
33
+ resolution: width && height ? `${width}x${height}` : (tier ?? 'Source'),
34
+ quality,
35
+ width,
36
+ height,
37
+ vcodec,
38
+ acodec,
39
+ isAudio: Boolean(acodec && acodec !== 'none'),
40
+ isVideo: Boolean(vcodec && vcodec !== 'none'),
41
+ isMuxed: Boolean(
42
+ acodec && acodec !== 'none' && vcodec && vcodec !== 'none'
43
+ ),
44
+ };
45
+ });
46
+
47
+ if (formats.length === 0) return null;
48
+
49
+ const info: VideoInfo = {
50
+ type: 'video',
51
+ id: parsedData.id || url,
52
+ title: parsedData.title || 'Threads Post',
53
+ uploader: parsedData.uploader || 'Threads User',
54
+ thumbnail: parsedData.thumbnail || undefined,
55
+ webpageUrl: url,
56
+ formats,
57
+ extractorKey: 'threads',
58
+ isJsInfo: true,
59
+ fromBrain: false,
60
+ isPartial: false,
61
+ isIsrcMatch: false,
62
+ isFullData: false,
63
+ };
64
+
65
+ // make caption authoritative over og:title
66
+ if (parsedData.title) {
67
+ info.metascraper = { title: parsedData.title };
68
+ }
69
+
70
+ info.title = normalizeTitle(info as unknown as Record<string, unknown>);
71
+ info.uploader = normalizeArtist(info as unknown as Record<string, unknown>);
72
+
73
+ return info;
74
+ }
backend/src/services/extractors/threads/parser.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ID_REGEX } from './constants.js';
2
+ import { decode, decodeHtmlEntities } from '../facebook/utils.js';
3
+ import { extractFromJson } from './json-extractor.js';
4
+ import { ThreadsRawFormat, ThreadsParsed } from './types.js';
5
+
6
+ function matchContent(html: string, property: string): string | undefined {
7
+ const pattern = new RegExp(
8
+ `<meta property="${property}" content="([^"]*)"`,
9
+ 'u'
10
+ );
11
+ const match = html.match(pattern);
12
+ return match ? decodeHtmlEntities(match[1]) : undefined;
13
+ }
14
+
15
+ function parseOgVideo(html: string): string | undefined {
16
+ return (
17
+ matchContent(html, 'og:video:secure_url') ??
18
+ matchContent(html, 'og:video:url') ??
19
+ matchContent(html, 'og:video')
20
+ );
21
+ }
22
+
23
+ // strip "(@handle) on Threads" noise
24
+ function parseOgAuthor(html: string): string | undefined {
25
+ const title = matchContent(html, 'og:title');
26
+ if (!title) return undefined;
27
+ const handle = title.match(/\(@([A-Za-z0-9_.]+)\)/u);
28
+ if (handle) return handle[1];
29
+ return title.replace(/\s+on Threads.*$/iu, '').trim() || undefined;
30
+ }
31
+
32
+ export function parseHtml(html: string, url: string): ThreadsParsed {
33
+ const idMatch = url.match(ID_REGEX);
34
+ const code = idMatch ? idMatch[1] : null;
35
+ const ogCaption = matchContent(html, 'og:description');
36
+ const ogImage = matchContent(html, 'og:image');
37
+ const ogAuthor = parseOgAuthor(html);
38
+
39
+ // json-first; og regex fallback
40
+ const json = extractFromJson(html);
41
+ if (json) {
42
+ return {
43
+ id: code,
44
+ title: json.title || ogCaption || '',
45
+ uploader: json.uploader || ogAuthor || '',
46
+ thumbnail: json.thumbnail || ogImage || '',
47
+ formats: json.formats,
48
+ };
49
+ }
50
+
51
+ const formats: ThreadsRawFormat[] = [];
52
+ const ogVideo = parseOgVideo(html);
53
+ if (ogVideo)
54
+ formats.push({ url: decode(ogVideo), format_id: 'hd', ext: 'mp4' });
55
+
56
+ // photo only when no video found
57
+ if (formats.length === 0 && ogImage) {
58
+ formats.push({ url: ogImage, format_id: 'photo', ext: 'jpeg' });
59
+ }
60
+
61
+ return {
62
+ id: code,
63
+ title: ogCaption || '',
64
+ uploader: ogAuthor || '',
65
+ thumbnail: ogImage || '',
66
+ formats,
67
+ };
68
+ }
backend/src/services/extractors/threads/types.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // shared contract across parse + normalize
2
+
3
+ // snake_case mirrors threads/ig wire keys
4
+ export interface ThreadsRawFormat {
5
+ url: string;
6
+ format_id?: string;
7
+ ext?: string;
8
+ vcodec?: string;
9
+ acodec?: string;
10
+ width?: number;
11
+ height?: number;
12
+ }
13
+
14
+ // parsed media and page meta
15
+ export interface ThreadsParsed {
16
+ id: string | null;
17
+ title: string;
18
+ uploader: string;
19
+ thumbnail: string;
20
+ formats: ThreadsRawFormat[];
21
+ }
backend/src/services/extractors/tiktok.ts ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { secureFetch } from '../../utils/network/security.util.js';
2
+ import { VideoInfo, Format, ExtractorOptions } from '../../types/index.js';
3
+ import { Readable } from 'node:stream';
4
+ import { normalizeTitle, normalizeArtist } from '../social.service.js';
5
+
6
+ const DESKTOP_UA =
7
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
8
+
9
+ const cookieCache = new Map<string, string>();
10
+
11
+ interface TikTokPlayAddr {
12
+ Width?: number;
13
+ Height?: number;
14
+ DataSize?: number;
15
+ UrlList?: string[];
16
+ }
17
+ interface TikTokBitrate {
18
+ Bitrate?: number;
19
+ GearName?: string;
20
+ CodecType?: string;
21
+ PlayAddr?: TikTokPlayAddr;
22
+ }
23
+ interface TikTokVideo {
24
+ duration?: number;
25
+ width?: number;
26
+ height?: number;
27
+ cover?: string;
28
+ originCover?: string;
29
+ playAddr?: string;
30
+ codecType?: string;
31
+ bitrateInfo?: TikTokBitrate[];
32
+ }
33
+ interface TikTokItem {
34
+ id?: string;
35
+ desc?: string;
36
+ author?: { uniqueId?: string; nickname?: string };
37
+ video?: TikTokVideo;
38
+ imagePost?: { images?: { imageURL?: { urlList?: string[] } }[] };
39
+ }
40
+
41
+ // extract embedded rehydration JSON
42
+ function parseUniversalData(html: string): TikTokItem | null {
43
+ const match = html.match(
44
+ /<script\b[^>]*\bid="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>([\s\S]*?)<\/script>/u
45
+ );
46
+ if (!match) return null;
47
+ try {
48
+ const data = JSON.parse(match[1]) as {
49
+ __DEFAULT_SCOPE__?: {
50
+ 'webapp.video-detail'?: { itemInfo?: { itemStruct?: TikTokItem } };
51
+ };
52
+ };
53
+ return (
54
+ data.__DEFAULT_SCOPE__?.['webapp.video-detail']?.itemInfo?.itemStruct ??
55
+ null
56
+ );
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function buildVideoFormats(video: TikTokVideo): Format[] {
63
+ const mapped = (video.bitrateInfo ?? [])
64
+ .map((rung): Format | null => {
65
+ const url = rung.PlayAddr?.UrlList?.[0];
66
+ if (!url) return null;
67
+ const width = rung.PlayAddr?.Width ?? video.width;
68
+ const height = rung.PlayAddr?.Height ?? video.height;
69
+ const short = width && height ? Math.min(width, height) : undefined;
70
+ const isHevc = rung.CodecType?.includes('265') ?? false;
71
+ return {
72
+ formatId: rung.GearName || `${short ?? 'src'}p`,
73
+ url,
74
+ extension: 'mp4',
75
+ width,
76
+ height,
77
+ resolution: width && height ? `${width}x${height}` : undefined,
78
+ quality: short ? `${short}p${isHevc ? ' (HEVC)' : ''}` : undefined,
79
+ vcodec: isHevc ? 'hevc' : 'h264',
80
+ acodec: 'aac',
81
+ tbr: rung.Bitrate ? Math.round(rung.Bitrate / 1000) : undefined,
82
+ filesize:
83
+ typeof rung.PlayAddr?.DataSize === 'number'
84
+ ? rung.PlayAddr.DataSize
85
+ : undefined,
86
+ isMuxed: true,
87
+ isVideo: true,
88
+ isAudio: false,
89
+ };
90
+ })
91
+ .filter((format): format is Format => format !== null);
92
+
93
+ // dedup by resolution; prefer h264
94
+ mapped.sort((lhs, rhs) => {
95
+ const byHeight = (rhs.height ?? 0) - (lhs.height ?? 0);
96
+ if (byHeight !== 0) return byHeight;
97
+ if (lhs.vcodec !== rhs.vcodec) return lhs.vcodec === 'h264' ? -1 : 1;
98
+ return (rhs.tbr ?? 0) - (lhs.tbr ?? 0);
99
+ });
100
+ const seen = new Set<number>();
101
+ const deduped = mapped.filter((format) => {
102
+ const key = format.height ?? 0;
103
+ if (seen.has(key)) return false;
104
+ seen.add(key);
105
+ return true;
106
+ });
107
+
108
+ // fallback to single muxed url
109
+ if (deduped.length === 0 && video.playAddr) {
110
+ deduped.push({
111
+ formatId: 'source',
112
+ url: video.playAddr,
113
+ extension: 'mp4',
114
+ width: video.width,
115
+ height: video.height,
116
+ resolution:
117
+ video.width && video.height
118
+ ? `${video.width}x${video.height}`
119
+ : undefined,
120
+ vcodec: video.codecType?.includes('265') ? 'hevc' : 'h264',
121
+ acodec: 'aac',
122
+ isMuxed: true,
123
+ isVideo: true,
124
+ isAudio: false,
125
+ });
126
+ }
127
+ return deduped;
128
+ }
129
+
130
+ function buildPhotoFormats(item: TikTokItem): Format[] {
131
+ return (item.imagePost?.images ?? [])
132
+ .map((image, index): Format | null => {
133
+ const url = image.imageURL?.urlList?.[0];
134
+ if (!url) return null;
135
+ return {
136
+ formatId: `image_${index}`,
137
+ url,
138
+ extension: 'jpeg',
139
+ isMuxed: false,
140
+ isVideo: false,
141
+ isAudio: false,
142
+ };
143
+ })
144
+ .filter((format): format is Format => format !== null);
145
+ }
146
+
147
+ export async function getInfo(
148
+ url: string,
149
+ _options: ExtractorOptions = {}
150
+ ): Promise<VideoInfo | null> {
151
+ try {
152
+ const response = await secureFetch(url, {
153
+ headers: {
154
+ 'User-Agent': DESKTOP_UA,
155
+ 'Accept-Language': 'en-US,en;q=0.9',
156
+ },
157
+ });
158
+ if (!response.ok) return null;
159
+
160
+ const cookieHeader = (response.headers.getSetCookie?.() ?? [])
161
+ .map((entry) => entry.split(';')[0])
162
+ .join('; ');
163
+
164
+ // null lets pipeline fall back to yt-dlp
165
+ const item = parseUniversalData(await response.text());
166
+ if (!item) return null;
167
+
168
+ const isPhoto = Boolean(item.imagePost?.images?.length);
169
+ const formats = isPhoto
170
+ ? buildPhotoFormats(item)
171
+ : item.video
172
+ ? buildVideoFormats(item.video)
173
+ : [];
174
+ if (formats.length === 0) return null;
175
+
176
+ const info: VideoInfo = {
177
+ type: 'video',
178
+ id: item.id || url,
179
+ title: item.desc || 'TikTok Video',
180
+ uploader:
181
+ item.author?.nickname || item.author?.uniqueId || 'TikTok User',
182
+ webpageUrl: response.url,
183
+ thumbnail: item.video?.cover || item.video?.originCover || undefined,
184
+ duration: item.video?.duration,
185
+ formats,
186
+ extractorKey: 'tiktok',
187
+ isJsInfo: true,
188
+ fromBrain: false,
189
+ isPartial: false,
190
+ isIsrcMatch: false,
191
+ isFullData: !isPhoto,
192
+ };
193
+
194
+ info.title = normalizeTitle(info as unknown as Record<string, unknown>);
195
+ info.uploader = normalizeArtist(info as unknown as Record<string, unknown>);
196
+
197
+ if (cookieHeader) {
198
+ if (cookieCache.size >= 100) {
199
+ const oldest = cookieCache.keys().next().value;
200
+ if (oldest !== undefined) cookieCache.delete(oldest);
201
+ }
202
+ cookieCache.set(info.id, cookieHeader);
203
+ }
204
+
205
+ return info;
206
+ } catch (error: unknown) {
207
+ const message = error instanceof Error ? error.message : String(error);
208
+ console.error(`[JS-TikTok] Error extracting ${url}: ${message}`);
209
+ return null;
210
+ }
211
+ }
212
+
213
+ export async function getStream(
214
+ videoInfo: VideoInfo,
215
+ options: ExtractorOptions = {}
216
+ ): Promise<Readable> {
217
+ const selected =
218
+ videoInfo.formats.find(
219
+ (format) => String(format.formatId) === String(options.formatId)
220
+ ) || videoInfo.formats[0];
221
+ if (!selected?.url) throw new Error('No stream URL found');
222
+
223
+ // cookies + referer + range authorize cdn
224
+ const headers: Record<string, string> = {
225
+ 'User-Agent': DESKTOP_UA,
226
+ Referer: 'https://www.tiktok.com/',
227
+ Range: 'bytes=0-',
228
+ };
229
+ const cookie = cookieCache.get(videoInfo.id);
230
+ if (cookie) headers.Cookie = cookie;
231
+
232
+ const response = await secureFetch(selected.url, { headers });
233
+ if (!response.ok || !response.body) {
234
+ throw new Error(`TikTok stream failed: HTTP ${response.status}`);
235
+ }
236
+ return Readable.fromWeb(
237
+ response.body as import('node:stream/web').ReadableStream
238
+ );
239
+ }