Compare commits
19
Commits
fd7515f8c2
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771297cdf1 | ||
|
|
fd0bc092bd | ||
|
|
38e52374c9 | ||
|
|
ecdfc95ff3 | ||
|
|
09600118a5 | ||
|
|
6603ad556e | ||
|
|
e23bd6b235 | ||
|
|
0d46a9a099 | ||
|
|
e0ef1dee77 | ||
|
|
00068e8ed2 | ||
|
|
f9f2934154 | ||
|
|
1f51ec7a2b | ||
|
|
d2e491c762 | ||
|
|
2b5f68752f | ||
|
|
65f92abe7b | ||
|
|
837dc954c2 | ||
|
|
27ba48d181 | ||
|
|
f949566f76 | ||
|
|
1d27d22581 |
@@ -33,5 +33,10 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"frontend-design@claude-plugins-official": true,
|
||||||
|
"ui-ux-pro-max@ui-ux-pro-max-skill": true,
|
||||||
|
"superpowers@claude-plugins-official": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
DATABASE_URL="mysql://ombrora:ombrora@localhost:3306/ombrora"
|
DATABASE_URL="mysql://ombrora:ombrora@localhost:3306/ombrora"
|
||||||
STORAGE_PATH="/absolute/path/to/local/storage"
|
STORAGE_PATH="/absolute/path/to/local/storage"
|
||||||
|
NEXT_PUBLIC_BASE_URL="https://example.com"
|
||||||
|
|||||||
+2
-1
@@ -1,8 +1,9 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
.env
|
.env
|
||||||
|
.env.prod
|
||||||
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
storage/
|
storage/
|
||||||
worker/worker.pid
|
|
||||||
# Added by code-review-graph
|
# Added by code-review-graph
|
||||||
.code-review-graph/
|
.code-review-graph/
|
||||||
|
|||||||
@@ -10,23 +10,22 @@
|
|||||||
- Utilise Docker & Docker-Compose pour la base de données (MariaDB)
|
- Utilise Docker & Docker-Compose pour la base de données (MariaDB)
|
||||||
- On développe tout en NodeJS notamment avec : NextJS & Prisma pour la partie backend et React pour la partie frontend
|
- On développe tout en NodeJS notamment avec : NextJS & Prisma pour la partie backend et React pour la partie frontend
|
||||||
- Utilise Tailwind pour le CSS
|
- Utilise Tailwind pour le CSS
|
||||||
- L'application est toujours multilangue (EN + FR)
|
- Si tu dois utiliser des Workers, gère le avec Passenger (pm2)
|
||||||
|
- L'application est toujours multilangue (EN, FR, ES, IT — voir `messages/`)
|
||||||
|
|
||||||
## Application : Ombrora-YTDLP
|
## Application : Ombrora-YTDLP
|
||||||
|
|
||||||
- Interface web publique permettant de soumettre des URLs de videos a telecharger via yt-dlp.
|
- Interface web publique permettant de soumettre des URLs de videos a telecharger via yt-dlp, avec pages SEO par plateforme (`/[locale]/[slug]`, ex. `/youtube-downloader`, definies dans `src/lib/downloader-platforms.ts`) et une page `supported-sites` listant tous les sites geres par yt-dlp.
|
||||||
- Les telechargements sont geres via une file d'attente (queue) executee par Passenger en arriere-plan avec support du multithreading.
|
- Avant soumission, l'URL est analysee en temps reel via un probe yt-dlp (`POST /api/probe`, `src/lib/ytdlp-probe.ts`, `yt-dlp -J`) : les options presentees a l'utilisateur (qualite, sous-titres disponibles, decoupe, qualite audio MP3) sont derivees de cette analyse et non d'une liste statique ; la soumission est bloquee tant que le probe n'a pas reussi.
|
||||||
|
- Les telechargements sont geres via une file d'attente (queue) executee par un worker Node.js (`worker/`, lance via `tsx`) supervise par pm2, avec support du multithreading (concurrence configurable via `WORKER_CONCURRENCY`).
|
||||||
- Chaque telechargement est stocke en base de donnees (MariaDB via Prisma). **Aucune entree n'est jamais supprimee** : la DB conserve l'historique complet de tous les telechargements (statuts, erreurs, metadata).
|
- Chaque telechargement est stocke en base de donnees (MariaDB via Prisma). **Aucune entree n'est jamais supprimee** : la DB conserve l'historique complet de tous les telechargements (statuts, erreurs, metadata).
|
||||||
- Le worker Passenger est responsable de dequeuer et d'executer yt-dlp en parallele selon la capacite configuree.
|
- `yt-dlp` est le zipapp Python officiel (`bin/yt-dlp`), pas le binaire standalone PyInstaller : ce dernier s'auto-extrait dans `/tmp` et echoue avec "failed to map segment from shared object" quand `/tmp` est monte `noexec` (cas d'o2switch). Le zipapp est toujours invoque explicitement via `PYTHON_BIN` (`src/lib/ytdlp.ts`) plutot que via son shebang, car le `python3` systeme d'o2switch (3.6) est trop ancien pour yt-dlp (3.10+ requis) — sur o2switch, `PYTHON_BIN=/opt/alt/python311/bin/python3`.
|
||||||
|
|
||||||
## Deployment (o2switch)
|
## Deployment (o2switch)
|
||||||
|
|
||||||
- o2switch is shared hosting: no compiler toolchain, no root access. Any dependency with a native/binary component must ship as a precompiled binary — it cannot be built from source on the server.
|
- o2switch is shared hosting: no compiler toolchain, no root access. Any dependency with a native/binary component must ship as a precompiled binary — it cannot be built from source on the server. This is why `yt-dlp`/`ffmpeg`/`ffprobe` ship as precompiled binaries in `bin/` (or `BIN_DIR`) rather than as npm deps.
|
||||||
- Before adding any new dependency with native bindings, confirm it publishes prebuilt binaries for o2switch's platform/arch — otherwise it will fail to install or run there.
|
- Before adding any new dependency with native bindings, confirm it publishes prebuilt binaries for o2switch's platform/arch — otherwise it will fail to install or run there.
|
||||||
- **The o2switch nodevenv/Passenger setup ("Setup Node.js App" in cPanel) only supports a single `package.json`/`node_modules` for the whole registered app — not one per subfolder.** Verified by directly debugging a failed `frontend/` build: `npm install --prefix frontend --include=dev` (root's own `build` script) and even a plain `npm install` run with `cd frontend` first (confirmed via `pwd` to genuinely be inside `frontend/`) both completed "successfully" (correct, unmodified `frontend/package-lock.json`, real `resolved` entries for every package) yet never created a `frontend/node_modules` directory on the server at all. Meanwhile `vite`/`@vitejs/plugin-react` (already present as root devDependencies) resolved fine during the build — only packages that exist *exclusively* in `frontend/package.json` (`react-router-dom`, `react-i18next`, `i18next`, `@phosphor-icons/react`) failed to resolve, with Vite/Rolldown erroring `Rolldown failed to resolve import "react-router-dom"`.
|
- Single Next.js app with a single `package.json`/`node_modules` at the repo root — there is no separate frontend build or subfolder to keep in sync. Deployed via cPanel "Setup Node.js App" (Passenger) for the web app; the worker is a separate process that Passenger does not supervise, kept alive by pm2 instead (`ecosystem.config.cjs`, `npm run worker:pm2:*`). Full deployment steps (including `npm run deploy` / `scripts/deploy.sh`) are documented in `README.md`.
|
||||||
- **Fix (applied):** every runtime package `frontend/src/**` imports must also be listed in the **root** `package.json`'s `dependencies` (not just `frontend/package.json`'s) — `react`/`react-dom` already were; `react-router-dom`, `react-i18next`, `i18next`, `@phosphor-icons/react` were added there too. Root's single `node_modules` is an ancestor directory of `frontend/src/`, so Node/Vite's normal upward `node_modules` resolution walk finds them there even with no `frontend/node_modules` on the server.
|
|
||||||
- `frontend/package.json` still declares the same packages in its own `dependencies` — that's intentional, not stale duplication. It's what makes local dev (`npm run dev` inside `frontend/`, which gets a real, normal `frontend/node_modules` on a dev machine) work independently of this server-only constraint. When adding a new frontend runtime dependency, add it to **both** `package.json` files (frontend's own, for local dev; root's, for the o2switch build) and run `npm install` in both places to keep both lockfiles in sync.
|
|
||||||
- `frontend/package.json`'s `devDependencies` (`vite`, `@vitejs/plugin-react`, `oxlint`, `@types/react*`) do **not** need mirroring to root — only the ones already there (`vite`, `@vitejs/plugin-react`) are actually required for the production build to run at all; `oxlint`/`@types/*` are dev-only tooling never invoked during `npm run build`.
|
|
||||||
|
|
||||||
<!-- BEGIN:nextjs-agent-rules -->
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ Interface web de téléchargement de vidéos via [yt-dlp](https://github.com/yt-
|
|||||||
|
|
||||||
## Fonctionnement
|
## Fonctionnement
|
||||||
|
|
||||||
1. L'utilisateur soumet une URL depuis l'interface web.
|
1. L'utilisateur colle une URL dans l'interface web. Elle est immédiatement analysée via un probe `yt-dlp -J` (`POST /api/probe`), qui détermine les qualités, sous-titres et durée réellement disponibles pour cette vidéo.
|
||||||
2. La demande est enregistrée en base (statut `PENDING`).
|
2. Les options de téléchargement (format, qualité, sous-titres, découpe, qualité audio MP3) affichées à l'utilisateur sont dérivées de ce probe — la soumission est bloquée tant qu'il n'a pas réussi.
|
||||||
3. Le worker Passenger (Node.js, arrière-plan) dépile les jobs et exécute `yt-dlp` en parallèle (concurrence configurable).
|
3. La demande est enregistrée en base (statut `PENDING`).
|
||||||
4. Le fichier téléchargé est stocké localement ; un token signé (24h) est généré.
|
4. Le worker (Node.js, arrière-plan, supervisé par pm2) dépile les jobs et exécute `yt-dlp` en parallèle (concurrence configurable).
|
||||||
5. L'utilisateur est redirigé vers une page de statut qui se rafraîchit jusqu'à ce que le fichier soit prêt.
|
5. Le fichier téléchargé est stocké localement ; un token signé (24h) est généré.
|
||||||
6. Un cron quotidien supprime les fichiers expirés et marque les entrées `FILE_DELETED` — la DB conserve l'historique complet.
|
6. L'utilisateur est redirigé vers une page de statut qui se rafraîchit jusqu'à ce que le fichier soit prêt.
|
||||||
|
7. Un cron quotidien supprime les fichiers expirés et marque les entrées `FILE_DELETED` — la DB conserve l'historique complet.
|
||||||
|
|
||||||
## Stack technique
|
## Stack technique
|
||||||
|
|
||||||
@@ -17,15 +18,18 @@ Interface web de téléchargement de vidéos via [yt-dlp](https://github.com/yt-
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| Framework | Next.js 16 (App Router) |
|
| Framework | Next.js 16 (App Router) |
|
||||||
| Base de données | MariaDB 11 via Prisma 7 |
|
| Base de données | MariaDB 11 via Prisma 7 |
|
||||||
| Frontend | React 19, TypeScript |
|
| Frontend | React 19, TypeScript, Tailwind CSS v4 |
|
||||||
| Worker | Node.js (ts-node) |
|
| i18n | next-intl (EN, FR, ES, IT), routing par `[locale]` |
|
||||||
|
| Thème | next-themes (clair/sombre) |
|
||||||
|
| Analytics | PostHog (cookieless) |
|
||||||
|
| Worker | Node.js (tsx), supervisé par pm2 |
|
||||||
| Conteneur (dev) | Docker Compose |
|
| Conteneur (dev) | Docker Compose |
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
|
|
||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- Docker & Docker Compose (pour MariaDB en développement)
|
- Docker & Docker Compose (pour MariaDB en développement)
|
||||||
- `yt-dlp` et `ffmpeg` (binaires précompilés dans `./bin/` ou dans le `PATH`)
|
- `yt-dlp`, `ffmpeg` et `ffprobe` (binaires précompilés dans `./bin/` ou dans le `PATH`)
|
||||||
|
|
||||||
## Démarrage rapide
|
## Démarrage rapide
|
||||||
|
|
||||||
@@ -60,7 +64,9 @@ L'interface est accessible sur [http://localhost:3000](http://localhost:3000).
|
|||||||
|----------|--------|---------|-------------|
|
|----------|--------|---------|-------------|
|
||||||
| `DATABASE_URL` | oui | `mysql://ombrora:ombrora@localhost:3306/ombrora` | Connexion MariaDB |
|
| `DATABASE_URL` | oui | `mysql://ombrora:ombrora@localhost:3306/ombrora` | Connexion MariaDB |
|
||||||
| `STORAGE_PATH` | oui | `/var/www/ombrora/storage` | Répertoire de stockage des fichiers téléchargés |
|
| `STORAGE_PATH` | oui | `/var/www/ombrora/storage` | Répertoire de stockage des fichiers téléchargés |
|
||||||
| `BIN_DIR` | non | `./bin` | Répertoire contenant `yt-dlp` et `ffmpeg` (défaut : `./bin`) |
|
| `NEXT_PUBLIC_BASE_URL` | oui | `https://example.com` | URL publique du site, utilisée pour le SEO (canonical, OpenGraph, JSON-LD, sitemap) |
|
||||||
|
| `BIN_DIR` | non | `./bin` | Répertoire contenant `yt-dlp`, `ffmpeg` et `ffprobe` (défaut : `./bin`) — absent de `.env.example`, à ajouter manuellement si le défaut ne convient pas |
|
||||||
|
| `PYTHON_BIN` | non | `/opt/alt/python311/bin/python3` | Interpréteur utilisé pour exécuter le zipapp `bin/yt-dlp` (défaut : `python3` sur Linux, `python` sur Windows) — à définir si le `python3` système est trop ancien (yt-dlp nécessite 3.10+), comme sur o2switch |
|
||||||
|
|
||||||
## Scripts npm
|
## Scripts npm
|
||||||
|
|
||||||
@@ -68,31 +74,41 @@ L'interface est accessible sur [http://localhost:3000](http://localhost:3000).
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `npm run dev` | Serveur de développement Next.js |
|
| `npm run dev` | Serveur de développement Next.js |
|
||||||
| `npm run build` | Build de production |
|
| `npm run build` | Build de production |
|
||||||
| `npm start` | Serveur de production |
|
| `npm start` | Serveur de production (`server.js`, requis par Passenger sur o2switch — `next start` seul n'est pas utilisable comme fichier de démarrage) |
|
||||||
| `npm test` | Tests Jest |
|
| `npm test` | Tests Jest |
|
||||||
| `npm run worker` | Worker de téléchargement (boucle de polling) |
|
| `npm run worker` | Worker de téléchargement (boucle de polling, exécution directe/dev) |
|
||||||
| `npm run worker:check` | Vérifie si le worker tourne, le démarre sinon (usage cron) |
|
| `npm run worker:pm2:start` | Démarre le worker sous pm2 (`ecosystem.config.cjs`), avec auto-restart |
|
||||||
|
| `npm run worker:pm2:stop` | Arrête le worker géré par pm2 |
|
||||||
|
| `npm run worker:pm2:restart` | Redémarre le worker géré par pm2 |
|
||||||
|
| `npm run worker:pm2:status` | Affiche l'état du process `video-downloader-worker` |
|
||||||
|
| `npm run worker:pm2:logs` | Affiche les logs du worker géré par pm2 |
|
||||||
| `npm run worker:cleanup` | Expire et supprime les fichiers anciens (usage cron) |
|
| `npm run worker:cleanup` | Expire et supprime les fichiers anciens (usage cron) |
|
||||||
| `npm run db:migrate` | Crée et applique les migrations Prisma |
|
| `npm run db:migrate` | Crée et applique les migrations Prisma (dev, interactif) |
|
||||||
|
| `npm run db:migrate:deploy` | Applique les migrations en attente (production, non interactif) |
|
||||||
| `npm run db:generate` | Régénère le client Prisma |
|
| `npm run db:generate` | Régénère le client Prisma |
|
||||||
|
| `npm run deploy` | Déploiement complet sur o2switch (voir section ci-dessous) |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Les paramètres applicatifs sont centralisés dans `config/app.config.ts` :
|
Les paramètres applicatifs sont centralisés dans `config/app.config.ts` :
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
DOWNLOAD_LINK_TTL_HOURS: 24 // Durée de validité du token de téléchargement
|
DOWNLOAD_LINK_TTL_HOURS: 24 // Durée de validité du token de téléchargement
|
||||||
WORKER_CONCURRENCY: 3 // Téléchargements parallèles maximum
|
WORKER_CONCURRENCY: 3 // Téléchargements parallèles maximum
|
||||||
WORKER_POLL_INTERVAL_MS: 10000 // Intervalle de polling du worker (ms)
|
WORKER_POLL_INTERVAL_MS: 10000 // Intervalle de polling du worker (ms)
|
||||||
RATE_LIMIT_MAX: 5 // Soumissions max par IP par fenêtre
|
RATE_LIMIT_MAX: 5 // Soumissions max par IP par fenêtre
|
||||||
RATE_LIMIT_WINDOW_MS: 3600000 // Fenêtre de rate-limit (1h)
|
RATE_LIMIT_WINDOW_MS: 3600000 // Fenêtre de rate-limit des soumissions (1h)
|
||||||
|
PROBE_RATE_LIMIT_MAX: 20 // Analyses (probe) max par IP par fenêtre
|
||||||
|
PROBE_RATE_LIMIT_WINDOW_MS: 3600000 // Fenêtre de rate-limit du probe (1h)
|
||||||
|
PROBE_TIMEOUT_MS: 20000 // Timeout du probe yt-dlp -J (ms)
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
| Méthode | Endpoint | Description |
|
| Méthode | Endpoint | Description |
|
||||||
|---------|----------|-------------|
|
|---------|----------|-------------|
|
||||||
| `POST` | `/api/downloads` | Soumet une URL. Corps JSON : `{ url, format?, quality?, subtitles?, extraArgs? }`. Retourne `{ uuid }`. |
|
| `POST` | `/api/probe` | Analyse une URL via `yt-dlp -J`. Corps JSON : `{ url }`. Retourne les qualités, sous-titres et durée réellement disponibles pour la vidéo, ou une erreur (`422` si l'analyse échoue, `429` si rate-limit dépassé). |
|
||||||
|
| `POST` | `/api/downloads` | Soumet une URL. Corps JSON : `{ url, format, quality, subtitles, subtitleLangs?, clipStart?, clipEnd?, audioQuality?, extraArgs? }`. Retourne `{ uuid }`. |
|
||||||
| `GET` | `/api/downloads/:uuid` | Statut d'un téléchargement. Retourne `{ status, fileName, fileSize, downloadToken?, tokenExpiresAt?, errorMsg? }`. |
|
| `GET` | `/api/downloads/:uuid` | Statut d'un téléchargement. Retourne `{ status, fileName, fileSize, downloadToken?, tokenExpiresAt?, errorMsg? }`. |
|
||||||
| `GET` | `/api/download/:token` | Téléchargement du fichier (token à usage unique, 24h). |
|
| `GET` | `/api/download/:token` | Téléchargement du fichier (token à usage unique, 24h). |
|
||||||
|
|
||||||
@@ -101,26 +117,47 @@ RATE_LIMIT_WINDOW_MS: 3600000 // Fenêtre de rate-limit (1h)
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── page.tsx # Page d'accueil (formulaire de soumission)
|
│ ├── [locale]/
|
||||||
│ ├── status/[uuid]/page.tsx # Page de suivi
|
│ │ ├── layout.tsx # Header, thème, next-intl, JSON-LD, PostHog
|
||||||
|
│ │ ├── page.tsx # Accueil (hero, formulaire, sections SEO)
|
||||||
|
│ │ ├── [slug]/page.tsx # Pages SEO par plateforme (youtube-downloader, etc.)
|
||||||
|
│ │ ├── status/[uuid]/page.tsx # Page de suivi
|
||||||
|
│ │ └── supported-sites/page.tsx # Liste des sites supportés par yt-dlp
|
||||||
|
│ ├── sitemap.ts # Génération du sitemap.xml
|
||||||
│ └── api/
|
│ └── api/
|
||||||
│ ├── downloads/route.ts # POST — création
|
│ ├── downloads/route.ts # POST — création
|
||||||
│ ├── downloads/[uuid]/route.ts # GET — statut
|
│ ├── downloads/[uuid]/route.ts # GET — statut
|
||||||
│ └── download/[token]/route.ts # GET — téléchargement
|
│ ├── download/[token]/route.ts # GET — téléchargement
|
||||||
|
│ └── probe/route.ts # POST — analyse yt-dlp -J
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── SubmitForm.tsx # Formulaire de soumission
|
│ ├── SubmitForm.tsx # Formulaire de soumission (options dérivées du probe)
|
||||||
│ └── StatusView.tsx # Vue de suivi (polling)
|
│ ├── StatusView.tsx # Vue de suivi (polling)
|
||||||
|
│ ├── Header.tsx, LanguageSwitcher.tsx, ThemeToggle.tsx
|
||||||
|
│ ├── HowItWorksSection.tsx, ReassuranceSection.tsx, SupportedPlatformsSection.tsx
|
||||||
|
│ └── FaqSection.tsx, FaqAccordion.tsx, SupportedSitesBrowser.tsx
|
||||||
|
├── i18n/
|
||||||
|
│ ├── routing.ts # Locales, préfixes d'URL
|
||||||
|
│ └── request.ts # Config next-intl côté serveur
|
||||||
|
├── proxy.ts # Middleware next-intl (détection/routage locale)
|
||||||
└── lib/
|
└── lib/
|
||||||
├── prisma.ts # Client Prisma (singleton)
|
├── prisma.ts # Client Prisma (singleton)
|
||||||
├── rate-limit.ts # Rate limiting par IP
|
├── rate-limit.ts # Factory createRateLimiter (downloads + probe)
|
||||||
├── token.ts # Génération/validation de tokens
|
├── token.ts # Génération/validation de tokens
|
||||||
└── ytdlp.ts # Construction des arguments yt-dlp
|
├── ytdlp.ts # Construction des arguments/commande yt-dlp
|
||||||
|
├── ytdlp-options.ts # Formats/qualités/langues proposés
|
||||||
|
├── ytdlp-probe.ts # Analyse yt-dlp -J d'une URL
|
||||||
|
├── downloader-platforms.ts # Données des pages SEO par plateforme
|
||||||
|
└── supported-sites.ts # Liste des sites supportés par yt-dlp
|
||||||
|
|
||||||
worker/
|
worker/
|
||||||
├── index.ts # Boucle de polling principale
|
├── index.ts # Boucle de polling principale
|
||||||
├── processor.ts # Exécution de yt-dlp, mise à jour DB
|
├── processor.ts # Exécution de yt-dlp, mise à jour DB
|
||||||
├── cron-cleanup.ts # Nettoyage des fichiers expirés
|
└── cron-cleanup.ts # Nettoyage des fichiers expirés
|
||||||
└── cron-check.ts # Supervision du worker (cron)
|
|
||||||
|
messages/ # Traductions (en.json, fr.json, es.json, it.json)
|
||||||
|
|
||||||
|
server.js # Serveur HTTP custom (fichier de démarrage Passenger sur o2switch)
|
||||||
|
ecosystem.config.cjs # Config pm2 du process "video-downloader-worker"
|
||||||
|
|
||||||
prisma/
|
prisma/
|
||||||
└── schema.prisma # Modèles : Download, DownloadToken
|
└── schema.prisma # Modèles : Download, DownloadToken
|
||||||
@@ -130,20 +167,27 @@ prisma/
|
|||||||
|
|
||||||
### Contraintes spécifiques à l'hébergement mutualisé
|
### Contraintes spécifiques à l'hébergement mutualisé
|
||||||
|
|
||||||
- **Pas de compilation sur le serveur** : `yt-dlp` et `ffmpeg` doivent être des binaires précompilés pour la plateforme cible, placés dans `BIN_DIR`.
|
- **Pas de compilation sur le serveur** : `yt-dlp`, `ffmpeg` et `ffprobe` doivent être des binaires précompilés pour la plateforme cible, placés dans `BIN_DIR`.
|
||||||
- **`node_modules` unique** : Passenger ne crée qu'un seul `node_modules` à la racine. Toute dépendance runtime importée par le frontend doit figurer dans le `dependencies` du `package.json` racine (en plus de `frontend/package.json`).
|
- **`node_modules` unique** : Passenger ne crée qu'un seul `node_modules` à la racine. Le projet n'a qu'un seul `package.json` (pas de sous-dossier frontend séparé à synchroniser).
|
||||||
- **Worker** : lancer `npm run worker:check` depuis un cron cPanel pour maintenir le worker actif.
|
- **Worker** : Passenger ne supervise que l'app Next.js. Le worker est un process à part, maintenu vivant par **pm2** (auto-restart en cas de crash), à la manière de Passenger pour l'app web.
|
||||||
|
- **Fichier de démarrage (Passenger)** : Passenger exécute directement `node <fichier>` — il ne peut pas lancer une commande CLI (`next start`) ni un script npm. Le dépôt fournit donc `server.js` à la racine (serveur HTTP custom minimal, cf. [doc Next.js](https://nextjs.org/docs/app/guides/custom-server)), qui appelle l'API programmatique de Next.js et écoute sur `process.env.PORT` (port fourni par Passenger).
|
||||||
|
|
||||||
### Étapes de déploiement
|
### Étapes de déploiement
|
||||||
|
|
||||||
1. Uploader les sources (hors `node_modules`, `.env`, `storage/`).
|
1. Uploader les sources (hors `node_modules`, `.env`, `storage/`).
|
||||||
2. Déposer `yt-dlp` et `ffmpeg` dans `BIN_DIR`.
|
2. Déposer `yt-dlp` et `ffmpeg` dans `BIN_DIR`.
|
||||||
3. Créer `.env` avec les valeurs de production.
|
3. Remplir `.env.prod` avec les valeurs de production (non versionné, à créer/mettre à jour manuellement sur le serveur).
|
||||||
4. Via cPanel > "Setup Node.js App" : pointer sur le dépôt, lancer `npm install` puis `npm run build`.
|
4. Via cPanel > "Setup Node.js App" : créer l'app Node une première fois pour que Passenger et le nodevenv soient configurés (pointer sur le dépôt). Dans le champ **"Application startup file"**, indiquer `server.js`.
|
||||||
5. Appliquer les migrations : `npm run db:migrate`.
|
5. En SSH, dans l'environnement Node fourni par cPanel (celui activé par le lien "Enter to the virtual environment" de cPanel) :
|
||||||
6. Configurer deux crons cPanel :
|
```bash
|
||||||
- `npm run worker:check` toutes les 5 minutes (redémarre le worker si arrêté).
|
npm run deploy
|
||||||
- `npm run worker:cleanup` une fois par jour (suppression des fichiers expirés).
|
```
|
||||||
|
Ce script (`scripts/deploy.sh`) enchaîne : copie de `.env.prod` vers `.env`, `npm install`, génération du client Prisma, application des migrations en attente (`prisma migrate deploy`), `npm run build`, puis démarrage/redémarrage du worker sous pm2 (`pm2 startOrRestart` + `pm2 save`, pour persister la liste des process en vue d'un `pm2 resurrect` après reboot).
|
||||||
|
6. Redémarrer l'app Next.js via le bouton "Restart" de cPanel > "Setup Node.js App" pour que Passenger recharge le nouveau build (le script ne pilote pas Passenger, qui est géré en dehors du SSH/nodevenv).
|
||||||
|
7. Configurer un cron cPanel pour `npm run worker:cleanup` une fois par jour (suppression des fichiers expirés). pm2 gère lui-même le redémarrage du worker en cas de crash ; il n'y a donc plus besoin de cron de supervision dédié.
|
||||||
|
8. Si l'hébergeur redémarre le serveur, relancer `pm2 resurrect` (ou `npm run worker:pm2:start` si la sauvegarde `pm2 save` n'a pas été faite) pour reprendre le worker.
|
||||||
|
|
||||||
|
Pour les déploiements suivants, seule l'étape 5 (`npm run deploy`) et l'étape 6 (redémarrage Passenger) sont nécessaires.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -7,5 +7,9 @@ export const config = {
|
|||||||
STORAGE_PATH: process.env.STORAGE_PATH ?? '',
|
STORAGE_PATH: process.env.STORAGE_PATH ?? '',
|
||||||
RATE_LIMIT_MAX: 5,
|
RATE_LIMIT_MAX: 5,
|
||||||
RATE_LIMIT_WINDOW_MS: 3_600_000,
|
RATE_LIMIT_WINDOW_MS: 3_600_000,
|
||||||
|
PROBE_RATE_LIMIT_MAX: 20,
|
||||||
|
PROBE_RATE_LIMIT_WINDOW_MS: 3_600_000,
|
||||||
|
PROBE_TIMEOUT_MS: 20_000,
|
||||||
BIN_DIR: process.env.BIN_DIR ?? path.join(process.cwd(), 'bin'),
|
BIN_DIR: process.env.BIN_DIR ?? path.join(process.cwd(), 'bin'),
|
||||||
|
PYTHON_BIN: process.env.PYTHON_BIN ?? (process.platform === 'win32' ? 'python' : 'python3'),
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# 4K Support Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add 2160p (4K) and 1440p (2K) as selectable download quality tiers, and update marketing copy that currently caps the advertised resolution at 1080p.
|
||||||
|
|
||||||
|
**Architecture:** The download pipeline already resolves quality generically — the probe (`src/lib/ytdlp-probe.ts`) filters a fixed `QUALITIES` list against the source video's max height, and the yt-dlp arg builder (`src/lib/ytdlp.ts`) turns any quality string into a `height<=?N` format selector. No pipeline logic changes; only the `QUALITIES` list, its test coverage, and static marketing copy change.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Jest, next-intl (`messages/*.json`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Spec: `docs/superpowers/specs/2026-08-11-4k-support-design.md`
|
||||||
|
- Add exactly two new tiers: `2160p` and `1440p` (no other resolutions).
|
||||||
|
- No changes to `src/lib/ytdlp.ts`, `worker/processor.ts`, or the probe's filtering logic — both are already generic over the quality string.
|
||||||
|
- Marketing copy change is a literal `1080p` → `4K` swap; wording around it stays otherwise identical.
|
||||||
|
- No changes to `src/lib/downloader-platforms.ts` — it carries no resolution claim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add 2160p/1440p to the quality list and probe test coverage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/ytdlp-options.ts:2`
|
||||||
|
- Modify: `src/lib/__tests__/ytdlp-probe.test.ts` (add a new test in the `parseProbeOutput` describe block, after the existing "detects a video with multiple qualities" test at line 40)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing new.
|
||||||
|
- Produces: `QUALITIES` (from `src/lib/ytdlp-options.ts`) now includes `'2160p'` and `'1440p'`, ordered highest-to-lowest: `['best', '2160p', '1440p', '1080p', '720p', '480p', '360p']`. `ytdlp-probe.ts` and `SubmitForm.tsx` both import `QUALITIES` by name (unchanged) and iterate it in array order — later tasks and existing consumers see the two new entries automatically.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add this test to `src/lib/__tests__/ytdlp-probe.test.ts`, right after the `it('detects a video with multiple qualities and subtitles', ...)` block (after line 40):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('includes 4K and 2K when the source video offers them', () => {
|
||||||
|
const result = parseProbeOutput(json({
|
||||||
|
formats: [
|
||||||
|
{ height: 1080, vcodec: 'avc1' },
|
||||||
|
{ height: 1440, vcodec: 'avc1' },
|
||||||
|
{ height: 2160, vcodec: 'avc1' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
expect(result.availableQualities).toEqual(['best', '2160p', '1440p', '1080p'])
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- ytdlp-probe.test.ts`
|
||||||
|
Expected: FAIL — `availableQualities` is `['best', '1080p']` because `QUALITIES` does not yet contain `'2160p'`/`'1440p'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
In `src/lib/ytdlp-options.ts`, replace line 2:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const QUALITIES = ['best', '2160p', '1440p', '1080p', '720p', '480p', '360p'] as const
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- ytdlp-probe.test.ts`
|
||||||
|
Expected: PASS — all tests in the file pass, including the existing 1080p-source case (`'caps available qualities to the max height found'` and `'detects a video with multiple qualities and subtitles'`), which stay unaffected since `2160p`/`1440p` are correctly filtered out when `maxHeight` is below them.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/ytdlp-options.ts src/lib/__tests__/ytdlp-probe.test.ts
|
||||||
|
git commit -m "feat: add 4K and 2K quality tiers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Cover the 2160p format selector in the yt-dlp arg builder tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/__tests__/ytdlp.test.ts` (add a new test after the existing `it('adds a height filter to -f when quality is not "best"', ...)` block at line 41)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `buildYtdlpArgs` from `src/lib/ytdlp.ts` (unchanged signature: `(params: YtdlpParams) => string[]`), `base` fixture object defined at the top of the test file.
|
||||||
|
- Produces: nothing new — this task only adds test coverage confirming `buildYtdlpArgs` already handles `quality: '2160p'` correctly (via its existing generic `quality.replace('p', '')` logic).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing-if-broken test**
|
||||||
|
|
||||||
|
Add this test to `src/lib/__tests__/ytdlp.test.ts`, right after the `it('adds a height filter to -f when quality is not "best"', ...)` block (after line 41):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('adds a height filter to -f for 4K quality', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, quality: '2160p' })
|
||||||
|
const idx = args.indexOf('-f')
|
||||||
|
expect(idx).toBeGreaterThan(-1)
|
||||||
|
expect(args[idx + 1]).toContain('height<=?2160')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- ytdlp.test.ts`
|
||||||
|
Expected: PASS immediately — `buildYtdlpArgs` already builds the filter generically from the `quality` string, so no source change is needed. This step confirms that generic behavior explicitly with a regression test.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/__tests__/ytdlp.test.ts
|
||||||
|
git commit -m "test: cover 4K format selector in buildYtdlpArgs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Update marketing copy in all 4 locales to advertise 4K
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `messages/en.json` (lines 52, 60, 79, 117, 166)
|
||||||
|
- Modify: `messages/fr.json` (lines 52, 60, 79, 117, 166)
|
||||||
|
- Modify: `messages/es.json` (lines 52, 60, 79, 117, 166)
|
||||||
|
- Modify: `messages/it.json` (lines 52, 60, 79, 117, 166)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing — these are static i18n strings rendered by `HowItWorksSection.tsx`, `FaqAccordion.tsx`, and the platform FAQ pages (via next-intl), none of which need changes since only string content changes, not keys.
|
||||||
|
- Produces: nothing consumed by later tasks.
|
||||||
|
|
||||||
|
Each file has exactly 5 occurrences of the literal substring `1080p`, all of which should become `4K`, at these keys:
|
||||||
|
- `home.formatsDesc`
|
||||||
|
- `howItWorks.step2Desc`
|
||||||
|
- `faq.a4`
|
||||||
|
- `downloaderPages.youtube.faqA2`
|
||||||
|
- `downloaderPages.vimeo.faqA2`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `messages/en.json`**
|
||||||
|
|
||||||
|
Current (lines 52, 60, 79, 117, 166):
|
||||||
|
```
|
||||||
|
"formatsDesc": "mp4, mp3, webm, mkv -- up to 1080p"
|
||||||
|
"step2Desc": "Pick MP4, MP3, WebM or MKV and the quality you want, up to 1080p",
|
||||||
|
"a4": "Video in MP4, WebM or MKV, or audio-only in MP3, up to 1080p depending on the source video.",
|
||||||
|
"faqA2": "Up to 1080p, depending on the resolutions available for that specific video."
|
||||||
|
"faqA2": "It depends on what the uploader made available, up to 1080p in most cases."
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace every `1080p` with `4K` (5 occurrences in this file):
|
||||||
|
```
|
||||||
|
"formatsDesc": "mp4, mp3, webm, mkv -- up to 4K"
|
||||||
|
"step2Desc": "Pick MP4, MP3, WebM or MKV and the quality you want, up to 4K",
|
||||||
|
"a4": "Video in MP4, WebM or MKV, or audio-only in MP3, up to 4K depending on the source video.",
|
||||||
|
"faqA2": "Up to 4K, depending on the resolutions available for that specific video."
|
||||||
|
"faqA2": "It depends on what the uploader made available, up to 4K in most cases."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `messages/fr.json`**
|
||||||
|
|
||||||
|
Current (lines 52, 60, 79, 117, 166):
|
||||||
|
```
|
||||||
|
"formatsDesc": "mp4, mp3, webm, mkv — jusqu'en 1080p"
|
||||||
|
"step2Desc": "Sélectionnez MP4, MP3, WebM ou MKV et la qualité souhaitée, jusqu'en 1080p",
|
||||||
|
"a4": "Vidéo en MP4, WebM ou MKV, ou audio seul en MP3, jusqu'en 1080p selon la vidéo source.",
|
||||||
|
"faqA2": "Jusqu'à 1080p, selon les résolutions disponibles pour cette vidéo en particulier."
|
||||||
|
"faqA2": "Cela dépend de ce que l'auteur a mis à disposition, jusqu'à 1080p dans la plupart des cas."
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace every `1080p` with `4K` (5 occurrences in this file):
|
||||||
|
```
|
||||||
|
"formatsDesc": "mp4, mp3, webm, mkv — jusqu'en 4K"
|
||||||
|
"step2Desc": "Sélectionnez MP4, MP3, WebM ou MKV et la qualité souhaitée, jusqu'en 4K",
|
||||||
|
"a4": "Vidéo en MP4, WebM ou MKV, ou audio seul en MP3, jusqu'en 4K selon la vidéo source.",
|
||||||
|
"faqA2": "Jusqu'à 4K, selon les résolutions disponibles pour cette vidéo en particulier."
|
||||||
|
"faqA2": "Cela dépend de ce que l'auteur a mis à disposition, jusqu'à 4K dans la plupart des cas."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `messages/es.json`**
|
||||||
|
|
||||||
|
Current (lines 52, 60, 79, 117, 166):
|
||||||
|
```
|
||||||
|
"formatsDesc": "mp4, mp3, webm, mkv — hasta 1080p"
|
||||||
|
"step2Desc": "Selecciona MP4, MP3, WebM o MKV y la calidad deseada, hasta 1080p",
|
||||||
|
"a4": "Vídeo en MP4, WebM o MKV, o solo audio en MP3, hasta 1080p según el vídeo de origen.",
|
||||||
|
"faqA2": "Hasta 1080p, según las resoluciones disponibles para ese vídeo en concreto."
|
||||||
|
"faqA2": "Depende de lo que haya puesto a disposición quien lo subió, hasta 1080p en la mayoría de los casos."
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace every `1080p` with `4K` (5 occurrences in this file):
|
||||||
|
```
|
||||||
|
"formatsDesc": "mp4, mp3, webm, mkv — hasta 4K"
|
||||||
|
"step2Desc": "Selecciona MP4, MP3, WebM o MKV y la calidad deseada, hasta 4K",
|
||||||
|
"a4": "Vídeo en MP4, WebM o MKV, o solo audio en MP3, hasta 4K según el vídeo de origen.",
|
||||||
|
"faqA2": "Hasta 4K, según las resoluciones disponibles para ese vídeo en concreto."
|
||||||
|
"faqA2": "Depende de lo que haya puesto a disposición quien lo subió, hasta 4K en la mayoría de los casos."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `messages/it.json`**
|
||||||
|
|
||||||
|
Current (lines 52, 60, 79, 117, 166):
|
||||||
|
```
|
||||||
|
"formatsDesc": "mp4, mp3, webm, mkv — fino a 1080p"
|
||||||
|
"step2Desc": "Seleziona MP4, MP3, WebM o MKV e la qualità desiderata, fino a 1080p",
|
||||||
|
"a4": "Video in MP4, WebM o MKV, oppure solo audio in MP3, fino a 1080p a seconda del video originale.",
|
||||||
|
"faqA2": "Fino a 1080p, in base alle risoluzioni disponibili per quel video specifico."
|
||||||
|
"faqA2": "Dipende da cosa ha reso disponibile chi ha caricato il video, fino a 1080p nella maggior parte dei casi."
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace every `1080p` with `4K` (5 occurrences in this file):
|
||||||
|
```
|
||||||
|
"formatsDesc": "mp4, mp3, webm, mkv — fino a 4K"
|
||||||
|
"step2Desc": "Seleziona MP4, MP3, WebM o MKV e la qualità desiderata, fino a 4K",
|
||||||
|
"a4": "Video in MP4, WebM o MKV, oppure solo audio in MP3, fino a 4K a seconda del video originale.",
|
||||||
|
"faqA2": "Fino a 4K, in base alle risoluzioni disponibili per quel video specifico."
|
||||||
|
"faqA2": "Dipende da cosa ha reso disponibile chi ha caricato il video, fino a 4K nella maggior parte dei casi."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify no stray `1080p` remains in any locale file**
|
||||||
|
|
||||||
|
Run: `grep -rn "1080" messages/`
|
||||||
|
Expected: no output (empty result).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the full test suite**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS — locale copy changes don't affect any test, and Tasks 1–2's new tests still pass.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add messages/en.json messages/fr.json messages/es.json messages/it.json
|
||||||
|
git commit -m "docs: advertise 4K support in marketing copy across all locales"
|
||||||
|
```
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 4K support in video downloads
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Add 4K (2160p) and 2K (1440p) as selectable download quality tiers, and update marketing
|
||||||
|
copy that currently caps the advertised resolution at 1080p.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The download pipeline already resolves quality generically:
|
||||||
|
|
||||||
|
- `src/lib/ytdlp-options.ts` exports `QUALITIES`, a fixed list consumed by the probe
|
||||||
|
(`src/lib/ytdlp-probe.ts`) to compute which qualities are actually available for a given
|
||||||
|
source video (filtered against the max height yt-dlp reports), and by the UI
|
||||||
|
(`src/components/SubmitForm.tsx`, via `capabilities.availableQualities`) to render the
|
||||||
|
quality `<select>`.
|
||||||
|
- `src/lib/ytdlp.ts` (`buildYtdlpArgs`) turns the selected quality string into a yt-dlp
|
||||||
|
format selector via `height<=?${quality.replace('p', '')}`, with no hardcoded ceiling.
|
||||||
|
|
||||||
|
Because both the filtering and the format-selector logic are already generic over the
|
||||||
|
quality string, no logic changes are required — only the `QUALITIES` list itself, its
|
||||||
|
tests, and marketing copy need to change.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Quality list — `src/lib/ytdlp-options.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const QUALITIES = ['best', '2160p', '1440p', '1080p', '720p', '480p', '360p'] as const
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tests
|
||||||
|
|
||||||
|
- `src/lib/__tests__/ytdlp-probe.test.ts`: add a case with a 4K-height fixture
|
||||||
|
(`height: 2160`) asserting `availableQualities` includes `'2160p'` and `'1440p'`. The
|
||||||
|
existing 1080p-source case is unaffected (2160p/1440p correctly excluded since
|
||||||
|
`maxHeight` there is 1080).
|
||||||
|
- `src/lib/__tests__/ytdlp.test.ts`: add a case for `quality: '2160p'` asserting the built
|
||||||
|
args contain `height<=?2160`, mirroring the existing `1080p` case.
|
||||||
|
|
||||||
|
### 3. Marketing copy — `messages/{en,fr,es,it}.json`
|
||||||
|
|
||||||
|
Replace the "up to 1080p" claim (and localized equivalents) with "up to 4K" in the 5
|
||||||
|
affected keys, in each of the 4 locale files: `formatsDesc`, `step2Desc`, `a4`, and the two
|
||||||
|
`faqA2` occurrences (home FAQ + platform-page FAQ). Only the resolution figure changes;
|
||||||
|
surrounding wording stays identical.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- No change to the download pipeline, worker, or database schema — the format selector and
|
||||||
|
probe filtering are already resolution-agnostic.
|
||||||
|
- No change to per-platform SEO page copy (`src/lib/downloader-platforms.ts`) — it does not
|
||||||
|
mention a specific resolution ceiling.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'video-downloader-worker',
|
||||||
|
script: 'node_modules/tsx/dist/cli.mjs',
|
||||||
|
args: 'worker/index.ts',
|
||||||
|
cwd: __dirname,
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 10,
|
||||||
|
restart_delay: 5000,
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
+18
-7
@@ -9,15 +9,26 @@
|
|||||||
"urlLabel": "Video URL",
|
"urlLabel": "Video URL",
|
||||||
"format": "Format",
|
"format": "Format",
|
||||||
"quality": "Quality",
|
"quality": "Quality",
|
||||||
"subtitles": "Subtitles (fr, en)",
|
"subtitles": "Subtitles",
|
||||||
|
"subtitleLanguages": "Subtitle languages",
|
||||||
"advanced": "Advanced options",
|
"advanced": "Advanced options",
|
||||||
"formats": "mp4 · mp3 · webm · mkv",
|
"formats": "mp4 · mp3 · webm · mkv",
|
||||||
"submit": "Download",
|
"submit": "Download",
|
||||||
"submitting": "Sending...",
|
"submitting": "Sending...",
|
||||||
"errorRateLimit": "Too many submissions. Try again in an hour.",
|
"errorRateLimit": "Too many submissions. Try again in an hour.",
|
||||||
"errorGeneric": "Submission error."
|
"errorGeneric": "Submission error.",
|
||||||
|
"analyzing": "Analyzing video...",
|
||||||
|
"probeError": "Couldn't analyze this video. Check the link or try again.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"audioOnlySource": "Audio-only source",
|
||||||
|
"clipRange": "Clip (optional)",
|
||||||
|
"clipStart": "Start (s)",
|
||||||
|
"clipEnd": "End (s)",
|
||||||
|
"audioQuality": "Audio quality",
|
||||||
|
"videoLength": "Length: {duration}"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
"backHome": "Back to homepage",
|
||||||
"heading": "Download status",
|
"heading": "Download status",
|
||||||
"notFound": "Download not found.",
|
"notFound": "Download not found.",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -39,7 +50,7 @@
|
|||||||
"private": "Private",
|
"private": "Private",
|
||||||
"privateDesc": "No data retained",
|
"privateDesc": "No data retained",
|
||||||
"formats": "Multi-format",
|
"formats": "Multi-format",
|
||||||
"formatsDesc": "mp4, mp3, webm, mkv -- up to 1080p"
|
"formatsDesc": "mp4, mp3, webm, mkv -- up to 4K"
|
||||||
},
|
},
|
||||||
"howItWorks": {
|
"howItWorks": {
|
||||||
"heading": "How it works",
|
"heading": "How it works",
|
||||||
@@ -47,7 +58,7 @@
|
|||||||
"step1Title": "Paste the link",
|
"step1Title": "Paste the link",
|
||||||
"step1Desc": "Copy the video URL from YouTube, TikTok, Instagram or any supported site",
|
"step1Desc": "Copy the video URL from YouTube, TikTok, Instagram or any supported site",
|
||||||
"step2Title": "Choose your format",
|
"step2Title": "Choose your format",
|
||||||
"step2Desc": "Pick MP4, MP3, WebM or MKV and the quality you want, up to 1080p",
|
"step2Desc": "Pick MP4, MP3, WebM or MKV and the quality you want, up to 4K",
|
||||||
"step3Title": "Download",
|
"step3Title": "Download",
|
||||||
"step3Desc": "Your file is ready in seconds, via a secure link valid for 24 hours"
|
"step3Desc": "Your file is ready in seconds, via a secure link valid for 24 hours"
|
||||||
},
|
},
|
||||||
@@ -66,7 +77,7 @@
|
|||||||
"q3": "Which sites are supported?",
|
"q3": "Which sites are supported?",
|
||||||
"a3": "YouTube, TikTok, Instagram, Facebook, X (Twitter), Vimeo, Twitch, SoundCloud, Dailymotion and over 1000 other sites. <link>See the full list</link>.",
|
"a3": "YouTube, TikTok, Instagram, Facebook, X (Twitter), Vimeo, Twitch, SoundCloud, Dailymotion and over 1000 other sites. <link>See the full list</link>.",
|
||||||
"q4": "What formats and quality can I get?",
|
"q4": "What formats and quality can I get?",
|
||||||
"a4": "Video in MP4, WebM or MKV, or audio-only in MP3, up to 1080p depending on the source video.",
|
"a4": "Video in MP4, WebM or MKV, or audio-only in MP3, up to 4K depending on the source video.",
|
||||||
"q5": "Do you keep a copy of my videos?",
|
"q5": "Do you keep a copy of my videos?",
|
||||||
"a5": "No. Files are stored temporarily and automatically deleted after 24 hours. We never share or reuse the content you download.",
|
"a5": "No. Files are stored temporarily and automatically deleted after 24 hours. We never share or reuse the content you download.",
|
||||||
"q6": "Do I need to create an account?",
|
"q6": "Do I need to create an account?",
|
||||||
@@ -104,7 +115,7 @@
|
|||||||
"faqQ1": "Can I download a full YouTube playlist?",
|
"faqQ1": "Can I download a full YouTube playlist?",
|
||||||
"faqA1": "No, only single videos are supported at the moment. Paste the direct link to the video you want.",
|
"faqA1": "No, only single videos are supported at the moment. Paste the direct link to the video you want.",
|
||||||
"faqQ2": "What's the maximum quality I can get?",
|
"faqQ2": "What's the maximum quality I can get?",
|
||||||
"faqA2": "Up to 1080p, depending on the resolutions available for that specific video."
|
"faqA2": "Up to 4K, depending on the resolutions available for that specific video."
|
||||||
},
|
},
|
||||||
"tiktok": {
|
"tiktok": {
|
||||||
"about": "TikTok videos are only playable in the app or with the platform's watermark baked in when shared elsewhere. Ombrora downloads the original TikTok video file so you can save it to your device without the in-app player.",
|
"about": "TikTok videos are only playable in the app or with the platform's watermark baked in when shared elsewhere. Ombrora downloads the original TikTok video file so you can save it to your device without the in-app player.",
|
||||||
@@ -153,7 +164,7 @@
|
|||||||
"faqQ1": "Can I download password-protected Vimeo videos?",
|
"faqQ1": "Can I download password-protected Vimeo videos?",
|
||||||
"faqA1": "No, only videos that are publicly viewable without a password can be downloaded.",
|
"faqA1": "No, only videos that are publicly viewable without a password can be downloaded.",
|
||||||
"faqQ2": "What quality can I expect?",
|
"faqQ2": "What quality can I expect?",
|
||||||
"faqA2": "It depends on what the uploader made available, up to 1080p in most cases."
|
"faqA2": "It depends on what the uploader made available, up to 4K in most cases."
|
||||||
},
|
},
|
||||||
"soundcloud": {
|
"soundcloud": {
|
||||||
"about": "SoundCloud is built for audio: tracks, remixes, podcasts and DJ sets. Ombrora downloads SoundCloud tracks directly as MP3 files.",
|
"about": "SoundCloud is built for audio: tracks, remixes, podcasts and DJ sets. Ombrora downloads SoundCloud tracks directly as MP3 files.",
|
||||||
|
|||||||
+18
-7
@@ -9,15 +9,26 @@
|
|||||||
"urlLabel": "URL del vídeo",
|
"urlLabel": "URL del vídeo",
|
||||||
"format": "Formato",
|
"format": "Formato",
|
||||||
"quality": "Calidad",
|
"quality": "Calidad",
|
||||||
"subtitles": "Subtítulos (fr, en)",
|
"subtitles": "Subtítulos",
|
||||||
|
"subtitleLanguages": "Idiomas de los subtítulos",
|
||||||
"advanced": "Opciones avanzadas",
|
"advanced": "Opciones avanzadas",
|
||||||
"formats": "mp4 · mp3 · webm · mkv",
|
"formats": "mp4 · mp3 · webm · mkv",
|
||||||
"submit": "Descargar",
|
"submit": "Descargar",
|
||||||
"submitting": "Enviando...",
|
"submitting": "Enviando...",
|
||||||
"errorRateLimit": "Demasiadas peticiones. Inténtalo de nuevo en una hora.",
|
"errorRateLimit": "Demasiadas peticiones. Inténtalo de nuevo en una hora.",
|
||||||
"errorGeneric": "Error al enviar."
|
"errorGeneric": "Error al enviar.",
|
||||||
|
"analyzing": "Analizando el vídeo...",
|
||||||
|
"probeError": "No se pudo analizar este vídeo. Comprueba el enlace o inténtalo de nuevo.",
|
||||||
|
"retry": "Reintentar",
|
||||||
|
"audioOnlySource": "Fuente solo de audio",
|
||||||
|
"clipRange": "Fragmento (opcional)",
|
||||||
|
"clipStart": "Inicio (s)",
|
||||||
|
"clipEnd": "Fin (s)",
|
||||||
|
"audioQuality": "Calidad de audio",
|
||||||
|
"videoLength": "Duración: {duration}"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
"backHome": "Volver al inicio",
|
||||||
"heading": "Estado de la descarga",
|
"heading": "Estado de la descarga",
|
||||||
"notFound": "Descarga no encontrada.",
|
"notFound": "Descarga no encontrada.",
|
||||||
"loading": "Cargando...",
|
"loading": "Cargando...",
|
||||||
@@ -39,7 +50,7 @@
|
|||||||
"private": "Privado",
|
"private": "Privado",
|
||||||
"privateDesc": "Sin datos retenidos",
|
"privateDesc": "Sin datos retenidos",
|
||||||
"formats": "Multi-formato",
|
"formats": "Multi-formato",
|
||||||
"formatsDesc": "mp4, mp3, webm, mkv — hasta 1080p"
|
"formatsDesc": "mp4, mp3, webm, mkv — hasta 4K"
|
||||||
},
|
},
|
||||||
"howItWorks": {
|
"howItWorks": {
|
||||||
"heading": "Cómo funciona",
|
"heading": "Cómo funciona",
|
||||||
@@ -47,7 +58,7 @@
|
|||||||
"step1Title": "Pega el enlace",
|
"step1Title": "Pega el enlace",
|
||||||
"step1Desc": "Copia la URL del vídeo desde YouTube, TikTok, Instagram o cualquier sitio compatible",
|
"step1Desc": "Copia la URL del vídeo desde YouTube, TikTok, Instagram o cualquier sitio compatible",
|
||||||
"step2Title": "Elige tu formato",
|
"step2Title": "Elige tu formato",
|
||||||
"step2Desc": "Selecciona MP4, MP3, WebM o MKV y la calidad deseada, hasta 1080p",
|
"step2Desc": "Selecciona MP4, MP3, WebM o MKV y la calidad deseada, hasta 4K",
|
||||||
"step3Title": "Descarga",
|
"step3Title": "Descarga",
|
||||||
"step3Desc": "Tu archivo está listo en segundos, mediante un enlace seguro válido durante 24 horas"
|
"step3Desc": "Tu archivo está listo en segundos, mediante un enlace seguro válido durante 24 horas"
|
||||||
},
|
},
|
||||||
@@ -66,7 +77,7 @@
|
|||||||
"q3": "¿Qué sitios son compatibles?",
|
"q3": "¿Qué sitios son compatibles?",
|
||||||
"a3": "YouTube, TikTok, Instagram, Facebook, X (Twitter), Vimeo, Twitch, SoundCloud, Dailymotion y más de 1000 sitios adicionales. <link>Ver la lista completa</link>.",
|
"a3": "YouTube, TikTok, Instagram, Facebook, X (Twitter), Vimeo, Twitch, SoundCloud, Dailymotion y más de 1000 sitios adicionales. <link>Ver la lista completa</link>.",
|
||||||
"q4": "¿Qué formatos y calidad puedo obtener?",
|
"q4": "¿Qué formatos y calidad puedo obtener?",
|
||||||
"a4": "Vídeo en MP4, WebM o MKV, o solo audio en MP3, hasta 1080p según el vídeo de origen.",
|
"a4": "Vídeo en MP4, WebM o MKV, o solo audio en MP3, hasta 4K según el vídeo de origen.",
|
||||||
"q5": "¿Guardáis una copia de mis vídeos?",
|
"q5": "¿Guardáis una copia de mis vídeos?",
|
||||||
"a5": "No. Los archivos se almacenan temporalmente y se eliminan automáticamente después de 24 horas. Nunca compartimos ni reutilizamos el contenido descargado.",
|
"a5": "No. Los archivos se almacenan temporalmente y se eliminan automáticamente después de 24 horas. Nunca compartimos ni reutilizamos el contenido descargado.",
|
||||||
"q6": "¿Necesito crear una cuenta?",
|
"q6": "¿Necesito crear una cuenta?",
|
||||||
@@ -104,7 +115,7 @@
|
|||||||
"faqQ1": "¿Puedo descargar una lista de reproducción completa de YouTube?",
|
"faqQ1": "¿Puedo descargar una lista de reproducción completa de YouTube?",
|
||||||
"faqA1": "No, por ahora solo se admiten vídeos individuales. Pega el enlace directo del vídeo que quieres.",
|
"faqA1": "No, por ahora solo se admiten vídeos individuales. Pega el enlace directo del vídeo que quieres.",
|
||||||
"faqQ2": "¿Cuál es la calidad máxima que puedo obtener?",
|
"faqQ2": "¿Cuál es la calidad máxima que puedo obtener?",
|
||||||
"faqA2": "Hasta 1080p, según las resoluciones disponibles para ese vídeo en concreto."
|
"faqA2": "Hasta 4K, según las resoluciones disponibles para ese vídeo en concreto."
|
||||||
},
|
},
|
||||||
"tiktok": {
|
"tiktok": {
|
||||||
"about": "Los vídeos de TikTok solo se pueden reproducir en la app o con la marca de agua de la plataforma incrustada al compartirlos fuera de ella. Ombrora descarga el archivo de vídeo original de TikTok para que puedas guardarlo en tu dispositivo sin pasar por el reproductor de la app.",
|
"about": "Los vídeos de TikTok solo se pueden reproducir en la app o con la marca de agua de la plataforma incrustada al compartirlos fuera de ella. Ombrora descarga el archivo de vídeo original de TikTok para que puedas guardarlo en tu dispositivo sin pasar por el reproductor de la app.",
|
||||||
@@ -153,7 +164,7 @@
|
|||||||
"faqQ1": "¿Puedo descargar vídeos de Vimeo protegidos con contraseña?",
|
"faqQ1": "¿Puedo descargar vídeos de Vimeo protegidos con contraseña?",
|
||||||
"faqA1": "No, solo se pueden descargar vídeos visibles públicamente sin contraseña.",
|
"faqA1": "No, solo se pueden descargar vídeos visibles públicamente sin contraseña.",
|
||||||
"faqQ2": "¿Qué calidad puedo esperar?",
|
"faqQ2": "¿Qué calidad puedo esperar?",
|
||||||
"faqA2": "Depende de lo que haya puesto a disposición quien lo subió, hasta 1080p en la mayoría de los casos."
|
"faqA2": "Depende de lo que haya puesto a disposición quien lo subió, hasta 4K en la mayoría de los casos."
|
||||||
},
|
},
|
||||||
"soundcloud": {
|
"soundcloud": {
|
||||||
"about": "SoundCloud está pensado para el audio: temas, remixes, podcasts y sets de DJ. Ombrora descarga los temas de SoundCloud directamente como archivos MP3.",
|
"about": "SoundCloud está pensado para el audio: temas, remixes, podcasts y sets de DJ. Ombrora descarga los temas de SoundCloud directamente como archivos MP3.",
|
||||||
|
|||||||
+18
-7
@@ -9,15 +9,26 @@
|
|||||||
"urlLabel": "URL de la vidéo",
|
"urlLabel": "URL de la vidéo",
|
||||||
"format": "Format",
|
"format": "Format",
|
||||||
"quality": "Qualité",
|
"quality": "Qualité",
|
||||||
"subtitles": "Sous-titres (fr, en)",
|
"subtitles": "Sous-titres",
|
||||||
|
"subtitleLanguages": "Langues des sous-titres",
|
||||||
"advanced": "Options avancées",
|
"advanced": "Options avancées",
|
||||||
"formats": "mp4 · mp3 · webm · mkv",
|
"formats": "mp4 · mp3 · webm · mkv",
|
||||||
"submit": "Télécharger",
|
"submit": "Télécharger",
|
||||||
"submitting": "Envoi en cours...",
|
"submitting": "Envoi en cours...",
|
||||||
"errorRateLimit": "Trop de soumissions. Réessayez dans une heure.",
|
"errorRateLimit": "Trop de soumissions. Réessayez dans une heure.",
|
||||||
"errorGeneric": "Erreur lors de la soumission."
|
"errorGeneric": "Erreur lors de la soumission.",
|
||||||
|
"analyzing": "Analyse de la vidéo...",
|
||||||
|
"probeError": "Impossible d'analyser cette vidéo. Vérifiez le lien ou réessayez.",
|
||||||
|
"retry": "Réessayer",
|
||||||
|
"audioOnlySource": "Source audio uniquement",
|
||||||
|
"clipRange": "Extrait (optionnel)",
|
||||||
|
"clipStart": "Début (s)",
|
||||||
|
"clipEnd": "Fin (s)",
|
||||||
|
"audioQuality": "Qualité audio",
|
||||||
|
"videoLength": "Durée : {duration}"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
"backHome": "Retour à l'accueil",
|
||||||
"heading": "Statut du téléchargement",
|
"heading": "Statut du téléchargement",
|
||||||
"notFound": "Téléchargement introuvable.",
|
"notFound": "Téléchargement introuvable.",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -39,7 +50,7 @@
|
|||||||
"private": "Privé",
|
"private": "Privé",
|
||||||
"privateDesc": "Aucune donnée conservée",
|
"privateDesc": "Aucune donnée conservée",
|
||||||
"formats": "Multi-format",
|
"formats": "Multi-format",
|
||||||
"formatsDesc": "mp4, mp3, webm, mkv — jusqu'en 1080p"
|
"formatsDesc": "mp4, mp3, webm, mkv — jusqu'en 4K"
|
||||||
},
|
},
|
||||||
"howItWorks": {
|
"howItWorks": {
|
||||||
"heading": "Comment ça marche",
|
"heading": "Comment ça marche",
|
||||||
@@ -47,7 +58,7 @@
|
|||||||
"step1Title": "Collez le lien",
|
"step1Title": "Collez le lien",
|
||||||
"step1Desc": "Copiez l'URL de la vidéo depuis YouTube, TikTok, Instagram ou tout site pris en charge",
|
"step1Desc": "Copiez l'URL de la vidéo depuis YouTube, TikTok, Instagram ou tout site pris en charge",
|
||||||
"step2Title": "Choisissez votre format",
|
"step2Title": "Choisissez votre format",
|
||||||
"step2Desc": "Sélectionnez MP4, MP3, WebM ou MKV et la qualité souhaitée, jusqu'en 1080p",
|
"step2Desc": "Sélectionnez MP4, MP3, WebM ou MKV et la qualité souhaitée, jusqu'en 4K",
|
||||||
"step3Title": "Téléchargez",
|
"step3Title": "Téléchargez",
|
||||||
"step3Desc": "Votre fichier est prêt en quelques secondes, via un lien sécurisé valable 24 heures"
|
"step3Desc": "Votre fichier est prêt en quelques secondes, via un lien sécurisé valable 24 heures"
|
||||||
},
|
},
|
||||||
@@ -66,7 +77,7 @@
|
|||||||
"q3": "Quels sites sont pris en charge ?",
|
"q3": "Quels sites sont pris en charge ?",
|
||||||
"a3": "YouTube, TikTok, Instagram, Facebook, X (Twitter), Vimeo, Twitch, SoundCloud, Dailymotion et plus de 1000 autres sites. <link>Voir la liste complète</link>.",
|
"a3": "YouTube, TikTok, Instagram, Facebook, X (Twitter), Vimeo, Twitch, SoundCloud, Dailymotion et plus de 1000 autres sites. <link>Voir la liste complète</link>.",
|
||||||
"q4": "Quels formats et quelle qualité puis-je obtenir ?",
|
"q4": "Quels formats et quelle qualité puis-je obtenir ?",
|
||||||
"a4": "Vidéo en MP4, WebM ou MKV, ou audio seul en MP3, jusqu'en 1080p selon la vidéo source.",
|
"a4": "Vidéo en MP4, WebM ou MKV, ou audio seul en MP3, jusqu'en 4K selon la vidéo source.",
|
||||||
"q5": "Conservez-vous une copie de mes vidéos ?",
|
"q5": "Conservez-vous une copie de mes vidéos ?",
|
||||||
"a5": "Non. Les fichiers sont stockés temporairement et supprimés automatiquement après 24 heures. Nous ne partageons ni ne réutilisons jamais le contenu téléchargé.",
|
"a5": "Non. Les fichiers sont stockés temporairement et supprimés automatiquement après 24 heures. Nous ne partageons ni ne réutilisons jamais le contenu téléchargé.",
|
||||||
"q6": "Dois-je créer un compte ?",
|
"q6": "Dois-je créer un compte ?",
|
||||||
@@ -104,7 +115,7 @@
|
|||||||
"faqQ1": "Puis-je télécharger une playlist YouTube entière ?",
|
"faqQ1": "Puis-je télécharger une playlist YouTube entière ?",
|
||||||
"faqA1": "Non, seules les vidéos individuelles sont prises en charge pour le moment. Collez le lien direct de la vidéo souhaitée.",
|
"faqA1": "Non, seules les vidéos individuelles sont prises en charge pour le moment. Collez le lien direct de la vidéo souhaitée.",
|
||||||
"faqQ2": "Quelle est la qualité maximale disponible ?",
|
"faqQ2": "Quelle est la qualité maximale disponible ?",
|
||||||
"faqA2": "Jusqu'à 1080p, selon les résolutions disponibles pour cette vidéo en particulier."
|
"faqA2": "Jusqu'à 4K, selon les résolutions disponibles pour cette vidéo en particulier."
|
||||||
},
|
},
|
||||||
"tiktok": {
|
"tiktok": {
|
||||||
"about": "Les vidéos TikTok ne sont lisibles que dans l'application ou avec le filigrane de la plateforme incrusté lorsqu'elles sont partagées ailleurs. Ombrora télécharge le fichier vidéo TikTok d'origine pour que vous puissiez l'enregistrer sur votre appareil sans passer par le lecteur intégré.",
|
"about": "Les vidéos TikTok ne sont lisibles que dans l'application ou avec le filigrane de la plateforme incrusté lorsqu'elles sont partagées ailleurs. Ombrora télécharge le fichier vidéo TikTok d'origine pour que vous puissiez l'enregistrer sur votre appareil sans passer par le lecteur intégré.",
|
||||||
@@ -153,7 +164,7 @@
|
|||||||
"faqQ1": "Puis-je télécharger des vidéos Vimeo protégées par mot de passe ?",
|
"faqQ1": "Puis-je télécharger des vidéos Vimeo protégées par mot de passe ?",
|
||||||
"faqA1": "Non, seules les vidéos visibles publiquement sans mot de passe peuvent être téléchargées.",
|
"faqA1": "Non, seules les vidéos visibles publiquement sans mot de passe peuvent être téléchargées.",
|
||||||
"faqQ2": "Quelle qualité puis-je obtenir ?",
|
"faqQ2": "Quelle qualité puis-je obtenir ?",
|
||||||
"faqA2": "Cela dépend de ce que l'auteur a mis à disposition, jusqu'à 1080p dans la plupart des cas."
|
"faqA2": "Cela dépend de ce que l'auteur a mis à disposition, jusqu'à 4K dans la plupart des cas."
|
||||||
},
|
},
|
||||||
"soundcloud": {
|
"soundcloud": {
|
||||||
"about": "SoundCloud est conçu pour l’audio : morceaux, remixes, podcasts et sets DJ. Ombrora télécharge les morceaux SoundCloud directement en fichiers MP3.",
|
"about": "SoundCloud est conçu pour l’audio : morceaux, remixes, podcasts et sets DJ. Ombrora télécharge les morceaux SoundCloud directement en fichiers MP3.",
|
||||||
|
|||||||
+18
-7
@@ -9,15 +9,26 @@
|
|||||||
"urlLabel": "URL del video",
|
"urlLabel": "URL del video",
|
||||||
"format": "Formato",
|
"format": "Formato",
|
||||||
"quality": "Qualità",
|
"quality": "Qualità",
|
||||||
"subtitles": "Sottotitoli (fr, en)",
|
"subtitles": "Sottotitoli",
|
||||||
|
"subtitleLanguages": "Lingue dei sottotitoli",
|
||||||
"advanced": "Opzioni avanzate",
|
"advanced": "Opzioni avanzate",
|
||||||
"formats": "mp4 · mp3 · webm · mkv",
|
"formats": "mp4 · mp3 · webm · mkv",
|
||||||
"submit": "Scarica",
|
"submit": "Scarica",
|
||||||
"submitting": "Invio in corso...",
|
"submitting": "Invio in corso...",
|
||||||
"errorRateLimit": "Troppe richieste. Riprova tra un'ora.",
|
"errorRateLimit": "Troppe richieste. Riprova tra un'ora.",
|
||||||
"errorGeneric": "Errore durante l'invio."
|
"errorGeneric": "Errore durante l'invio.",
|
||||||
|
"analyzing": "Analisi del video...",
|
||||||
|
"probeError": "Impossibile analizzare questo video. Controlla il link o riprova.",
|
||||||
|
"retry": "Riprova",
|
||||||
|
"audioOnlySource": "Sorgente solo audio",
|
||||||
|
"clipRange": "Estratto (opzionale)",
|
||||||
|
"clipStart": "Inizio (s)",
|
||||||
|
"clipEnd": "Fine (s)",
|
||||||
|
"audioQuality": "Qualità audio",
|
||||||
|
"videoLength": "Durata: {duration}"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
|
"backHome": "Torna alla home",
|
||||||
"heading": "Stato del download",
|
"heading": "Stato del download",
|
||||||
"notFound": "Download non trovato.",
|
"notFound": "Download non trovato.",
|
||||||
"loading": "Caricamento...",
|
"loading": "Caricamento...",
|
||||||
@@ -39,7 +50,7 @@
|
|||||||
"private": "Privato",
|
"private": "Privato",
|
||||||
"privateDesc": "Nessun dato conservato",
|
"privateDesc": "Nessun dato conservato",
|
||||||
"formats": "Multi-formato",
|
"formats": "Multi-formato",
|
||||||
"formatsDesc": "mp4, mp3, webm, mkv — fino a 1080p"
|
"formatsDesc": "mp4, mp3, webm, mkv — fino a 4K"
|
||||||
},
|
},
|
||||||
"howItWorks": {
|
"howItWorks": {
|
||||||
"heading": "Come funziona",
|
"heading": "Come funziona",
|
||||||
@@ -47,7 +58,7 @@
|
|||||||
"step1Title": "Incolla il link",
|
"step1Title": "Incolla il link",
|
||||||
"step1Desc": "Copia l'URL del video da YouTube, TikTok, Instagram o qualsiasi sito supportato",
|
"step1Desc": "Copia l'URL del video da YouTube, TikTok, Instagram o qualsiasi sito supportato",
|
||||||
"step2Title": "Scegli il formato",
|
"step2Title": "Scegli il formato",
|
||||||
"step2Desc": "Seleziona MP4, MP3, WebM o MKV e la qualità desiderata, fino a 1080p",
|
"step2Desc": "Seleziona MP4, MP3, WebM o MKV e la qualità desiderata, fino a 4K",
|
||||||
"step3Title": "Scarica",
|
"step3Title": "Scarica",
|
||||||
"step3Desc": "Il tuo file è pronto in pochi secondi, tramite un link sicuro valido 24 ore"
|
"step3Desc": "Il tuo file è pronto in pochi secondi, tramite un link sicuro valido 24 ore"
|
||||||
},
|
},
|
||||||
@@ -66,7 +77,7 @@
|
|||||||
"q3": "Quali siti sono supportati?",
|
"q3": "Quali siti sono supportati?",
|
||||||
"a3": "YouTube, TikTok, Instagram, Facebook, X (Twitter), Vimeo, Twitch, SoundCloud, Dailymotion e oltre 1000 altri siti. <link>Vedi l'elenco completo</link>.",
|
"a3": "YouTube, TikTok, Instagram, Facebook, X (Twitter), Vimeo, Twitch, SoundCloud, Dailymotion e oltre 1000 altri siti. <link>Vedi l'elenco completo</link>.",
|
||||||
"q4": "Quali formati e qualità posso ottenere?",
|
"q4": "Quali formati e qualità posso ottenere?",
|
||||||
"a4": "Video in MP4, WebM o MKV, oppure solo audio in MP3, fino a 1080p a seconda del video originale.",
|
"a4": "Video in MP4, WebM o MKV, oppure solo audio in MP3, fino a 4K a seconda del video originale.",
|
||||||
"q5": "Conservate una copia dei miei video?",
|
"q5": "Conservate una copia dei miei video?",
|
||||||
"a5": "No. I file vengono archiviati temporaneamente ed eliminati automaticamente dopo 24 ore. Non condividiamo né riutilizziamo mai i contenuti scaricati.",
|
"a5": "No. I file vengono archiviati temporaneamente ed eliminati automaticamente dopo 24 ore. Non condividiamo né riutilizziamo mai i contenuti scaricati.",
|
||||||
"q6": "Devo creare un account?",
|
"q6": "Devo creare un account?",
|
||||||
@@ -104,7 +115,7 @@
|
|||||||
"faqQ1": "Posso scaricare un'intera playlist di YouTube?",
|
"faqQ1": "Posso scaricare un'intera playlist di YouTube?",
|
||||||
"faqA1": "No, al momento sono supportati solo i singoli video. Incolla il link diretto del video desiderato.",
|
"faqA1": "No, al momento sono supportati solo i singoli video. Incolla il link diretto del video desiderato.",
|
||||||
"faqQ2": "Qual è la qualità massima disponibile?",
|
"faqQ2": "Qual è la qualità massima disponibile?",
|
||||||
"faqA2": "Fino a 1080p, in base alle risoluzioni disponibili per quel video specifico."
|
"faqA2": "Fino a 4K, in base alle risoluzioni disponibili per quel video specifico."
|
||||||
},
|
},
|
||||||
"tiktok": {
|
"tiktok": {
|
||||||
"about": "I video di TikTok sono riproducibili solo nell'app o con la filigrana della piattaforma incorporata quando vengono condivisi altrove. Ombrora scarica il file video originale di TikTok, così puoi salvarlo sul tuo dispositivo senza passare dal player dell'app.",
|
"about": "I video di TikTok sono riproducibili solo nell'app o con la filigrana della piattaforma incorporata quando vengono condivisi altrove. Ombrora scarica il file video originale di TikTok, così puoi salvarlo sul tuo dispositivo senza passare dal player dell'app.",
|
||||||
@@ -153,7 +164,7 @@
|
|||||||
"faqQ1": "Posso scaricare video di Vimeo protetti da password?",
|
"faqQ1": "Posso scaricare video di Vimeo protetti da password?",
|
||||||
"faqA1": "No, si possono scaricare solo i video visibili pubblicamente senza password.",
|
"faqA1": "No, si possono scaricare solo i video visibili pubblicamente senza password.",
|
||||||
"faqQ2": "Che qualità posso aspettarmi?",
|
"faqQ2": "Che qualità posso aspettarmi?",
|
||||||
"faqA2": "Dipende da cosa ha reso disponibile chi ha caricato il video, fino a 1080p nella maggior parte dei casi."
|
"faqA2": "Dipende da cosa ha reso disponibile chi ha caricato il video, fino a 4K nella maggior parte dei casi."
|
||||||
},
|
},
|
||||||
"soundcloud": {
|
"soundcloud": {
|
||||||
"about": "SoundCloud è pensato per l’audio: brani, remix, podcast e set DJ. Ombrora scarica i brani di SoundCloud direttamente come file MP3.",
|
"about": "SoundCloud è pensato per l’audio: brani, remix, podcast e set DJ. Ombrora scarica i brani di SoundCloud direttamente come file MP3.",
|
||||||
|
|||||||
Generated
+1255
-206
File diff suppressed because it is too large
Load Diff
+14
-8
@@ -8,14 +8,20 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "node server.js",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"worker": "ts-node -r tsconfig-paths/register worker/index.ts",
|
"worker": "tsx worker/index.ts",
|
||||||
"worker:cleanup": "ts-node -r tsconfig-paths/register worker/cron-cleanup.ts",
|
"worker:cleanup": "tsx worker/cron-cleanup.ts",
|
||||||
"worker:check": "ts-node -r tsconfig-paths/register worker/cron-check.ts",
|
"worker:pm2:start": "pm2 start ecosystem.config.cjs",
|
||||||
|
"worker:pm2:stop": "pm2 stop ecosystem.config.cjs",
|
||||||
|
"worker:pm2:restart": "pm2 restart ecosystem.config.cjs",
|
||||||
|
"worker:pm2:status": "pm2 status video-downloader-worker",
|
||||||
|
"worker:pm2:logs": "pm2 logs video-downloader-worker",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:generate": "prisma generate"
|
"db:migrate:deploy": "prisma migrate deploy",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"deploy": "bash scripts/deploy.sh"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
@@ -40,6 +46,7 @@
|
|||||||
"next": "^16.3.0",
|
"next": "^16.3.0",
|
||||||
"next-intl": "^4.13.6",
|
"next-intl": "^4.13.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pm2": "^7.0.3",
|
||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8"
|
"react-dom": "^19.2.8"
|
||||||
@@ -55,8 +62,7 @@
|
|||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
"jest-environment-node": "^30.4.1",
|
"jest-environment-node": "^30.4.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"ts-node": "^10.9.2",
|
"tsx": "^4.23.12",
|
||||||
"tsconfig-paths": "^4.2.0",
|
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `Download` ADD COLUMN `audioQuality` VARCHAR(191) NULL,
|
||||||
|
ADD COLUMN `clipEnd` INTEGER NULL,
|
||||||
|
ADD COLUMN `clipStart` INTEGER NULL,
|
||||||
|
ADD COLUMN `subtitleLangs` TEXT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `Download` ADD COLUMN `title` TEXT NULL;
|
||||||
@@ -14,7 +14,12 @@ model Download {
|
|||||||
format String
|
format String
|
||||||
quality String
|
quality String
|
||||||
subtitles Boolean @default(false)
|
subtitles Boolean @default(false)
|
||||||
|
subtitleLangs String? @db.Text
|
||||||
|
clipStart Int?
|
||||||
|
clipEnd Int?
|
||||||
|
audioQuality String?
|
||||||
extraArgs String? @db.Text
|
extraArgs String? @db.Text
|
||||||
|
title String? @db.Text
|
||||||
|
|
||||||
filePath String? @db.Text
|
filePath String? @db.Text
|
||||||
fileName String?
|
fileName String?
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "==> Copie de .env.prod vers .env"
|
||||||
|
cp .env.prod .env
|
||||||
|
|
||||||
|
echo "==> Installation des dependances"
|
||||||
|
npm install --include=dev
|
||||||
|
|
||||||
|
echo "==> Generation du client Prisma"
|
||||||
|
npm run db:generate
|
||||||
|
|
||||||
|
echo "==> Application des migrations Prisma (production)"
|
||||||
|
npm run db:migrate:deploy
|
||||||
|
|
||||||
|
echo "==> Build de production"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "==> Demarrage/redemarrage du worker sous pm2"
|
||||||
|
pm2 startOrRestart ecosystem.config.cjs
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
echo "==> Deploiement termine."
|
||||||
|
echo "Pensez a redemarrer l'app Next.js via cPanel > Setup Node.js App si Passenger ne recharge pas automatiquement le nouveau build."
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const { createServer } = require('http')
|
||||||
|
const next = require('next')
|
||||||
|
|
||||||
|
const port = parseInt(process.env.PORT || '3000', 10)
|
||||||
|
const app = next({ dev: false })
|
||||||
|
const handle = app.getRequestHandler()
|
||||||
|
|
||||||
|
app.prepare().then(() => {
|
||||||
|
createServer((req, res) => {
|
||||||
|
handle(req, res)
|
||||||
|
}).listen(port, () => {
|
||||||
|
console.log(`> Ready on port ${port}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -75,6 +75,7 @@ export default async function LocaleLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
|
<link rel="sitemap" type="application/xml" title="Sitemap" href="/sitemap.xml" />
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { Link } from '@/navigation'
|
||||||
import { StatusView } from '@/components/StatusView'
|
import { StatusView } from '@/components/StatusView'
|
||||||
|
|
||||||
export default async function StatusPage({
|
export default async function StatusPage({
|
||||||
@@ -11,6 +12,14 @@ export default async function StatusPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col items-center px-6 py-16">
|
<main className="flex flex-col items-center px-6 py-16">
|
||||||
|
<div className="w-full max-w-md mb-6">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-gray-500 hover:text-violet-600 dark:text-gray-400 dark:hover:text-violet-400 transition-colors"
|
||||||
|
>
|
||||||
|
← {t('backHome')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-10">
|
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-10">
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { GET } from '../route'
|
import { GET } from '../route'
|
||||||
import * as tokenLib from '@/lib/token'
|
import * as tokenLib from '@/lib/token'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { writeFileSync, unlinkSync } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import os from 'os'
|
||||||
|
|
||||||
jest.mock('@/lib/token')
|
jest.mock('@/lib/token')
|
||||||
jest.mock('@/lib/prisma', () => ({
|
jest.mock('@/lib/prisma', () => ({
|
||||||
@@ -8,12 +11,17 @@ jest.mock('@/lib/prisma', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const mockValidate = tokenLib.validateToken as jest.Mock
|
const mockValidate = tokenLib.validateToken as jest.Mock
|
||||||
|
const mockTokenUpdate = prisma.downloadToken.update as jest.Mock
|
||||||
|
|
||||||
function req(token: string) {
|
function req(token: string) {
|
||||||
return new Request(`http://localhost/api/download/${token}`)
|
return new Request(`http://localhost/api/download/${token}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('GET /api/download/[token]', () => {
|
describe('GET /api/download/[token]', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockTokenUpdate.mockResolvedValue({})
|
||||||
|
})
|
||||||
|
|
||||||
it('returns 404 for invalid or expired token', async () => {
|
it('returns 404 for invalid or expired token', async () => {
|
||||||
mockValidate.mockResolvedValue(null)
|
mockValidate.mockResolvedValue(null)
|
||||||
const res = await GET(req('bad'), { params: Promise.resolve({ token: 'bad' }) })
|
const res = await GET(req('bad'), { params: Promise.resolve({ token: 'bad' }) })
|
||||||
@@ -23,10 +31,24 @@ describe('GET /api/download/[token]', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns 404 when file does not exist on disk', async () => {
|
it('returns 404 when file does not exist on disk', async () => {
|
||||||
mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath: '/nonexistent/path/abc.mp4' })
|
mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath: '/nonexistent/path/abc.mp4', title: null })
|
||||||
const res = await GET(req('ok-tok'), { params: Promise.resolve({ token: 'ok-tok' }) })
|
const res = await GET(req('ok-tok'), { params: Promise.resolve({ token: 'ok-tok' }) })
|
||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
const body = await res.json()
|
const body = await res.json()
|
||||||
expect(body.error).toBe('File not found on server')
|
expect(body.error).toBe('File not found on server')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('serves the file with a title-based Content-Disposition filename', async () => {
|
||||||
|
const filePath = path.join(os.tmpdir(), `test-${Date.now()}.mp4`)
|
||||||
|
writeFileSync(filePath, 'video-bytes')
|
||||||
|
|
||||||
|
mockValidate.mockResolvedValue({ downloadId: 'dl-1', filePath, title: 'My Cool Video' })
|
||||||
|
const res = await GET(req('ok-tok'), { params: Promise.resolve({ token: 'ok-tok' }) })
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.headers.get('Content-Disposition')).toContain('filename="My Cool Video.mp4"')
|
||||||
|
|
||||||
|
await res.text()
|
||||||
|
unlinkSync(filePath)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createReadStream, statSync } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { validateToken } from '@/lib/token'
|
import { validateToken } from '@/lib/token'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { buildDownloadFilename, contentDispositionHeader } from '@/lib/filename'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
@@ -15,7 +16,7 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 404 })
|
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { filePath } = result
|
const { filePath, title } = result
|
||||||
|
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
try {
|
try {
|
||||||
@@ -29,12 +30,12 @@ export async function GET(
|
|||||||
data: { usedAt: new Date() },
|
data: { usedAt: new Date() },
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileName = path.basename(filePath)
|
const fileName = buildDownloadFilename(title, path.basename(filePath))
|
||||||
const stream = createReadStream(filePath)
|
const stream = createReadStream(filePath)
|
||||||
|
|
||||||
return new NextResponse(stream as unknown as ReadableStream, {
|
return new NextResponse(stream as unknown as ReadableStream, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Disposition': `attachment; filename="${fileName}"`,
|
'Content-Disposition': contentDispositionHeader(fileName),
|
||||||
'Content-Length': String(stat.size),
|
'Content-Length': String(stat.size),
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ describe('GET /api/downloads/[uuid]', () => {
|
|||||||
expect(body.fileSize).toBe('1048576')
|
expect(body.fileSize).toBe('1048576')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('derives fileName from the video title when available', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ ...baseDownload, title: 'My Video', tokens: [] })
|
||||||
|
const res = await GET(req('abc'), { params: Promise.resolve({ uuid: 'abc' }) })
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.fileName).toBe('My Video.mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the stored fileName when there is no title', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ ...baseDownload, title: null, tokens: [] })
|
||||||
|
const res = await GET(req('abc'), { params: Promise.resolve({ uuid: 'abc' }) })
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.fileName).toBe('video.mp4')
|
||||||
|
})
|
||||||
|
|
||||||
it('returns downloadToken when status is DONE and token is valid', async () => {
|
it('returns downloadToken when status is DONE and token is valid', async () => {
|
||||||
mockFindUnique.mockResolvedValue({
|
mockFindUnique.mockResolvedValue({
|
||||||
...baseDownload,
|
...baseDownload,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { buildDownloadFilename } from '@/lib/filename'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
@@ -28,7 +29,7 @@ export async function GET(
|
|||||||
format: download.format,
|
format: download.format,
|
||||||
quality: download.quality,
|
quality: download.quality,
|
||||||
subtitles: download.subtitles,
|
subtitles: download.subtitles,
|
||||||
fileName: download.fileName,
|
fileName: download.fileName ? buildDownloadFilename(download.title, download.fileName) : null,
|
||||||
fileSize: download.fileSize?.toString() ?? null,
|
fileSize: download.fileSize?.toString() ?? null,
|
||||||
errorMsg: download.errorMsg,
|
errorMsg: download.errorMsg,
|
||||||
submittedAt: download.submittedAt,
|
submittedAt: download.submittedAt,
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { POST } from '../route'
|
import { POST } from '../route'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import * as rateLimit from '@/lib/rate-limit'
|
import { downloadRateLimiter } from '@/lib/rate-limit'
|
||||||
|
|
||||||
jest.mock('@/lib/prisma', () => ({
|
jest.mock('@/lib/prisma', () => ({
|
||||||
prisma: { download: { create: jest.fn() } },
|
prisma: { download: { create: jest.fn() } },
|
||||||
}))
|
}))
|
||||||
jest.mock('@/lib/rate-limit')
|
jest.mock('@/lib/rate-limit', () => ({
|
||||||
|
downloadRateLimiter: { isLimited: jest.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
const mockCreate = prisma.download.create as jest.Mock
|
const mockCreate = prisma.download.create as jest.Mock
|
||||||
const mockIsRateLimited = rateLimit.isRateLimited as jest.Mock
|
const mockIsLimited = downloadRateLimiter.isLimited as jest.Mock
|
||||||
|
|
||||||
function req(body: object, ip = '1.2.3.4') {
|
function req(body: object, ip = '1.2.3.4') {
|
||||||
return new Request('http://localhost/api/downloads', {
|
return new Request('http://localhost/api/downloads', {
|
||||||
@@ -20,12 +22,12 @@ function req(body: object, ip = '1.2.3.4') {
|
|||||||
|
|
||||||
describe('POST /api/downloads', () => {
|
describe('POST /api/downloads', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockIsRateLimited.mockReturnValue(false)
|
mockIsLimited.mockReturnValue(false)
|
||||||
mockCreate.mockResolvedValue({ uuid: 'test-uuid' })
|
mockCreate.mockResolvedValue({ uuid: 'test-uuid' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns 429 when rate limited', async () => {
|
it('returns 429 when rate limited', async () => {
|
||||||
mockIsRateLimited.mockReturnValue(true)
|
mockIsLimited.mockReturnValue(true)
|
||||||
const res = await POST(req({ url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false }))
|
const res = await POST(req({ url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false }))
|
||||||
expect(res.status).toBe(429)
|
expect(res.status).toBe(429)
|
||||||
})
|
})
|
||||||
@@ -40,6 +42,14 @@ describe('POST /api/downloads', () => {
|
|||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns 400 when clipEnd is not greater than clipStart', async () => {
|
||||||
|
const res = await POST(req({
|
||||||
|
url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false,
|
||||||
|
clipStart: 30, clipEnd: 10,
|
||||||
|
}))
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
it('returns 201 with uuid on success', async () => {
|
it('returns 201 with uuid on success', async () => {
|
||||||
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc', format: 'mp4', quality: 'best', subtitles: false }))
|
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc', format: 'mp4', quality: 'best', subtitles: false }))
|
||||||
expect(res.status).toBe(201)
|
expect(res.status).toBe(201)
|
||||||
@@ -58,4 +68,41 @@ describe('POST /api/downloads', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('stores subtitleLangs as a comma-joined string', async () => {
|
||||||
|
await POST(req({
|
||||||
|
url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: true,
|
||||||
|
subtitleLangs: ['fr', 'en'],
|
||||||
|
}))
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ subtitleLangs: 'fr,en' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores clip range and audio quality when provided', async () => {
|
||||||
|
await POST(req({
|
||||||
|
url: 'https://y.com', format: 'mp3', quality: 'best', subtitles: false,
|
||||||
|
clipStart: 10, clipEnd: 30, audioQuality: '192',
|
||||||
|
}))
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ clipStart: 10, clipEnd: 30, audioQuality: '192' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the video title when provided', async () => {
|
||||||
|
await POST(req({
|
||||||
|
url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false,
|
||||||
|
title: 'My Video',
|
||||||
|
}))
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ title: 'My Video' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores a null title when not provided', async () => {
|
||||||
|
await POST(req({ url: 'https://y.com', format: 'mp4', quality: 'best', subtitles: false }))
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({ title: null }),
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { isRateLimited } from '@/lib/rate-limit'
|
import { downloadRateLimiter } from '@/lib/rate-limit'
|
||||||
|
import { getClientIp } from '@/lib/request-ip'
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const ip =
|
const ip = getClientIp(req)
|
||||||
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '0.0.0.0'
|
|
||||||
|
|
||||||
if (isRateLimited(ip)) {
|
if (downloadRateLimiter.isLimited(ip)) {
|
||||||
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +24,32 @@ export async function POST(req: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clipStart = body.clipStart != null ? Number(body.clipStart) : null
|
||||||
|
const clipEnd = body.clipEnd != null ? Number(body.clipEnd) : null
|
||||||
|
|
||||||
|
if (clipStart != null && clipEnd != null && clipEnd <= clipStart) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid clip range: clipEnd must be greater than clipStart' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitleLangs: string[] | null = Array.isArray(body.subtitleLangs) && body.subtitleLangs.length > 0
|
||||||
|
? body.subtitleLangs.map(String)
|
||||||
|
: null
|
||||||
|
|
||||||
const download = await prisma.download.create({
|
const download = await prisma.download.create({
|
||||||
data: {
|
data: {
|
||||||
url: String(body.url),
|
url: String(body.url),
|
||||||
format: String(body.format),
|
format: String(body.format),
|
||||||
quality: String(body.quality),
|
quality: String(body.quality),
|
||||||
subtitles: Boolean(body.subtitles),
|
subtitles: Boolean(body.subtitles),
|
||||||
|
subtitleLangs: subtitleLangs ? subtitleLangs.join(',') : null,
|
||||||
|
clipStart,
|
||||||
|
clipEnd,
|
||||||
|
audioQuality: body.audioQuality ? String(body.audioQuality) : null,
|
||||||
extraArgs: body.extraArgs ?? null,
|
extraArgs: body.extraArgs ?? null,
|
||||||
|
title: body.title ? String(body.title) : null,
|
||||||
ipAddress: ip,
|
ipAddress: ip,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { POST } from '../route'
|
||||||
|
import { probeRateLimiter } from '@/lib/rate-limit'
|
||||||
|
import { runYtdlpProbe } from '@/lib/ytdlp-probe'
|
||||||
|
|
||||||
|
jest.mock('@/lib/rate-limit', () => ({
|
||||||
|
probeRateLimiter: { isLimited: jest.fn() },
|
||||||
|
}))
|
||||||
|
jest.mock('@/lib/ytdlp-probe', () => ({
|
||||||
|
runYtdlpProbe: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockIsLimited = probeRateLimiter.isLimited as jest.Mock
|
||||||
|
const mockRunProbe = runYtdlpProbe as jest.Mock
|
||||||
|
|
||||||
|
function req(body: object, ip = '1.2.3.4') {
|
||||||
|
return new Request('http://localhost/api/probe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/probe', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockIsLimited.mockReturnValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 429 when rate limited', async () => {
|
||||||
|
mockIsLimited.mockReturnValue(true)
|
||||||
|
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc' }))
|
||||||
|
expect(res.status).toBe(429)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when url is missing', async () => {
|
||||||
|
const res = await POST(req({}))
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 400 when url is not a valid URL', async () => {
|
||||||
|
const res = await POST(req({ url: 'not-a-url' }))
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 200 with capabilities on success', async () => {
|
||||||
|
const capabilities = {
|
||||||
|
title: 'Some video',
|
||||||
|
durationSeconds: 100,
|
||||||
|
isAudioOnly: false,
|
||||||
|
availableQualities: ['best', '720p'],
|
||||||
|
subtitleLangs: ['en'],
|
||||||
|
}
|
||||||
|
mockRunProbe.mockResolvedValue(capabilities)
|
||||||
|
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc' }))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual(capabilities)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 422 when the probe fails', async () => {
|
||||||
|
mockRunProbe.mockRejectedValue(new Error('boom'))
|
||||||
|
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc' }))
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { probeRateLimiter } from '@/lib/rate-limit'
|
||||||
|
import { getClientIp } from '@/lib/request-ip'
|
||||||
|
import { runYtdlpProbe } from '@/lib/ytdlp-probe'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const ip = getClientIp(req)
|
||||||
|
|
||||||
|
if (probeRateLimiter.isLimited(ip)) {
|
||||||
|
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!body?.url || typeof body.url !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing required field: url' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(body.url)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const capabilities = await runYtdlpProbe(body.url)
|
||||||
|
return NextResponse.json(capabilities)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'PROBE_FAILED' }, { status: 422 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { SubmitForm } from '@/components/SubmitForm'
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<main style={{ padding: '2rem' }}>
|
|
||||||
<h1>Ombrora</h1>
|
|
||||||
<SubmitForm />
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { MetadataRoute } from 'next'
|
||||||
|
import { routing } from '@/i18n/routing'
|
||||||
|
import { DOWNLOADER_PLATFORMS } from '@/lib/downloader-platforms'
|
||||||
|
|
||||||
|
const STATIC_PATHS = ['', 'supported-sites']
|
||||||
|
|
||||||
|
function urlFor(baseUrl: string, locale: string, path: string) {
|
||||||
|
return `${baseUrl}/${locale}${path ? `/${path}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
||||||
|
const paths = [...STATIC_PATHS, ...DOWNLOADER_PLATFORMS.map((platform) => platform.slug)]
|
||||||
|
|
||||||
|
const entries: MetadataRoute.Sitemap = []
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const languages = Object.fromEntries(
|
||||||
|
routing.locales.map((locale) => [locale, urlFor(baseUrl, locale, path)])
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const locale of routing.locales) {
|
||||||
|
entries.push({
|
||||||
|
url: languages[locale],
|
||||||
|
alternates: { languages },
|
||||||
|
changeFrequency: path === '' ? 'weekly' : 'monthly',
|
||||||
|
priority: path === '' ? 1 : path === 'supported-sites' ? 0.5 : 0.8,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { StatusView } from '@/components/StatusView'
|
|
||||||
|
|
||||||
export default async function StatusPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ uuid: string }>
|
|
||||||
}) {
|
|
||||||
const { uuid } = await params
|
|
||||||
return (
|
|
||||||
<main style={{ padding: '2rem' }}>
|
|
||||||
<h1>Statut du telechargement</h1>
|
|
||||||
<StatusView uuid={uuid} />
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+238
-50
@@ -1,15 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, FormEvent } from 'react'
|
import { useEffect, useRef, useState, FormEvent } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
import { useRouter } from '@/navigation'
|
import { useRouter } from '@/navigation'
|
||||||
|
import { FORMATS, AUDIO_QUALITIES } from '@/lib/ytdlp-options'
|
||||||
|
|
||||||
const FORMATS = ['mp4', 'mp3', 'webm', 'mkv']
|
type ProbeCapabilities = {
|
||||||
const QUALITIES = ['best', '1080p', '720p', '480p', '360p']
|
title: string
|
||||||
|
durationSeconds: number | null
|
||||||
|
isAudioOnly: boolean
|
||||||
|
availableQualities: string[]
|
||||||
|
subtitleLangs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
function Spinner() {
|
type ProbeState =
|
||||||
|
| { status: 'idle' }
|
||||||
|
| { status: 'debouncing' }
|
||||||
|
| { status: 'probing' }
|
||||||
|
| { status: 'ready'; data: ProbeCapabilities }
|
||||||
|
| { status: 'error' }
|
||||||
|
|
||||||
|
function Spinner({ className = 'w-4 h-4' }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden>
|
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -19,19 +32,100 @@ function Spinner() {
|
|||||||
const selectClass =
|
const selectClass =
|
||||||
'rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-gray-900 dark:text-gray-50 text-sm outline-none focus:ring-2 focus:ring-violet-500'
|
'rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-gray-900 dark:text-gray-50 text-sm outline-none focus:ring-2 focus:ring-violet-500'
|
||||||
|
|
||||||
|
const numberInputClass =
|
||||||
|
'w-24 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-gray-900 dark:text-gray-50 text-sm outline-none focus:ring-2 focus:ring-violet-500'
|
||||||
|
|
||||||
|
function isValidUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
new URL(value)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function langLabel(code: string, locale: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames([locale], { type: 'language' }).of(code) ?? code
|
||||||
|
} catch {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string } = {}) {
|
export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string } = {}) {
|
||||||
const t = useTranslations('home')
|
const t = useTranslations('home')
|
||||||
|
const locale = useLocale()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
|
const [probe, setProbe] = useState<ProbeState>({ status: 'idle' })
|
||||||
|
const [retryToken, setRetryToken] = useState(0)
|
||||||
|
|
||||||
const [format, setFormat] = useState('mp4')
|
const [format, setFormat] = useState('mp4')
|
||||||
const [quality, setQuality] = useState('best')
|
const [quality, setQuality] = useState('best')
|
||||||
const [subtitles, setSubtitles] = useState(false)
|
const [subtitles, setSubtitles] = useState(false)
|
||||||
|
const [subtitleLangs, setSubtitleLangs] = useState<string[]>([])
|
||||||
|
const [audioQuality, setAudioQuality] = useState('best')
|
||||||
|
const [clipStart, setClipStart] = useState('')
|
||||||
|
const [clipEnd, setClipEnd] = useState('')
|
||||||
const [extraArgs, setExtraArgs] = useState('')
|
const [extraArgs, setExtraArgs] = useState('')
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
|
||||||
|
if (!isValidUrl(url)) {
|
||||||
|
setProbe({ status: 'idle' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setProbe({ status: 'debouncing' })
|
||||||
|
|
||||||
|
const debounce = setTimeout(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
setProbe({ status: 'probing' })
|
||||||
|
|
||||||
|
fetch('/api/probe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error('probe failed')
|
||||||
|
const data = (await res.json()) as ProbeCapabilities
|
||||||
|
setProbe({ status: 'ready', data })
|
||||||
|
|
||||||
|
if (data.isAudioOnly) {
|
||||||
|
setFormat('mp3')
|
||||||
|
}
|
||||||
|
setQuality((prev) => (data.availableQualities.includes(prev) ? prev : 'best'))
|
||||||
|
setSubtitleLangs(data.subtitleLangs)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name === 'AbortError') return
|
||||||
|
setProbe({ status: 'error' })
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => clearTimeout(debounce)
|
||||||
|
}, [url, retryToken])
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (probe.status !== 'ready') return
|
||||||
|
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
@@ -42,7 +136,12 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
|
|||||||
url,
|
url,
|
||||||
format,
|
format,
|
||||||
quality,
|
quality,
|
||||||
|
title: probe.data.title || null,
|
||||||
subtitles,
|
subtitles,
|
||||||
|
subtitleLangs: subtitles && subtitleLangs.length ? subtitleLangs : null,
|
||||||
|
audioQuality: format === 'mp3' ? audioQuality : null,
|
||||||
|
clipStart: clipStart !== '' ? Number(clipStart) : null,
|
||||||
|
clipEnd: clipEnd !== '' ? Number(clipEnd) : null,
|
||||||
extraArgs: extraArgs.trim() || null,
|
extraArgs: extraArgs.trim() || null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -63,6 +162,9 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
|
|||||||
router.push(`/status/${uuid}`)
|
router.push(`/status/${uuid}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const capabilities = probe.status === 'ready' ? probe.data : null
|
||||||
|
const submitDisabled = loading || probe.status !== 'ready'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
|
<form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
|
||||||
{/* URL input with inline submit button */}
|
{/* URL input with inline submit button */}
|
||||||
@@ -80,7 +182,7 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={submitDisabled}
|
||||||
className="flex items-center gap-2 px-5 py-3 bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white text-sm font-medium transition-colors disabled:opacity-60 cursor-pointer disabled:cursor-not-allowed"
|
className="flex items-center gap-2 px-5 py-3 bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white text-sm font-medium transition-colors disabled:opacity-60 cursor-pointer disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading && <Spinner />}
|
{loading && <Spinner />}
|
||||||
@@ -89,53 +191,139 @@ export function SubmitForm({ examplePlaceholder }: { examplePlaceholder?: string
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options row */}
|
{(probe.status === 'debouncing' || probe.status === 'probing') && (
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-3 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<label className="flex items-center gap-2">
|
<Spinner className="w-3.5 h-3.5" />
|
||||||
<span>{t('format')}</span>
|
{t('analyzing')}
|
||||||
<select
|
</p>
|
||||||
value={format}
|
)}
|
||||||
onChange={(e) => setFormat(e.target.value)}
|
|
||||||
className={selectClass}
|
{probe.status === 'error' && (
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-sm text-red-600 dark:text-red-400">
|
||||||
|
<span>{t('probeError')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRetryToken((n) => n + 1)}
|
||||||
|
className="underline hover:no-underline"
|
||||||
>
|
>
|
||||||
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
{t('retry')}
|
||||||
</select>
|
</button>
|
||||||
</label>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="flex items-center gap-2">
|
{capabilities && (
|
||||||
<span>{t('quality')}</span>
|
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<select
|
{capabilities.isAudioOnly ? (
|
||||||
value={quality}
|
<span className="rounded-lg bg-gray-100 dark:bg-slate-800 px-3 py-1 text-xs">
|
||||||
onChange={(e) => setQuality(e.target.value)}
|
{t('audioOnlySource')}
|
||||||
className={selectClass}
|
</span>
|
||||||
>
|
) : (
|
||||||
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
|
<>
|
||||||
</select>
|
<label className="flex items-center gap-2">
|
||||||
</label>
|
<span>{t('format')}</span>
|
||||||
|
<select value={format} onChange={(e) => setFormat(e.target.value)} className={selectClass}>
|
||||||
|
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
{format !== 'mp3' && (
|
||||||
<input
|
<label className="flex items-center gap-2">
|
||||||
type="checkbox"
|
<span>{t('quality')}</span>
|
||||||
checked={subtitles}
|
<select value={quality} onChange={(e) => setQuality(e.target.value)} className={selectClass}>
|
||||||
onChange={(e) => setSubtitles(e.target.checked)}
|
{capabilities.availableQualities.map((q) => <option key={q}>{q}</option>)}
|
||||||
className="accent-violet-600 w-4 h-4"
|
</select>
|
||||||
/>
|
</label>
|
||||||
<span>{t('subtitles')}</span>
|
)}
|
||||||
</label>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<details className="w-full mt-1">
|
{format === 'mp3' && (
|
||||||
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
|
<label className="flex items-center gap-2">
|
||||||
{t('advanced')}
|
<span>{t('audioQuality')}</span>
|
||||||
</summary>
|
<select value={audioQuality} onChange={(e) => setAudioQuality(e.target.value)} className={selectClass}>
|
||||||
<input
|
{AUDIO_QUALITIES.map((q) => <option key={q}>{q}</option>)}
|
||||||
type="text"
|
</select>
|
||||||
value={extraArgs}
|
</label>
|
||||||
onChange={(e) => setExtraArgs(e.target.value)}
|
)}
|
||||||
placeholder='["--sponsorblock-remove","all"]'
|
|
||||||
className="mt-2 w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
|
{capabilities.subtitleLangs.length > 0 && (
|
||||||
/>
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
</details>
|
<input
|
||||||
</div>
|
type="checkbox"
|
||||||
|
checked={subtitles}
|
||||||
|
onChange={(e) => setSubtitles(e.target.checked)}
|
||||||
|
className="accent-violet-600 w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>{t('subtitles')}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subtitles && capabilities.subtitleLangs.length > 0 && (
|
||||||
|
<div className="w-full flex flex-wrap items-center gap-x-4 gap-y-1 pl-1">
|
||||||
|
<span className="text-xs text-gray-400 dark:text-slate-500">{t('subtitleLanguages')}:</span>
|
||||||
|
{capabilities.subtitleLangs.map((lang) => (
|
||||||
|
<label key={lang} className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={subtitleLangs.includes(lang)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSubtitleLangs((prev) =>
|
||||||
|
e.target.checked ? [...prev, lang] : prev.filter((l) => l !== lang)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="accent-violet-600 w-3.5 h-3.5"
|
||||||
|
/>
|
||||||
|
{langLabel(lang, locale)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="w-full flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span className="text-xs text-gray-400 dark:text-slate-500">{t('clipRange')}</span>
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs">{t('clipStart')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={capabilities.durationSeconds ?? undefined}
|
||||||
|
value={clipStart}
|
||||||
|
onChange={(e) => setClipStart(e.target.value)}
|
||||||
|
className={numberInputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs">{t('clipEnd')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={capabilities.durationSeconds ?? undefined}
|
||||||
|
value={clipEnd}
|
||||||
|
onChange={(e) => setClipEnd(e.target.value)}
|
||||||
|
className={numberInputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{capabilities.durationSeconds != null && (
|
||||||
|
<span className="text-xs text-gray-400 dark:text-slate-500">
|
||||||
|
{t('videoLength', { duration: formatDuration(capabilities.durationSeconds) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="w-full mt-1">
|
||||||
|
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
|
||||||
|
{t('advanced')}
|
||||||
|
</summary>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={extraArgs}
|
||||||
|
onChange={(e) => setExtraArgs(e.target.value)}
|
||||||
|
placeholder='["--sponsorblock-remove","all"]'
|
||||||
|
className="mt-2 w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { sanitizeFilenameStem, buildDownloadFilename, contentDispositionHeader } from '../filename'
|
||||||
|
|
||||||
|
describe('sanitizeFilenameStem', () => {
|
||||||
|
it('strips filesystem-illegal characters', () => {
|
||||||
|
expect(sanitizeFilenameStem('a/b\\c:d*e?f"g<h>i|j')).toBe('a b c d e f g h i j')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses whitespace and trims', () => {
|
||||||
|
expect(sanitizeFilenameStem(' My Video Title ')).toBe('My Video Title')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to "download" when the result is empty', () => {
|
||||||
|
expect(sanitizeFilenameStem('///???')).toBe('download')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates very long titles', () => {
|
||||||
|
const long = 'a'.repeat(300)
|
||||||
|
expect(sanitizeFilenameStem(long).length).toBe(150)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildDownloadFilename', () => {
|
||||||
|
it('returns the stored filename when title is null', () => {
|
||||||
|
expect(buildDownloadFilename(null, 'abc-123.mp4')).toBe('abc-123.mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds a sanitized name with the original extension', () => {
|
||||||
|
expect(buildDownloadFilename('My Awesome Video', 'abc-123.mp4')).toBe('My Awesome Video.mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the extension for audio downloads', () => {
|
||||||
|
expect(buildDownloadFilename('Podcast Episode', 'abc-123.mp3')).toBe('Podcast Episode.mp3')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('contentDispositionHeader', () => {
|
||||||
|
it('includes an ASCII fallback and a UTF-8 encoded filename*', () => {
|
||||||
|
const header = contentDispositionHeader('Café Vidéo.mp4')
|
||||||
|
expect(header).toContain('attachment;')
|
||||||
|
expect(header).toContain('filename="Caf_ Vid_o.mp4"')
|
||||||
|
expect(header).toContain(`filename*=UTF-8''${encodeURIComponent('Café Vidéo.mp4')}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,41 +1,53 @@
|
|||||||
import { isRateLimited } from '../rate-limit'
|
import { createRateLimiter } from '../rate-limit'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
jest.resetModules()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('isRateLimited', () => {
|
describe('createRateLimiter', () => {
|
||||||
it('allows requests under the limit', () => {
|
it('allows requests under the limit', () => {
|
||||||
|
const limiter = createRateLimiter(5, 3_600_000)
|
||||||
const ip = '1.2.3.4'
|
const ip = '1.2.3.4'
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
expect(isRateLimited(ip)).toBe(false)
|
expect(limiter.isLimited(ip)).toBe(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('blocks the 6th request within the window', () => {
|
it('blocks the 6th request within the window', () => {
|
||||||
|
const limiter = createRateLimiter(5, 3_600_000)
|
||||||
const ip = '10.0.0.1'
|
const ip = '10.0.0.1'
|
||||||
for (let i = 0; i < 5; i++) isRateLimited(ip)
|
for (let i = 0; i < 5; i++) limiter.isLimited(ip)
|
||||||
expect(isRateLimited(ip)).toBe(true)
|
expect(limiter.isLimited(ip)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resets after the window expires', () => {
|
it('resets after the window expires', () => {
|
||||||
|
const limiter = createRateLimiter(5, 3_600_000)
|
||||||
const ip = '10.0.0.2'
|
const ip = '10.0.0.2'
|
||||||
for (let i = 0; i < 5; i++) isRateLimited(ip)
|
for (let i = 0; i < 5; i++) limiter.isLimited(ip)
|
||||||
expect(isRateLimited(ip)).toBe(true)
|
expect(limiter.isLimited(ip)).toBe(true)
|
||||||
jest.advanceTimersByTime(3_600_001)
|
jest.advanceTimersByTime(3_600_001)
|
||||||
expect(isRateLimited(ip)).toBe(false)
|
expect(limiter.isLimited(ip)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('tracks different IPs independently', () => {
|
it('tracks different IPs independently', () => {
|
||||||
|
const limiter = createRateLimiter(5, 3_600_000)
|
||||||
const ipA = '192.168.1.1'
|
const ipA = '192.168.1.1'
|
||||||
const ipB = '192.168.1.2'
|
const ipB = '192.168.1.2'
|
||||||
for (let i = 0; i < 5; i++) isRateLimited(ipA)
|
for (let i = 0; i < 5; i++) limiter.isLimited(ipA)
|
||||||
expect(isRateLimited(ipA)).toBe(true)
|
expect(limiter.isLimited(ipA)).toBe(true)
|
||||||
expect(isRateLimited(ipB)).toBe(false)
|
expect(limiter.isLimited(ipB)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks separate limiter instances independently', () => {
|
||||||
|
const limiterA = createRateLimiter(1, 3_600_000)
|
||||||
|
const limiterB = createRateLimiter(1, 3_600_000)
|
||||||
|
const ip = '5.5.5.5'
|
||||||
|
expect(limiterA.isLimited(ip)).toBe(false)
|
||||||
|
expect(limiterA.isLimited(ip)).toBe(true)
|
||||||
|
expect(limiterB.isLimited(ip)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,4 +60,16 @@ describe('validateToken', () => {
|
|||||||
filePath: '/storage/abc.mp4',
|
filePath: '/storage/abc.mp4',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns the video title alongside the file path', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({
|
||||||
|
expiresAt: new Date(Date.now() + 3_600_000),
|
||||||
|
download: { id: 'dl-1', filePath: '/storage/abc.mp4', title: 'My Video' },
|
||||||
|
})
|
||||||
|
expect(await validateToken('tok')).toEqual({
|
||||||
|
downloadId: 'dl-1',
|
||||||
|
filePath: '/storage/abc.mp4',
|
||||||
|
title: 'My Video',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const mockExistsSync = jest.fn()
|
||||||
|
jest.mock('fs', () => ({ existsSync: (...args: unknown[]) => mockExistsSync(...args) }))
|
||||||
|
jest.mock('../../../config/app.config', () => ({ config: { BIN_DIR: '/bin', PYTHON_BIN: 'python3.11' } }))
|
||||||
|
|
||||||
|
import { buildYtdlpCommand } from '../ytdlp'
|
||||||
|
|
||||||
|
describe('buildYtdlpCommand', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
mockExistsSync.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs the bundled zipapp through the configured Python interpreter', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true)
|
||||||
|
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
|
||||||
|
expect(command).toBe('python3.11')
|
||||||
|
expect(args[0]).toContain('yt-dlp')
|
||||||
|
expect(args.slice(1)).toEqual(['-J', 'https://x.test'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs a system-wide yt-dlp directly when no bundled binary exists', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false)
|
||||||
|
const { command, args } = buildYtdlpCommand(['-J', 'https://x.test'])
|
||||||
|
expect(command).toBe('yt-dlp')
|
||||||
|
expect(args).toEqual(['-J', 'https://x.test'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { parseProbeOutput, buildProbeArgs, ProbeError } from '../ytdlp-probe'
|
||||||
|
|
||||||
|
function json(obj: unknown): string {
|
||||||
|
return JSON.stringify(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildProbeArgs', () => {
|
||||||
|
it('dumps JSON without downloading and disables playlists', () => {
|
||||||
|
const args = buildProbeArgs('https://youtube.com/watch?v=abc')
|
||||||
|
expect(args).toContain('-J')
|
||||||
|
expect(args).toContain('--skip-download')
|
||||||
|
expect(args).toContain('--no-playlist')
|
||||||
|
expect(args[args.length - 1]).toBe('https://youtube.com/watch?v=abc')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseProbeOutput', () => {
|
||||||
|
it('throws a ProbeError on invalid JSON', () => {
|
||||||
|
expect(() => parseProbeOutput('not json')).toThrow(ProbeError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects a video with multiple qualities and subtitles', () => {
|
||||||
|
const result = parseProbeOutput(json({
|
||||||
|
title: 'Some video',
|
||||||
|
duration: 125,
|
||||||
|
formats: [
|
||||||
|
{ height: 360, vcodec: 'avc1' },
|
||||||
|
{ height: 720, vcodec: 'avc1' },
|
||||||
|
{ height: 1080, vcodec: 'avc1' },
|
||||||
|
{ vcodec: 'none', acodec: 'opus' },
|
||||||
|
],
|
||||||
|
subtitles: { en: [{}] },
|
||||||
|
automatic_captions: { fr: [{}], es: [{}] },
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(result.isAudioOnly).toBe(false)
|
||||||
|
expect(result.durationSeconds).toBe(125)
|
||||||
|
expect(result.availableQualities).toEqual(['best', '1080p', '720p', '480p', '360p'])
|
||||||
|
expect(result.subtitleLangs).toEqual(['fr', 'en', 'es'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes 4K and 2K when the source tops out at 4K', () => {
|
||||||
|
const result = parseProbeOutput(json({
|
||||||
|
formats: [
|
||||||
|
{ height: 1080, vcodec: 'avc1' },
|
||||||
|
{ height: 1440, vcodec: 'avc1' },
|
||||||
|
{ height: 2160, vcodec: 'avc1' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
expect(result.availableQualities).toEqual(['best', '2160p', '1440p', '1080p', '720p', '480p', '360p'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes 2K but excludes 4K when the source tops out below 4K', () => {
|
||||||
|
const result = parseProbeOutput(json({
|
||||||
|
formats: [
|
||||||
|
{ height: 1080, vcodec: 'avc1' },
|
||||||
|
{ height: 1440, vcodec: 'avc1' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
expect(result.availableQualities).toEqual(['best', '1440p', '1080p', '720p', '480p', '360p'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps available qualities to the max height found', () => {
|
||||||
|
const result = parseProbeOutput(json({
|
||||||
|
formats: [
|
||||||
|
{ height: 360, vcodec: 'avc1' },
|
||||||
|
{ height: 480, vcodec: 'avc1' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
expect(result.availableQualities).toEqual(['best', '480p', '360p'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects an audio-only source and hides quality options', () => {
|
||||||
|
const result = parseProbeOutput(json({
|
||||||
|
title: 'Some track',
|
||||||
|
duration: 200,
|
||||||
|
formats: [
|
||||||
|
{ vcodec: 'none', acodec: 'mp3' },
|
||||||
|
{ vcodec: 'none', acodec: 'opus' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
expect(result.isAudioOnly).toBe(true)
|
||||||
|
expect(result.availableQualities).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty subtitle list when none are available', () => {
|
||||||
|
const result = parseProbeOutput(json({
|
||||||
|
formats: [{ height: 720, vcodec: 'avc1' }],
|
||||||
|
}))
|
||||||
|
expect(result.subtitleLangs).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults duration to null when missing', () => {
|
||||||
|
const result = parseProbeOutput(json({ formats: [] }))
|
||||||
|
expect(result.durationSeconds).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,10 @@ const base = {
|
|||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
quality: 'best',
|
quality: 'best',
|
||||||
subtitles: false,
|
subtitles: false,
|
||||||
|
subtitleLangs: null,
|
||||||
|
clipStart: null,
|
||||||
|
clipEnd: null,
|
||||||
|
audioQuality: null,
|
||||||
extraArgs: null,
|
extraArgs: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,23 +26,96 @@ describe('buildYtdlpArgs', () => {
|
|||||||
expect(args[idx + 1]).toContain('%(ext)s')
|
expect(args[idx + 1]).toContain('%(ext)s')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds format filter when quality is not "best"', () => {
|
it('always sets --merge-output-format to the chosen video format', () => {
|
||||||
const args = buildYtdlpArgs({ ...base, quality: '1080p' })
|
const args = buildYtdlpArgs(base)
|
||||||
expect(args).toContain('-f')
|
const idx = args.indexOf('--merge-output-format')
|
||||||
|
expect(idx).toBeGreaterThan(-1)
|
||||||
|
expect(args[idx + 1]).toBe('mp4')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not add -f flag when quality is "best"', () => {
|
it('adds a height filter to -f when quality is not "best"', () => {
|
||||||
expect(buildYtdlpArgs(base)).not.toContain('-f')
|
const args = buildYtdlpArgs({ ...base, quality: '1080p' })
|
||||||
|
const idx = args.indexOf('-f')
|
||||||
|
expect(idx).toBeGreaterThan(-1)
|
||||||
|
expect(args[idx + 1]).toContain('height<=?1080')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a height filter to -f for 4K quality', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, quality: '2160p' })
|
||||||
|
const idx = args.indexOf('-f')
|
||||||
|
expect(idx).toBeGreaterThan(-1)
|
||||||
|
expect(args[idx + 1]).toContain('height<=?2160')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add a height filter when quality is "best"', () => {
|
||||||
|
const args = buildYtdlpArgs(base)
|
||||||
|
const idx = args.indexOf('-f')
|
||||||
|
expect(args[idx + 1]).not.toContain('height<=?')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts audio with -x and --audio-format when format is mp3', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, format: 'mp3' })
|
||||||
|
expect(args).toContain('-x')
|
||||||
|
const idx = args.indexOf('--audio-format')
|
||||||
|
expect(args[idx + 1]).toBe('mp3')
|
||||||
|
expect(args).not.toContain('--merge-output-format')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets --audio-quality to 0 for best mp3 quality', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, format: 'mp3', audioQuality: 'best' })
|
||||||
|
const idx = args.indexOf('--audio-quality')
|
||||||
|
expect(args[idx + 1]).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets --audio-quality to a bitrate when a specific mp3 quality is given', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, format: 'mp3', audioQuality: '192' })
|
||||||
|
const idx = args.indexOf('--audio-quality')
|
||||||
|
expect(args[idx + 1]).toBe('192K')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds subtitle flags when subtitles is true', () => {
|
it('adds subtitle flags when subtitles is true', () => {
|
||||||
const args = buildYtdlpArgs({ ...base, subtitles: true })
|
const args = buildYtdlpArgs({ ...base, subtitles: true })
|
||||||
expect(args).toContain('--write-sub')
|
expect(args).toContain('--write-subs')
|
||||||
|
expect(args).toContain('--write-auto-subs')
|
||||||
expect(args).toContain('--sub-lang')
|
expect(args).toContain('--sub-lang')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not add subtitle flags when subtitles is false', () => {
|
it('does not add subtitle flags when subtitles is false', () => {
|
||||||
expect(buildYtdlpArgs(base)).not.toContain('--write-sub')
|
expect(buildYtdlpArgs(base)).not.toContain('--write-subs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults subtitle languages to fr,en when none are given', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, subtitles: true })
|
||||||
|
const idx = args.indexOf('--sub-lang')
|
||||||
|
expect(args[idx + 1]).toBe('fr,en')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the given subtitle languages when provided', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, subtitles: true, subtitleLangs: ['es', 'it'] })
|
||||||
|
const idx = args.indexOf('--sub-lang')
|
||||||
|
expect(args[idx + 1]).toBe('es,it')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds --download-sections when a clip range is given', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, clipStart: 10, clipEnd: 30 })
|
||||||
|
const idx = args.indexOf('--download-sections')
|
||||||
|
expect(args[idx + 1]).toBe('*10-30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults clip start to 0 when only clipEnd is given', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, clipEnd: 30 })
|
||||||
|
const idx = args.indexOf('--download-sections')
|
||||||
|
expect(args[idx + 1]).toBe('*0-30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the clip end open when only clipStart is given', () => {
|
||||||
|
const args = buildYtdlpArgs({ ...base, clipStart: 10 })
|
||||||
|
const idx = args.indexOf('--download-sections')
|
||||||
|
expect(args[idx + 1]).toBe('*10-')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add --download-sections when no clip range is given', () => {
|
||||||
|
expect(buildYtdlpArgs(base)).not.toContain('--download-sections')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('appends extra args from a JSON array string', () => {
|
it('appends extra args from a JSON array string', () => {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const ILLEGAL_CHARS = /[<>:"/\\|?*\x00-\x1F]/g
|
||||||
|
|
||||||
|
export function sanitizeFilenameStem(title: string): string {
|
||||||
|
const cleaned = title.replace(ILLEGAL_CHARS, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
return cleaned.slice(0, 150) || 'download'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDownloadFilename(title: string | null, storedFileName: string): string {
|
||||||
|
if (!title) return storedFileName
|
||||||
|
const ext = path.extname(storedFileName)
|
||||||
|
return `${sanitizeFilenameStem(title)}${ext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentDispositionHeader(filename: string): string {
|
||||||
|
const asciiFallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'")
|
||||||
|
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`
|
||||||
|
}
|
||||||
+27
-12
@@ -1,19 +1,34 @@
|
|||||||
import { config } from '../../config/app.config'
|
import { config } from '../../config/app.config'
|
||||||
|
|
||||||
type Entry = { count: number; resetAt: number }
|
type Entry = { count: number; resetAt: number }
|
||||||
const store = new Map<string, Entry>()
|
|
||||||
|
|
||||||
export function isRateLimited(ip: string): boolean {
|
export function createRateLimiter(max: number, windowMs: number) {
|
||||||
const now = Date.now()
|
const store = new Map<string, Entry>()
|
||||||
const entry = store.get(ip)
|
|
||||||
|
|
||||||
if (!entry || now > entry.resetAt) {
|
return {
|
||||||
store.set(ip, { count: 1, resetAt: now + config.RATE_LIMIT_WINDOW_MS })
|
isLimited(ip: string): boolean {
|
||||||
return false
|
const now = Date.now()
|
||||||
|
const entry = store.get(ip)
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
store.set(ip, { count: 1, resetAt: now + windowMs })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= max) return true
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
return false
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.count >= config.RATE_LIMIT_MAX) return true
|
|
||||||
|
|
||||||
entry.count++
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const downloadRateLimiter = createRateLimiter(
|
||||||
|
config.RATE_LIMIT_MAX,
|
||||||
|
config.RATE_LIMIT_WINDOW_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
export const probeRateLimiter = createRateLimiter(
|
||||||
|
config.PROBE_RATE_LIMIT_MAX,
|
||||||
|
config.PROBE_RATE_LIMIT_WINDOW_MS
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
export function getClientIp(req: NextRequest): string {
|
||||||
|
return req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '0.0.0.0'
|
||||||
|
}
|
||||||
+7
-3
@@ -13,13 +13,17 @@ export async function createToken(downloadId: string): Promise<string> {
|
|||||||
|
|
||||||
export async function validateToken(
|
export async function validateToken(
|
||||||
token: string
|
token: string
|
||||||
): Promise<{ downloadId: string; filePath: string } | null> {
|
): Promise<{ downloadId: string; filePath: string; title: string | null } | null> {
|
||||||
const record = await prisma.downloadToken.findUnique({
|
const record = await prisma.downloadToken.findUnique({
|
||||||
where: { token },
|
where: { token },
|
||||||
include: { download: { select: { id: true, filePath: true } } },
|
include: { download: { select: { id: true, filePath: true, title: true } } },
|
||||||
})
|
})
|
||||||
if (!record) return null
|
if (!record) return null
|
||||||
if (record.expiresAt < new Date()) return null
|
if (record.expiresAt < new Date()) return null
|
||||||
if (!record.download.filePath) return null
|
if (!record.download.filePath) return null
|
||||||
return { downloadId: record.download.id, filePath: record.download.filePath }
|
return {
|
||||||
|
downloadId: record.download.id,
|
||||||
|
filePath: record.download.filePath,
|
||||||
|
title: record.download.title,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const FORMATS = ['mp4', 'mp3', 'webm', 'mkv'] as const
|
||||||
|
export const QUALITIES = ['best', '2160p', '1440p', '1080p', '720p', '480p', '360p'] as const
|
||||||
|
export const AUDIO_QUALITIES = ['best', '192', '128'] as const
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { spawn } from 'child_process'
|
||||||
|
import { config } from '../../config/app.config'
|
||||||
|
import { buildYtdlpCommand } from './ytdlp'
|
||||||
|
import { QUALITIES } from './ytdlp-options'
|
||||||
|
|
||||||
|
export type ProbeCapabilities = {
|
||||||
|
title: string
|
||||||
|
durationSeconds: number | null
|
||||||
|
isAudioOnly: boolean
|
||||||
|
availableQualities: string[]
|
||||||
|
subtitleLangs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type YtdlpFormat = {
|
||||||
|
height?: number | null
|
||||||
|
vcodec?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type YtdlpProbeJson = {
|
||||||
|
title?: string
|
||||||
|
duration?: number | null
|
||||||
|
formats?: YtdlpFormat[]
|
||||||
|
subtitles?: Record<string, unknown>
|
||||||
|
automatic_captions?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProbeError extends Error {}
|
||||||
|
|
||||||
|
export function buildProbeArgs(url: string): string[] {
|
||||||
|
return ['--no-warnings', '--skip-download', '--no-playlist', '-J', url]
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANG_PRIORITY = ['fr', 'en']
|
||||||
|
|
||||||
|
export function parseProbeOutput(stdout: string): ProbeCapabilities {
|
||||||
|
let json: YtdlpProbeJson
|
||||||
|
try {
|
||||||
|
json = JSON.parse(stdout)
|
||||||
|
} catch {
|
||||||
|
throw new ProbeError('yt-dlp returned invalid JSON')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formats = json.formats ?? []
|
||||||
|
const videoFormats = formats.filter((f) => f.vcodec && f.vcodec !== 'none')
|
||||||
|
const isAudioOnly = formats.length > 0 && videoFormats.length === 0
|
||||||
|
|
||||||
|
const heights = videoFormats
|
||||||
|
.map((f) => f.height)
|
||||||
|
.filter((h): h is number => typeof h === 'number')
|
||||||
|
const maxHeight = heights.length ? Math.max(...heights) : null
|
||||||
|
|
||||||
|
const availableQualities = isAudioOnly
|
||||||
|
? []
|
||||||
|
: QUALITIES.filter(
|
||||||
|
(q) => q === 'best' || (maxHeight != null && Number(q.replace('p', '')) <= maxHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
const langs = new Set<string>([
|
||||||
|
...Object.keys(json.subtitles ?? {}),
|
||||||
|
...Object.keys(json.automatic_captions ?? {}),
|
||||||
|
])
|
||||||
|
const subtitleLangs = [
|
||||||
|
...LANG_PRIORITY.filter((l) => langs.has(l)),
|
||||||
|
...[...langs].filter((l) => !LANG_PRIORITY.includes(l)).sort(),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: json.title ?? '',
|
||||||
|
durationSeconds: typeof json.duration === 'number' ? json.duration : null,
|
||||||
|
isAudioOnly,
|
||||||
|
availableQualities,
|
||||||
|
subtitleLangs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runYtdlpProbe(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number = config.PROBE_TIMEOUT_MS
|
||||||
|
): Promise<ProbeCapabilities> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { command, args } = buildYtdlpCommand(buildProbeArgs(url))
|
||||||
|
const proc = spawn(command, args)
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
proc.kill('SIGKILL')
|
||||||
|
reject(new ProbeError('yt-dlp probe timed out'))
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
proc.stdout.on('data', (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString()
|
||||||
|
})
|
||||||
|
proc.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new ProbeError(err.message))
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new ProbeError(stderr || `yt-dlp exited with code ${code}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolve(parseProbeOutput(stdout))
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
+58
-6
@@ -8,27 +8,79 @@ export type YtdlpParams = {
|
|||||||
format: string
|
format: string
|
||||||
quality: string
|
quality: string
|
||||||
subtitles: boolean
|
subtitles: boolean
|
||||||
|
subtitleLangs: string[] | null
|
||||||
|
clipStart: number | null
|
||||||
|
clipEnd: number | null
|
||||||
|
audioQuality: string | null
|
||||||
extraArgs: string | null
|
extraArgs: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveYtdlpBin(): string {
|
||||||
|
const local = path.join(config.BIN_DIR, 'yt-dlp')
|
||||||
|
return existsSync(local) ? local : 'yt-dlp'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type YtdlpCommand = { command: string; args: string[] }
|
||||||
|
|
||||||
|
// bin/yt-dlp is a pure-Python zipapp, not a standalone binary: it has no bundled
|
||||||
|
// interpreter or native libs to extract/mmap, so it works on hosts with a
|
||||||
|
// noexec /tmp (unlike yt-dlp's PyInstaller-built binaries). It relies on a
|
||||||
|
// `#!/usr/bin/env python3` shebang, which spawn() can't honor on Windows and
|
||||||
|
// which resolves to whatever `python3` happens to be on PATH elsewhere (on
|
||||||
|
// o2switch that's an unsupported 3.6) — so it's always invoked explicitly
|
||||||
|
// through PYTHON_BIN. A system-wide `yt-dlp` (PATH fallback) already has its
|
||||||
|
// own proper launcher and is run directly.
|
||||||
|
export function buildYtdlpCommand(args: string[]): YtdlpCommand {
|
||||||
|
const bin = resolveYtdlpBin()
|
||||||
|
const isBundledScript = bin !== 'yt-dlp'
|
||||||
|
|
||||||
|
if (isBundledScript) {
|
||||||
|
return { command: config.PYTHON_BIN, args: [bin, ...args] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: bin, args }
|
||||||
|
}
|
||||||
|
|
||||||
export function buildYtdlpArgs(params: YtdlpParams): string[] {
|
export function buildYtdlpArgs(params: YtdlpParams): string[] {
|
||||||
const { url, uuid, format, quality, subtitles, extraArgs } = params
|
const {
|
||||||
|
url,
|
||||||
|
uuid,
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
subtitles,
|
||||||
|
subtitleLangs,
|
||||||
|
clipStart,
|
||||||
|
clipEnd,
|
||||||
|
audioQuality,
|
||||||
|
extraArgs,
|
||||||
|
} = params
|
||||||
const outputTemplate = path.join(config.STORAGE_PATH, `${uuid}.%(ext)s`)
|
const outputTemplate = path.join(config.STORAGE_PATH, `${uuid}.%(ext)s`)
|
||||||
|
|
||||||
const args: string[] = ['--no-playlist', '-o', outputTemplate]
|
const args: string[] = ['--no-playlist', '-o', outputTemplate]
|
||||||
|
|
||||||
const ffmpegBin = path.join(config.BIN_DIR, 'ffmpeg')
|
// bin/ffmpeg is a Linux ELF binary bundled for o2switch prod deploy; it can't
|
||||||
|
// run on Windows, so local dev instead looks for a bin/ffmpeg.exe.
|
||||||
|
const ffmpegBin = path.join(config.BIN_DIR, process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')
|
||||||
if (existsSync(ffmpegBin)) {
|
if (existsSync(ffmpegBin)) {
|
||||||
args.push('--ffmpeg-location', ffmpegBin)
|
args.push('--ffmpeg-location', ffmpegBin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quality !== 'best') {
|
if (format === 'mp3') {
|
||||||
const height = quality.replace('p', '')
|
args.push('-x', '--audio-format', 'mp3')
|
||||||
args.push('-f', `${format}[height<=?${height}]+bestaudio/best[height<=?${height}]`)
|
args.push('--audio-quality', audioQuality && audioQuality !== 'best' ? `${audioQuality}K` : '0')
|
||||||
|
} else {
|
||||||
|
const heightFilter = quality !== 'best' ? `[height<=?${quality.replace('p', '')}]` : ''
|
||||||
|
args.push('-f', `bestvideo${heightFilter}+bestaudio/best${heightFilter}`)
|
||||||
|
args.push('--merge-output-format', format)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subtitles) {
|
if (subtitles) {
|
||||||
args.push('--write-sub', '--sub-lang', 'fr,en')
|
const langs = subtitleLangs?.length ? subtitleLangs.join(',') : 'fr,en'
|
||||||
|
args.push('--write-subs', '--write-auto-subs', '--sub-lang', langs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clipStart != null || clipEnd != null) {
|
||||||
|
args.push('--download-sections', `*${clipStart ?? 0}-${clipEnd ?? ''}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extraArgs) {
|
if (extraArgs) {
|
||||||
|
|||||||
@@ -28,12 +28,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ts-node": {
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "commonjs",
|
|
||||||
"moduleResolution": "node"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -29,6 +29,10 @@ const baseDownload = {
|
|||||||
format: 'mp4',
|
format: 'mp4',
|
||||||
quality: 'best',
|
quality: 'best',
|
||||||
subtitles: false,
|
subtitles: false,
|
||||||
|
subtitleLangs: null,
|
||||||
|
clipStart: null,
|
||||||
|
clipEnd: null,
|
||||||
|
audioQuality: null,
|
||||||
extraArgs: null,
|
extraArgs: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
||||||
import { spawn } from 'child_process'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
const PID_FILE = path.join(__dirname, 'worker.pid')
|
|
||||||
|
|
||||||
function isRunning(pid: number): boolean {
|
|
||||||
try {
|
|
||||||
process.kill(pid, 0)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existsSync(PID_FILE)) {
|
|
||||||
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
|
|
||||||
if (!isNaN(pid) && isRunning(pid)) {
|
|
||||||
console.log(`[cron-check] worker already running (PID ${pid})`)
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = spawn(
|
|
||||||
'node',
|
|
||||||
[
|
|
||||||
'-r', 'ts-node/register',
|
|
||||||
'-r', 'tsconfig-paths/register',
|
|
||||||
path.join(__dirname, 'index.ts'),
|
|
||||||
],
|
|
||||||
{ detached: true, stdio: 'ignore' }
|
|
||||||
)
|
|
||||||
child.unref()
|
|
||||||
|
|
||||||
if (child.pid) {
|
|
||||||
writeFileSync(PID_FILE, String(child.pid))
|
|
||||||
console.log(`[cron-check] worker started (PID ${child.pid})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0)
|
|
||||||
+8
-8
@@ -1,16 +1,11 @@
|
|||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import { existsSync, readdirSync, statSync } from 'fs'
|
import { readdirSync, statSync } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { buildYtdlpArgs } from '@/lib/ytdlp'
|
import { buildYtdlpArgs, buildYtdlpCommand } from '@/lib/ytdlp'
|
||||||
import { createToken } from '@/lib/token'
|
import { createToken } from '@/lib/token'
|
||||||
import { config } from '../config/app.config'
|
import { config } from '../config/app.config'
|
||||||
|
|
||||||
function resolveYtdlpBin(): string {
|
|
||||||
const local = path.join(config.BIN_DIR, 'yt-dlp')
|
|
||||||
return existsSync(local) ? local : 'yt-dlp'
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processDownload(downloadId: string): Promise<void> {
|
export async function processDownload(downloadId: string): Promise<void> {
|
||||||
const download = await prisma.download.findUnique({ where: { id: downloadId } })
|
const download = await prisma.download.findUnique({ where: { id: downloadId } })
|
||||||
if (!download) return
|
if (!download) return
|
||||||
@@ -21,13 +16,18 @@ export async function processDownload(downloadId: string): Promise<void> {
|
|||||||
format: download.format,
|
format: download.format,
|
||||||
quality: download.quality,
|
quality: download.quality,
|
||||||
subtitles: download.subtitles,
|
subtitles: download.subtitles,
|
||||||
|
subtitleLangs: download.subtitleLangs ? download.subtitleLangs.split(',') : null,
|
||||||
|
clipStart: download.clipStart,
|
||||||
|
clipEnd: download.clipEnd,
|
||||||
|
audioQuality: download.audioQuality,
|
||||||
extraArgs: download.extraArgs,
|
extraArgs: download.extraArgs,
|
||||||
})
|
})
|
||||||
|
|
||||||
let stderr = ''
|
let stderr = ''
|
||||||
|
const { command, args: spawnArgs } = buildYtdlpCommand(args)
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const proc = spawn(resolveYtdlpBin(), args)
|
const proc = spawn(command, spawnArgs)
|
||||||
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
||||||
|
|
||||||
proc.on('close', async (code) => {
|
proc.on('close', async (code) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user