Spaces:
Paused
Paused
nexstream-deploy commited on
Commit ·
2276d59
1
Parent(s): cbe4c34
deploy: nexstream backend (experiment)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +20 -0
- .gitattributes +0 -35
- Dockerfile +48 -0
- README.md +28 -5
- backend/.dockerignore +20 -0
- backend/.env.example +58 -0
- backend/.gitignore +4 -0
- backend/Dockerfile +45 -0
- backend/README.md +100 -0
- backend/deno-wrapper +2 -0
- backend/eslint.config.js +114 -0
- backend/package-lock.json +0 -0
- backend/package.json +113 -0
- backend/scripts/bench-convert.js +219 -0
- backend/scripts/ci-test.sh +129 -0
- backend/scripts/termux-shim.js +35 -0
- backend/scripts/test-sequential.sh +58 -0
- backend/src/app.ts +548 -0
- backend/src/controllers/keychanger.controller.ts +346 -0
- backend/src/controllers/remix.controller.ts +703 -0
- backend/src/controllers/video.controller.ts +401 -0
- backend/src/instrument.ts +37 -0
- backend/src/routes/keychanger.routes.ts +17 -0
- backend/src/routes/remix.routes.ts +29 -0
- backend/src/routes/video.routes.ts +26 -0
- backend/src/services/extract.service.ts +390 -0
- backend/src/services/extractors/bluesky.ts +170 -0
- backend/src/services/extractors/facebook/constants.ts +80 -0
- backend/src/services/extractors/facebook/fetcher.ts +46 -0
- backend/src/services/extractors/facebook/index.ts +86 -0
- backend/src/services/extractors/facebook/json-extractor.ts +90 -0
- backend/src/services/extractors/facebook/normalizer.ts +71 -0
- backend/src/services/extractors/facebook/parser.ts +158 -0
- backend/src/services/extractors/facebook/types.ts +19 -0
- backend/src/services/extractors/facebook/utils.ts +32 -0
- backend/src/services/extractors/index.ts +320 -0
- backend/src/services/extractors/instagram/fetcher.ts +61 -0
- backend/src/services/extractors/instagram/index.ts +75 -0
- backend/src/services/extractors/instagram/normalizer.ts +50 -0
- backend/src/services/extractors/instagram/parser.ts +70 -0
- backend/src/services/extractors/soundcloud.ts +187 -0
- backend/src/services/extractors/spotify.ts +147 -0
- backend/src/services/extractors/threads/constants.ts +20 -0
- backend/src/services/extractors/threads/fetcher.ts +62 -0
- backend/src/services/extractors/threads/index.ts +75 -0
- backend/src/services/extractors/threads/json-extractor.ts +171 -0
- backend/src/services/extractors/threads/normalizer.ts +74 -0
- backend/src/services/extractors/threads/parser.ts +68 -0
- backend/src/services/extractors/threads/types.ts +21 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(/&/gu, '&')
|
| 11 |
+
.replace(/"/gu, '"')
|
| 12 |
+
.replace(/</gu, '<')
|
| 13 |
+
.replace(/>/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(/&/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 |
+
}
|