Compare commits
25
Commits
74fca57f4c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771297cdf1 | ||
|
|
fd0bc092bd | ||
|
|
38e52374c9 | ||
|
|
ecdfc95ff3 | ||
|
|
09600118a5 | ||
|
|
6603ad556e | ||
|
|
e23bd6b235 | ||
|
|
0d46a9a099 | ||
|
|
e0ef1dee77 | ||
|
|
00068e8ed2 | ||
|
|
f9f2934154 | ||
|
|
1f51ec7a2b | ||
|
|
d2e491c762 | ||
|
|
2b5f68752f | ||
|
|
65f92abe7b | ||
|
|
837dc954c2 | ||
|
|
27ba48d181 | ||
|
|
f949566f76 | ||
|
|
1d27d22581 | ||
|
|
fd7515f8c2 | ||
|
|
f1a2639866 | ||
|
|
948b77cef4 | ||
|
|
bd863056a3 | ||
|
|
c8e991a8f7 | ||
|
|
1963521818 |
@@ -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,13 +74,19 @@ 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
|
||||||
|
|
||||||
@@ -85,14 +97,18 @@ DOWNLOAD_LINK_TTL_HOURS: 24 // Durée de validité du token de téléchargem
|
|||||||
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
+175
-3
@@ -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,9 +50,170 @@
|
|||||||
"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": {
|
||||||
|
"heading": "How it works",
|
||||||
|
"subheading": "Three steps, no software to install",
|
||||||
|
"step1Title": "Paste the link",
|
||||||
|
"step1Desc": "Copy the video URL from YouTube, TikTok, Instagram or any supported site",
|
||||||
|
"step2Title": "Choose your format",
|
||||||
|
"step2Desc": "Pick MP4, MP3, WebM or MKV and the quality you want, up to 4K",
|
||||||
|
"step3Title": "Download",
|
||||||
|
"step3Desc": "Your file is ready in seconds, via a secure link valid for 24 hours"
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"heading": "Supported platforms",
|
||||||
|
"subheading": "Works with your favorite sites",
|
||||||
|
"more": "+ 1000 more sites supported. <link>See the full list</link>.",
|
||||||
|
"disclaimer": "Ombrora is not affiliated with the platforms listed above. Please respect copyright and only download content you have the right to use."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"heading": "Frequently asked questions",
|
||||||
|
"q1": "Is Ombrora really free?",
|
||||||
|
"a1": "Yes. Ombrora is 100% free, with no account, no subscription and no hidden fees.",
|
||||||
|
"q2": "Is it legal to download videos?",
|
||||||
|
"a2": "Downloading is legal for content you own the rights to, that is in the public domain, or that you have permission to save for personal, offline use. You are responsible for respecting the copyright and terms of service of the source platform.",
|
||||||
|
"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>.",
|
||||||
|
"q4": "What formats and quality can I get?",
|
||||||
|
"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?",
|
||||||
|
"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?",
|
||||||
|
"a6": "No account, no email, no sign-up. Just paste your link and download.",
|
||||||
|
"q7": "Is there a limit on how many videos I can download?",
|
||||||
|
"a7": "Yes, a light rate limit protects the service from abuse: a few submissions per hour per visitor.",
|
||||||
|
"q8": "Why did my download fail?",
|
||||||
|
"a8": "Some videos are private, age-restricted, geo-blocked or region-locked by the source platform and cannot be downloaded. Live streams in progress and unreleased premieres are not supported either."
|
||||||
|
},
|
||||||
|
"supportedSitesPage": {
|
||||||
|
"title": "Supported sites -- Ombrora",
|
||||||
|
"description": "Full list of sites supported for video and audio downloads on Ombrora.",
|
||||||
|
"back": "← Back to home",
|
||||||
|
"heading": "Supported sites",
|
||||||
|
"subheading": "Browse or search the full list of sites Ombrora can download from.",
|
||||||
|
"searchPlaceholder": "Search a site...",
|
||||||
|
"resultCount": "{count} of {total} sites",
|
||||||
|
"noResults": "No site matches your search.",
|
||||||
|
"note": "This list is indicative and evolves over time: some entries may stop working if a source platform changes its restrictions, and new sites are added regularly. Don't see your site? Try pasting the link anyway, it may still work."
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "© 2026 Ombrora"
|
"copyright": "© 2026 Ombrora"
|
||||||
|
},
|
||||||
|
"downloaderPage": {
|
||||||
|
"metaTitle": "{platform} Downloader — Free Video & Audio Download",
|
||||||
|
"metaDescription": "Download {platform} videos for free in MP4, MP3, WebM or MKV. No sign-up, no watermark, fast and secure.",
|
||||||
|
"heading": "{platform} Downloader",
|
||||||
|
"subheading": "Paste a {platform} link below and get your file in seconds — free, no account required.",
|
||||||
|
"aboutHeading": "About downloading from {platform}",
|
||||||
|
"faqHeading": "{platform} downloader FAQ"
|
||||||
|
},
|
||||||
|
"downloaderPages": {
|
||||||
|
"youtube": {
|
||||||
|
"about": "YouTube is the world's largest video platform, hosting everything from music videos to full documentaries. Ombrora lets you save any public YouTube video as MP4 or extract the audio as MP3, in the resolution available on the source video.",
|
||||||
|
"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.",
|
||||||
|
"faqQ2": "What's the maximum quality I can get?",
|
||||||
|
"faqA2": "Up to 4K, depending on the resolutions available for that specific video."
|
||||||
|
},
|
||||||
|
"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.",
|
||||||
|
"faqQ1": "Does the downloaded video still have the TikTok watermark?",
|
||||||
|
"faqA1": "Ombrora saves the original video file provided by TikTok; whether it includes a watermark depends on how the creator published it.",
|
||||||
|
"faqQ2": "Can I download private TikTok videos?",
|
||||||
|
"faqA2": "No, only public videos with a shareable link can be downloaded."
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
"about": "Whether it's a Reel, an IGTV video or a video shared in a post, Ombrora can fetch the underlying file directly from a public Instagram link.",
|
||||||
|
"faqQ1": "Can I download Instagram Stories?",
|
||||||
|
"faqA1": "Only Stories that are still publicly available at the time of download can be fetched; expired Stories cannot be recovered.",
|
||||||
|
"faqQ2": "Does it work with Instagram Reels?",
|
||||||
|
"faqA2": "Yes, paste the Reel's link the same way you would for any other Instagram video."
|
||||||
|
},
|
||||||
|
"facebook": {
|
||||||
|
"about": "Facebook videos shared publicly, on pages or in groups, can be downloaded directly with Ombrora, without needing to log into Facebook.",
|
||||||
|
"faqQ1": "Can I download videos from private Facebook groups?",
|
||||||
|
"faqA1": "No, only videos that are publicly accessible without logging in can be downloaded.",
|
||||||
|
"faqQ2": "Does it support Facebook Watch videos?",
|
||||||
|
"faqA2": "Yes, paste the link to the Facebook Watch video the same way as any other Facebook video link."
|
||||||
|
},
|
||||||
|
"twitter": {
|
||||||
|
"about": "Videos and GIFs posted on X (formerly Twitter) can be saved directly from a public post link, in the best quality available.",
|
||||||
|
"faqQ1": "Can I download a video from a protected (private) account?",
|
||||||
|
"faqA1": "No, only videos from public posts that anyone can view are supported.",
|
||||||
|
"faqQ2": "Does it work with X Spaces recordings?",
|
||||||
|
"faqA2": "No, only video posts are currently supported, not audio Spaces."
|
||||||
|
},
|
||||||
|
"reddit": {
|
||||||
|
"about": "Reddit hosts videos with the audio track stored separately from the video file. Ombrora automatically merges both into a single downloadable file.",
|
||||||
|
"faqQ1": "Will the downloaded Reddit video include sound?",
|
||||||
|
"faqA1": "Yes, Ombrora automatically merges the video and audio tracks that Reddit stores separately.",
|
||||||
|
"faqQ2": "Can I download an entire gallery post?",
|
||||||
|
"faqA2": "Only the video portion of a post is downloaded; image-only galleries are not supported."
|
||||||
|
},
|
||||||
|
"pinterest": {
|
||||||
|
"about": "Many Pinterest pins are actually short videos. Ombrora extracts the video file behind a public pin so you can save it outside the app.",
|
||||||
|
"faqQ1": "Can I download image pins?",
|
||||||
|
"faqA1": "No, Ombrora only downloads video pins, not static images.",
|
||||||
|
"faqQ2": "Do I need a Pinterest account?",
|
||||||
|
"faqA2": "No, you just need the public link to the pin."
|
||||||
|
},
|
||||||
|
"vimeo": {
|
||||||
|
"about": "Vimeo is popular with filmmakers and creative professionals for its high-quality playback. Ombrora downloads public Vimeo videos in the best format available.",
|
||||||
|
"faqQ1": "Can I download password-protected Vimeo videos?",
|
||||||
|
"faqA1": "No, only videos that are publicly viewable without a password can be downloaded.",
|
||||||
|
"faqQ2": "What quality can I expect?",
|
||||||
|
"faqA2": "It depends on what the uploader made available, up to 4K in most cases."
|
||||||
|
},
|
||||||
|
"soundcloud": {
|
||||||
|
"about": "SoundCloud is built for audio: tracks, remixes, podcasts and DJ sets. Ombrora downloads SoundCloud tracks directly as MP3 files.",
|
||||||
|
"faqQ1": "Can I download private or followers-only tracks?",
|
||||||
|
"faqA1": "No, only tracks that are publicly playable can be downloaded.",
|
||||||
|
"faqQ2": "Will I get the full track or just a preview?",
|
||||||
|
"faqA2": "Ombrora downloads the full track as made available by the uploader on the public page."
|
||||||
|
},
|
||||||
|
"twitch": {
|
||||||
|
"about": "Ombrora can download past Twitch broadcasts and clips, letting you keep a local copy of a stream after it disappears from the platform.",
|
||||||
|
"faqQ1": "Can I download a live stream while it's happening?",
|
||||||
|
"faqA1": "No, only videos on demand (VODs) and clips that have finished processing can be downloaded.",
|
||||||
|
"faqQ2": "Do Twitch VODs expire before I can download them?",
|
||||||
|
"faqA2": "Twitch VODs are only kept online for a limited time by Twitch itself, so download them before they expire."
|
||||||
|
},
|
||||||
|
"dailymotion": {
|
||||||
|
"about": "Dailymotion is a video platform popular in Europe for news clips, entertainment and sports highlights. Ombrora downloads public Dailymotion videos directly.",
|
||||||
|
"faqQ1": "Can I download Dailymotion playlists?",
|
||||||
|
"faqA1": "No, only individual video links are supported.",
|
||||||
|
"faqQ2": "Is there a quality limit?",
|
||||||
|
"faqA2": "You'll get the best quality the uploader made available for that video."
|
||||||
|
},
|
||||||
|
"linkedin": {
|
||||||
|
"about": "Videos shared in LinkedIn posts, such as talks, product demos or interviews, can be downloaded directly from their public post link.",
|
||||||
|
"faqQ1": "Can I download videos from a private LinkedIn group?",
|
||||||
|
"faqA1": "No, only videos from posts visible without logging in can be downloaded.",
|
||||||
|
"faqQ2": "Does it work with LinkedIn Learning videos?",
|
||||||
|
"faqA2": "No, LinkedIn Learning courses are behind a paywall and are not supported."
|
||||||
|
},
|
||||||
|
"tumblr": {
|
||||||
|
"about": "Tumblr posts often embed videos and GIFs from external sources as well as natively uploaded clips. Ombrora downloads the video file behind a public Tumblr post.",
|
||||||
|
"faqQ1": "Does it work with GIFs?",
|
||||||
|
"faqA1": "Ombrora focuses on video files; animated GIFs are not converted or downloaded.",
|
||||||
|
"faqQ2": "Can I download from a private/password-protected blog?",
|
||||||
|
"faqA2": "No, only posts on publicly accessible Tumblr blogs can be downloaded."
|
||||||
|
},
|
||||||
|
"vk": {
|
||||||
|
"about": "VK (VKontakte) is one of the largest social platforms in Eastern Europe, with a large native video library. Ombrora downloads public VK videos directly.",
|
||||||
|
"faqQ1": "Can I download videos that require a VK account to view?",
|
||||||
|
"faqA1": "No, only videos accessible without logging in can be downloaded.",
|
||||||
|
"faqQ2": "What formats are available?",
|
||||||
|
"faqA2": "The same formats as other platforms: MP4, WebM, MKV or MP3 for audio only."
|
||||||
|
},
|
||||||
|
"snapchat": {
|
||||||
|
"about": "Ombrora supports downloading public Snapchat Spotlight videos — the short, TikTok-style clips shared publicly on the platform. Private Snaps and Stories from friends are not accessible.",
|
||||||
|
"faqQ1": "Can I download a friend's private Snap or Story?",
|
||||||
|
"faqA1": "No, only public Spotlight videos with a shareable link can be downloaded.",
|
||||||
|
"faqQ2": "Is Snapchat Memories supported?",
|
||||||
|
"faqA2": "No, only public Spotlight content is supported, not personal Memories."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+175
-3
@@ -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,9 +50,170 @@
|
|||||||
"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": {
|
||||||
|
"heading": "Cómo funciona",
|
||||||
|
"subheading": "Tres pasos, sin instalar nada",
|
||||||
|
"step1Title": "Pega el enlace",
|
||||||
|
"step1Desc": "Copia la URL del vídeo desde YouTube, TikTok, Instagram o cualquier sitio compatible",
|
||||||
|
"step2Title": "Elige tu formato",
|
||||||
|
"step2Desc": "Selecciona MP4, MP3, WebM o MKV y la calidad deseada, hasta 4K",
|
||||||
|
"step3Title": "Descarga",
|
||||||
|
"step3Desc": "Tu archivo está listo en segundos, mediante un enlace seguro válido durante 24 horas"
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"heading": "Plataformas compatibles",
|
||||||
|
"subheading": "Funciona con tus sitios favoritos",
|
||||||
|
"more": "+ 1000 sitios adicionales compatibles. <link>Ver la lista completa</link>.",
|
||||||
|
"disclaimer": "Ombrora no está afiliado a ninguna de las plataformas mencionadas arriba. Por favor, respeta los derechos de autor y descarga únicamente contenido que tengas derecho a usar."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"heading": "Preguntas frecuentes",
|
||||||
|
"q1": "¿Ombrora es realmente gratis?",
|
||||||
|
"a1": "Sí. Ombrora es 100% gratuito, sin cuenta, sin suscripción y sin costes ocultos.",
|
||||||
|
"q2": "¿Es legal descargar vídeos?",
|
||||||
|
"a2": "Descargar es legal para contenido del que posees los derechos, que es de dominio público, o que tienes permiso para guardar para uso personal sin conexión. Eres responsable de respetar los derechos de autor y las condiciones de uso de la plataforma de origen.",
|
||||||
|
"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>.",
|
||||||
|
"q4": "¿Qué formatos y calidad puedo obtener?",
|
||||||
|
"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?",
|
||||||
|
"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?",
|
||||||
|
"a6": "Sin cuenta, sin correo, sin registro. Solo pega tu enlace y descarga.",
|
||||||
|
"q7": "¿Hay un límite en la cantidad de vídeos que puedo descargar?",
|
||||||
|
"a7": "Sí, un límite razonable protege el servicio contra abusos: unas pocas solicitudes por hora y por visitante.",
|
||||||
|
"q8": "¿Por qué falló mi descarga?",
|
||||||
|
"a8": "Algunos vídeos son privados, tienen restricción de edad, están bloqueados geográficamente o limitados por la plataforma de origen y no se pueden descargar. Las transmisiones en directo en curso y los estrenos no publicados tampoco son compatibles."
|
||||||
|
},
|
||||||
|
"supportedSitesPage": {
|
||||||
|
"title": "Sitios compatibles -- Ombrora",
|
||||||
|
"description": "Lista completa de sitios compatibles para descargar vídeos y audio en Ombrora.",
|
||||||
|
"back": "← Volver al inicio",
|
||||||
|
"heading": "Sitios compatibles",
|
||||||
|
"subheading": "Consulta o busca en la lista completa de sitios desde los que Ombrora puede descargar.",
|
||||||
|
"searchPlaceholder": "Buscar un sitio...",
|
||||||
|
"resultCount": "{count} de {total} sitios",
|
||||||
|
"noResults": "Ningún sitio coincide con tu búsqueda.",
|
||||||
|
"note": "Esta lista es orientativa y evoluciona con el tiempo: algunos sitios pueden dejar de funcionar si la plataforma de origen cambia sus restricciones, y se añaden nuevos sitios con frecuencia. ¿No encuentras tu sitio? Prueba a pegar el enlace de todos modos, puede que funcione."
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "© 2026 Ombrora"
|
"copyright": "© 2026 Ombrora"
|
||||||
|
},
|
||||||
|
"downloaderPage": {
|
||||||
|
"metaTitle": "Descargador de {platform} — Vídeo y audio gratis",
|
||||||
|
"metaDescription": "Descarga vídeos de {platform} gratis en MP4, MP3, WebM o MKV. Sin registro, sin marca de agua, rápido y seguro.",
|
||||||
|
"heading": "Descargador de {platform}",
|
||||||
|
"subheading": "Pega un enlace de {platform} abajo y obtén tu archivo en segundos — gratis, sin cuenta.",
|
||||||
|
"aboutHeading": "Sobre la descarga desde {platform}",
|
||||||
|
"faqHeading": "FAQ del descargador de {platform}"
|
||||||
|
},
|
||||||
|
"downloaderPages": {
|
||||||
|
"youtube": {
|
||||||
|
"about": "YouTube es la mayor plataforma de vídeo del mundo, con contenidos que van desde videoclips musicales hasta documentales completos. Ombrora te permite guardar cualquier vídeo público de YouTube en MP4 o extraer el audio en MP3, en la resolución disponible en el vídeo original.",
|
||||||
|
"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.",
|
||||||
|
"faqQ2": "¿Cuál es la calidad máxima que puedo obtener?",
|
||||||
|
"faqA2": "Hasta 4K, según las resoluciones disponibles para ese vídeo en concreto."
|
||||||
|
},
|
||||||
|
"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.",
|
||||||
|
"faqQ1": "¿El vídeo descargado conserva la marca de agua de TikTok?",
|
||||||
|
"faqA1": "Ombrora guarda el archivo de vídeo original proporcionado por TikTok; que incluya marca de agua depende de cómo lo publicó el creador.",
|
||||||
|
"faqQ2": "¿Puedo descargar vídeos privados de TikTok?",
|
||||||
|
"faqA2": "No, solo se pueden descargar vídeos públicos con un enlace para compartir."
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
"about": "Ya sea un Reel, un vídeo de IGTV o un vídeo compartido en una publicación, Ombrora puede obtener el archivo correspondiente directamente desde un enlace público de Instagram.",
|
||||||
|
"faqQ1": "¿Puedo descargar Historias de Instagram?",
|
||||||
|
"faqA1": "Solo se pueden obtener las Historias que sigan disponibles públicamente en el momento de la descarga; las Historias caducadas no se pueden recuperar.",
|
||||||
|
"faqQ2": "¿Funciona con los Reels de Instagram?",
|
||||||
|
"faqA2": "Sí, pega el enlace del Reel igual que harías con cualquier otro vídeo de Instagram."
|
||||||
|
},
|
||||||
|
"facebook": {
|
||||||
|
"about": "Los vídeos de Facebook compartidos públicamente, en páginas o en grupos, se pueden descargar directamente con Ombrora, sin necesidad de iniciar sesión en Facebook.",
|
||||||
|
"faqQ1": "¿Puedo descargar vídeos de grupos privados de Facebook?",
|
||||||
|
"faqA1": "No, solo se pueden descargar vídeos accesibles públicamente sin iniciar sesión.",
|
||||||
|
"faqQ2": "¿Es compatible con los vídeos de Facebook Watch?",
|
||||||
|
"faqA2": "Sí, pega el enlace del vídeo de Facebook Watch igual que con cualquier otro enlace de vídeo de Facebook."
|
||||||
|
},
|
||||||
|
"twitter": {
|
||||||
|
"about": "Los vídeos y GIF publicados en X (antes Twitter) se pueden guardar directamente desde el enlace público de una publicación, en la mejor calidad disponible.",
|
||||||
|
"faqQ1": "¿Puedo descargar un vídeo de una cuenta protegida (privada)?",
|
||||||
|
"faqA1": "No, solo se admiten vídeos de publicaciones públicas que cualquiera puede ver.",
|
||||||
|
"faqQ2": "¿Funciona con las grabaciones de X Spaces?",
|
||||||
|
"faqA2": "No, actualmente solo se admiten publicaciones de vídeo, no los Spaces de audio."
|
||||||
|
},
|
||||||
|
"reddit": {
|
||||||
|
"about": "Reddit almacena la pista de audio por separado del archivo de vídeo. Ombrora combina automáticamente ambas en un único archivo descargable.",
|
||||||
|
"faqQ1": "¿El vídeo de Reddit descargado tendrá sonido?",
|
||||||
|
"faqA1": "Sí, Ombrora combina automáticamente las pistas de vídeo y audio que Reddit almacena por separado.",
|
||||||
|
"faqQ2": "¿Puedo descargar una publicación de galería completa?",
|
||||||
|
"faqA2": "Solo se descarga la parte de vídeo de una publicación; las galerías de solo imágenes no son compatibles."
|
||||||
|
},
|
||||||
|
"pinterest": {
|
||||||
|
"about": "Muchos pines de Pinterest son en realidad vídeos cortos. Ombrora extrae el archivo de vídeo detrás de un pin público para que puedas guardarlo fuera de la app.",
|
||||||
|
"faqQ1": "¿Puedo descargar pines de imagen?",
|
||||||
|
"faqA1": "No, Ombrora solo descarga pines de vídeo, no imágenes estáticas.",
|
||||||
|
"faqQ2": "¿Necesito una cuenta de Pinterest?",
|
||||||
|
"faqA2": "No, solo necesitas el enlace público del pin."
|
||||||
|
},
|
||||||
|
"vimeo": {
|
||||||
|
"about": "Vimeo es popular entre cineastas y profesionales creativos por su reproducción de alta calidad. Ombrora descarga vídeos públicos de Vimeo en el mejor formato disponible.",
|
||||||
|
"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.",
|
||||||
|
"faqQ2": "¿Qué calidad puedo esperar?",
|
||||||
|
"faqA2": "Depende de lo que haya puesto a disposición quien lo subió, hasta 4K en la mayoría de los casos."
|
||||||
|
},
|
||||||
|
"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.",
|
||||||
|
"faqQ1": "¿Puedo descargar temas privados o solo para seguidores?",
|
||||||
|
"faqA1": "No, solo se pueden descargar temas reproducibles públicamente.",
|
||||||
|
"faqQ2": "¿Obtendré el tema completo o solo una vista previa?",
|
||||||
|
"faqA2": "Ombrora descarga el tema completo tal como lo puso a disposición quien lo subió en la página pública."
|
||||||
|
},
|
||||||
|
"twitch": {
|
||||||
|
"about": "Ombrora puede descargar retransmisiones y clips pasados de Twitch, para que conserves una copia local de un stream después de que desaparezca de la plataforma.",
|
||||||
|
"faqQ1": "¿Puedo descargar una transmisión en directo mientras ocurre?",
|
||||||
|
"faqA1": "No, solo se pueden descargar vídeos bajo demanda (VOD) y clips que ya hayan terminado de procesarse.",
|
||||||
|
"faqQ2": "¿Los VOD de Twitch caducan antes de que pueda descargarlos?",
|
||||||
|
"faqA2": "Los VOD de Twitch solo permanecen disponibles en línea durante un tiempo limitado establecido por la propia Twitch, así que descárgalos antes de que caduquen."
|
||||||
|
},
|
||||||
|
"dailymotion": {
|
||||||
|
"about": "Dailymotion es una plataforma de vídeo popular en Europa para clips de noticias, entretenimiento y resúmenes deportivos. Ombrora descarga directamente los vídeos públicos de Dailymotion.",
|
||||||
|
"faqQ1": "¿Puedo descargar listas de reproducción de Dailymotion?",
|
||||||
|
"faqA1": "No, solo se admiten enlaces de vídeos individuales.",
|
||||||
|
"faqQ2": "¿Hay un límite de calidad?",
|
||||||
|
"faqA2": "Obtendrás la mejor calidad que quien lo subió puso a disposición para ese vídeo."
|
||||||
|
},
|
||||||
|
"linkedin": {
|
||||||
|
"about": "Los vídeos compartidos en publicaciones de LinkedIn, como charlas, demostraciones de productos o entrevistas, se pueden descargar directamente desde su enlace público.",
|
||||||
|
"faqQ1": "¿Puedo descargar vídeos de un grupo privado de LinkedIn?",
|
||||||
|
"faqA1": "No, solo se pueden descargar vídeos de publicaciones visibles sin iniciar sesión.",
|
||||||
|
"faqQ2": "¿Funciona con los vídeos de LinkedIn Learning?",
|
||||||
|
"faqA2": "No, los cursos de LinkedIn Learning son de pago y no son compatibles."
|
||||||
|
},
|
||||||
|
"tumblr": {
|
||||||
|
"about": "Las publicaciones de Tumblr suelen incluir vídeos y GIF de fuentes externas, además de clips subidos de forma nativa. Ombrora descarga el archivo de vídeo detrás de una publicación pública de Tumblr.",
|
||||||
|
"faqQ1": "¿Funciona con los GIF?",
|
||||||
|
"faqA1": "Ombrora se centra en archivos de vídeo; los GIF animados no se convierten ni se descargan.",
|
||||||
|
"faqQ2": "¿Puedo descargar desde un blog privado o protegido con contraseña?",
|
||||||
|
"faqA2": "No, solo se pueden descargar publicaciones de blogs de Tumblr de acceso público."
|
||||||
|
},
|
||||||
|
"vk": {
|
||||||
|
"about": "VK (VKontakte) es una de las mayores redes sociales de Europa del Este, con una amplia biblioteca de vídeo nativa. Ombrora descarga directamente los vídeos públicos de VK.",
|
||||||
|
"faqQ1": "¿Puedo descargar vídeos que requieren una cuenta de VK para verse?",
|
||||||
|
"faqA1": "No, solo se pueden descargar vídeos accesibles sin iniciar sesión.",
|
||||||
|
"faqQ2": "¿Qué formatos están disponibles?",
|
||||||
|
"faqA2": "Los mismos formatos que en otras plataformas: MP4, WebM, MKV o MP3 solo para audio."
|
||||||
|
},
|
||||||
|
"snapchat": {
|
||||||
|
"about": "Ombrora permite descargar vídeos públicos de Snapchat Spotlight, esos clips cortos al estilo TikTok que se comparten públicamente en la plataforma. Los Snaps y las Historias privadas de amigos no son accesibles.",
|
||||||
|
"faqQ1": "¿Puedo descargar el Snap o la Historia privada de un amigo?",
|
||||||
|
"faqA1": "No, solo se pueden descargar vídeos públicos de Spotlight con un enlace para compartir.",
|
||||||
|
"faqQ2": "¿Se admiten los Recuerdos (Memories) de Snapchat?",
|
||||||
|
"faqA2": "No, solo se admite el contenido público de Spotlight, no los Recuerdos personales."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+175
-3
@@ -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,9 +50,170 @@
|
|||||||
"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": {
|
||||||
|
"heading": "Comment ça marche",
|
||||||
|
"subheading": "Trois étapes, aucun logiciel à installer",
|
||||||
|
"step1Title": "Collez le lien",
|
||||||
|
"step1Desc": "Copiez l'URL de la vidéo depuis YouTube, TikTok, Instagram ou tout site pris en charge",
|
||||||
|
"step2Title": "Choisissez votre format",
|
||||||
|
"step2Desc": "Sélectionnez MP4, MP3, WebM ou MKV et la qualité souhaitée, jusqu'en 4K",
|
||||||
|
"step3Title": "Téléchargez",
|
||||||
|
"step3Desc": "Votre fichier est prêt en quelques secondes, via un lien sécurisé valable 24 heures"
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"heading": "Plateformes prises en charge",
|
||||||
|
"subheading": "Fonctionne avec vos sites préférés",
|
||||||
|
"more": "+ 1000 autres sites pris en charge. <link>Voir la liste complète</link>.",
|
||||||
|
"disclaimer": "Ombrora n'est affilié à aucune des plateformes listées ci-dessus. Merci de respecter le droit d'auteur et de ne télécharger que du contenu que vous avez le droit d'utiliser."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"heading": "Questions fréquentes",
|
||||||
|
"q1": "Ombrora est-il vraiment gratuit ?",
|
||||||
|
"a1": "Oui. Ombrora est 100% gratuit, sans compte, sans abonnement et sans frais cachés.",
|
||||||
|
"q2": "Est-il légal de télécharger des vidéos ?",
|
||||||
|
"a2": "Le téléchargement est légal pour un contenu dont vous détenez les droits, qui appartient au domaine public, ou que vous êtes autorisé à conserver pour un usage personnel hors ligne. Vous êtes responsable du respect du droit d'auteur et des conditions d'utilisation de la plateforme source.",
|
||||||
|
"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>.",
|
||||||
|
"q4": "Quels formats et quelle qualité puis-je obtenir ?",
|
||||||
|
"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 ?",
|
||||||
|
"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 ?",
|
||||||
|
"a6": "Aucun compte, aucun e-mail, aucune inscription. Collez simplement votre lien et téléchargez.",
|
||||||
|
"q7": "Y a-t-il une limite au nombre de vidéos que je peux télécharger ?",
|
||||||
|
"a7": "Oui, une limite raisonnable protège le service contre les abus : quelques soumissions par heure et par visiteur.",
|
||||||
|
"q8": "Pourquoi mon téléchargement a-t-il échoué ?",
|
||||||
|
"a8": "Certaines vidéos sont privées, soumises à une restriction d'âge, bloquées géographiquement ou limitées par la plateforme source et ne peuvent pas être téléchargées. Les lives en cours et les premières non sorties ne sont pas non plus pris en charge."
|
||||||
|
},
|
||||||
|
"supportedSitesPage": {
|
||||||
|
"title": "Sites pris en charge -- Ombrora",
|
||||||
|
"description": "Liste complète des sites pris en charge pour le téléchargement de vidéos et d'audio sur Ombrora.",
|
||||||
|
"back": "← Retour à l'accueil",
|
||||||
|
"heading": "Sites pris en charge",
|
||||||
|
"subheading": "Parcourez ou recherchez la liste complète des sites depuis lesquels Ombrora peut télécharger.",
|
||||||
|
"searchPlaceholder": "Rechercher un site...",
|
||||||
|
"resultCount": "{count} sur {total} sites",
|
||||||
|
"noResults": "Aucun site ne correspond à votre recherche.",
|
||||||
|
"note": "Cette liste est indicative et évolue régulièrement : certains sites peuvent cesser de fonctionner si la plateforme source change ses restrictions, et de nouveaux sites sont ajoutés fréquemment. Vous ne trouvez pas votre site ? Essayez tout de même de coller le lien, il fonctionnera peut-être."
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "© 2026 Ombrora"
|
"copyright": "© 2026 Ombrora"
|
||||||
|
},
|
||||||
|
"downloaderPage": {
|
||||||
|
"metaTitle": "Téléchargeur {platform} — Vidéo et audio gratuits",
|
||||||
|
"metaDescription": "Téléchargez vos vidéos {platform} gratuitement en MP4, MP3, WebM ou MKV. Sans inscription, sans watermark, rapide et sécurisé.",
|
||||||
|
"heading": "Téléchargeur {platform}",
|
||||||
|
"subheading": "Collez un lien {platform} ci-dessous et récupérez votre fichier en quelques secondes — gratuit, sans compte.",
|
||||||
|
"aboutHeading": "À propos du téléchargement depuis {platform}",
|
||||||
|
"faqHeading": "FAQ téléchargeur {platform}"
|
||||||
|
},
|
||||||
|
"downloaderPages": {
|
||||||
|
"youtube": {
|
||||||
|
"about": "YouTube est la plus grande plateforme vidéo au monde, avec des contenus allant des clips musicaux aux documentaires complets. Ombrora vous permet d'enregistrer n'importe quelle vidéo YouTube publique en MP4 ou d'en extraire l'audio en MP3, dans la résolution disponible sur la vidéo source.",
|
||||||
|
"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.",
|
||||||
|
"faqQ2": "Quelle est la qualité maximale disponible ?",
|
||||||
|
"faqA2": "Jusqu'à 4K, selon les résolutions disponibles pour cette vidéo en particulier."
|
||||||
|
},
|
||||||
|
"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é.",
|
||||||
|
"faqQ1": "La vidéo téléchargée conserve-t-elle le filigrane TikTok ?",
|
||||||
|
"faqA1": "Ombrora enregistre le fichier vidéo d'origine fourni par TikTok ; la présence d'un filigrane dépend de la façon dont le créateur l'a publiée.",
|
||||||
|
"faqQ2": "Puis-je télécharger des vidéos TikTok privées ?",
|
||||||
|
"faqA2": "Non, seules les vidéos publiques disposant d’un lien partageable peuvent être téléchargées."
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
"about": "Qu'il s'agisse d'un Reel, d'une vidéo IGTV ou d'une vidéo partagée dans une publication, Ombrora peut récupérer le fichier correspondant directement depuis un lien Instagram public.",
|
||||||
|
"faqQ1": "Puis-je télécharger des Stories Instagram ?",
|
||||||
|
"faqA1": "Seules les Stories encore publiquement disponibles au moment du téléchargement peuvent être récupérées ; les Stories expirées ne peuvent pas être récupérées.",
|
||||||
|
"faqQ2": "Est-ce que ça fonctionne avec les Reels Instagram ?",
|
||||||
|
"faqA2": "Oui, collez le lien du Reel de la même façon que pour n'importe quelle autre vidéo Instagram."
|
||||||
|
},
|
||||||
|
"facebook": {
|
||||||
|
"about": "Les vidéos Facebook partagées publiquement, sur des pages ou dans des groupes, peuvent être téléchargées directement avec Ombrora, sans avoir besoin de se connecter à Facebook.",
|
||||||
|
"faqQ1": "Puis-je télécharger des vidéos de groupes Facebook privés ?",
|
||||||
|
"faqA1": "Non, seules les vidéos accessibles publiquement sans connexion peuvent être téléchargées.",
|
||||||
|
"faqQ2": "Est-ce que les vidéos Facebook Watch sont prises en charge ?",
|
||||||
|
"faqA2": "Oui, collez le lien de la vidéo Facebook Watch de la même façon que pour tout autre lien vidéo Facebook."
|
||||||
|
},
|
||||||
|
"twitter": {
|
||||||
|
"about": "Les vidéos et GIF publiés sur X (anciennement Twitter) peuvent être enregistrés directement depuis le lien public d'une publication, dans la meilleure qualité disponible.",
|
||||||
|
"faqQ1": "Puis-je télécharger une vidéo depuis un compte protégé (privé) ?",
|
||||||
|
"faqA1": "Non, seules les vidéos de publications publiques visibles par tous sont prises en charge.",
|
||||||
|
"faqQ2": "Est-ce que ça fonctionne avec les enregistrements de X Spaces ?",
|
||||||
|
"faqA2": "Non, seules les publications vidéo sont prises en charge actuellement, pas les Spaces audio."
|
||||||
|
},
|
||||||
|
"reddit": {
|
||||||
|
"about": "Reddit stocke la piste audio séparément du fichier vidéo. Ombrora fusionne automatiquement les deux en un seul fichier téléchargeable.",
|
||||||
|
"faqQ1": "La vidéo Reddit téléchargée aura-t-elle du son ?",
|
||||||
|
"faqA1": "Oui, Ombrora fusionne automatiquement les pistes vidéo et audio que Reddit stocke séparément.",
|
||||||
|
"faqQ2": "Puis-je télécharger une publication de type galerie entière ?",
|
||||||
|
"faqA2": "Seule la partie vidéo d’une publication est téléchargée ; les galeries composées uniquement d’images ne sont pas prises en charge."
|
||||||
|
},
|
||||||
|
"pinterest": {
|
||||||
|
"about": "De nombreuses épingles Pinterest sont en réalité de courtes vidéos. Ombrora extrait le fichier vidéo derrière une épingle publique pour que vous puissiez l'enregistrer en dehors de l'application.",
|
||||||
|
"faqQ1": "Puis-je télécharger des épingles image ?",
|
||||||
|
"faqA1": "Non, Ombrora ne télécharge que les épingles vidéo, pas les images statiques.",
|
||||||
|
"faqQ2": "Ai-je besoin d'un compte Pinterest ?",
|
||||||
|
"faqA2": "Non, il vous suffit d'avoir le lien public de l'épingle."
|
||||||
|
},
|
||||||
|
"vimeo": {
|
||||||
|
"about": "Vimeo est apprécié des cinéastes et des professionnels de la création pour la qualité de sa lecture vidéo. Ombrora télécharge les vidéos Vimeo publiques dans le meilleur format disponible.",
|
||||||
|
"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.",
|
||||||
|
"faqQ2": "Quelle qualité puis-je obtenir ?",
|
||||||
|
"faqA2": "Cela dépend de ce que l'auteur a mis à disposition, jusqu'à 4K dans la plupart des cas."
|
||||||
|
},
|
||||||
|
"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.",
|
||||||
|
"faqQ1": "Puis-je télécharger des morceaux privés ou réservés aux abonnés ?",
|
||||||
|
"faqA1": "Non, seuls les morceaux lisibles publiquement peuvent être téléchargés.",
|
||||||
|
"faqQ2": "Vais-je obtenir le morceau complet ou juste un extrait ?",
|
||||||
|
"faqA2": "Ombrora télécharge le morceau complet tel que mis à disposition par l'auteur sur la page publique."
|
||||||
|
},
|
||||||
|
"twitch": {
|
||||||
|
"about": "Ombrora permet de télécharger les rediffusions et clips Twitch passés, pour garder une copie locale d'un stream après sa disparition de la plateforme.",
|
||||||
|
"faqQ1": "Puis-je télécharger un live pendant qu’il se déroule ?",
|
||||||
|
"faqA1": "Non, seules les vidéos à la demande (VOD) et les clips déjà traités peuvent être téléchargés.",
|
||||||
|
"faqQ2": "Les VOD Twitch expirent-elles avant que je puisse les télécharger ?",
|
||||||
|
"faqA2": "Les VOD Twitch ne sont conservées en ligne que pendant une durée limitée par Twitch lui-même, pensez donc à les télécharger avant leur expiration."
|
||||||
|
},
|
||||||
|
"dailymotion": {
|
||||||
|
"about": "Dailymotion est une plateforme vidéo populaire en Europe pour les clips d'actualité, le divertissement et les temps forts sportifs. Ombrora télécharge directement les vidéos Dailymotion publiques.",
|
||||||
|
"faqQ1": "Puis-je télécharger des playlists Dailymotion ?",
|
||||||
|
"faqA1": "Non, seuls les liens de vidéos individuelles sont pris en charge.",
|
||||||
|
"faqQ2": "Y a-t-il une limite de qualité ?",
|
||||||
|
"faqA2": "Vous obtiendrez la meilleure qualité mise à disposition par l'auteur pour cette vidéo."
|
||||||
|
},
|
||||||
|
"linkedin": {
|
||||||
|
"about": "Les vidéos partagées dans des publications LinkedIn, comme des conférences, des démonstrations de produits ou des interviews, peuvent être téléchargées directement depuis leur lien public.",
|
||||||
|
"faqQ1": "Puis-je télécharger des vidéos d’un groupe LinkedIn privé ?",
|
||||||
|
"faqA1": "Non, seules les vidéos de publications visibles sans connexion peuvent être téléchargées.",
|
||||||
|
"faqQ2": "Est-ce que ça fonctionne avec les vidéos LinkedIn Learning ?",
|
||||||
|
"faqA2": "Non, les formations LinkedIn Learning sont payantes et ne sont pas prises en charge."
|
||||||
|
},
|
||||||
|
"tumblr": {
|
||||||
|
"about": "Les publications Tumblr intègrent souvent des vidéos et GIF provenant de sources externes, ainsi que des clips uploadés nativement. Ombrora télécharge le fichier vidéo derrière une publication Tumblr publique.",
|
||||||
|
"faqQ1": "Est-ce que ça fonctionne avec les GIF ?",
|
||||||
|
"faqA1": "Ombrora se concentre sur les fichiers vidéo ; les GIF animés ne sont ni convertis ni téléchargés.",
|
||||||
|
"faqQ2": "Puis-je télécharger depuis un blog privé/protégé par mot de passe ?",
|
||||||
|
"faqA2": "Non, seules les publications de blogs Tumblr accessibles publiquement peuvent être téléchargées."
|
||||||
|
},
|
||||||
|
"vk": {
|
||||||
|
"about": "VK (VKontakte) est l'une des plus grandes plateformes sociales d'Europe de l'Est, avec une importante bibliothèque vidéo native. Ombrora télécharge directement les vidéos VK publiques.",
|
||||||
|
"faqQ1": "Puis-je télécharger des vidéos nécessitant un compte VK pour être visionnées ?",
|
||||||
|
"faqA1": "Non, seules les vidéos accessibles sans connexion peuvent être téléchargées.",
|
||||||
|
"faqQ2": "Quels formats sont disponibles ?",
|
||||||
|
"faqA2": "Les mêmes formats que pour les autres plateformes : MP4, WebM, MKV ou MP3 pour l'audio seul."
|
||||||
|
},
|
||||||
|
"snapchat": {
|
||||||
|
"about": "Ombrora prend en charge le téléchargement des vidéos Snapchat Spotlight publiques, ces courts clips façon TikTok partagés publiquement sur la plateforme. Les Snaps et Stories privés d'amis ne sont pas accessibles.",
|
||||||
|
"faqQ1": "Puis-je télécharger le Snap ou la Story privée d'un ami ?",
|
||||||
|
"faqA1": "Non, seules les vidéos Spotlight publiques disposant d’un lien partageable peuvent être téléchargées.",
|
||||||
|
"faqQ2": "Les Souvenirs (Memories) Snapchat sont-ils pris en charge ?",
|
||||||
|
"faqA2": "Non, seul le contenu Spotlight public est pris en charge, pas les Souvenirs personnels."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+175
-3
@@ -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,9 +50,170 @@
|
|||||||
"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": {
|
||||||
|
"heading": "Come funziona",
|
||||||
|
"subheading": "Tre passaggi, nessun software da installare",
|
||||||
|
"step1Title": "Incolla il link",
|
||||||
|
"step1Desc": "Copia l'URL del video da YouTube, TikTok, Instagram o qualsiasi sito supportato",
|
||||||
|
"step2Title": "Scegli il formato",
|
||||||
|
"step2Desc": "Seleziona MP4, MP3, WebM o MKV e la qualità desiderata, fino a 4K",
|
||||||
|
"step3Title": "Scarica",
|
||||||
|
"step3Desc": "Il tuo file è pronto in pochi secondi, tramite un link sicuro valido 24 ore"
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"heading": "Piattaforme supportate",
|
||||||
|
"subheading": "Funziona con i tuoi siti preferiti",
|
||||||
|
"more": "+ 1000 altri siti supportati. <link>Vedi l'elenco completo</link>.",
|
||||||
|
"disclaimer": "Ombrora non è affiliato a nessuna delle piattaforme elencate sopra. Rispetta il copyright e scarica solo contenuti che hai il diritto di utilizzare."
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"heading": "Domande frequenti",
|
||||||
|
"q1": "Ombrora è davvero gratuito?",
|
||||||
|
"a1": "Sì. Ombrora è gratuito al 100%, senza account, senza abbonamento e senza costi nascosti.",
|
||||||
|
"q2": "È legale scaricare video?",
|
||||||
|
"a2": "Scaricare è legale per contenuti di cui possiedi i diritti, che sono di dominio pubblico, o che sei autorizzato a conservare per uso personale offline. Sei responsabile del rispetto del copyright e dei termini di servizio della piattaforma di origine.",
|
||||||
|
"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>.",
|
||||||
|
"q4": "Quali formati e qualità posso ottenere?",
|
||||||
|
"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?",
|
||||||
|
"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?",
|
||||||
|
"a6": "Nessun account, nessuna email, nessuna registrazione. Incolla semplicemente il tuo link e scarica.",
|
||||||
|
"q7": "C'è un limite al numero di video che posso scaricare?",
|
||||||
|
"a7": "Sì, un limite ragionevole protegge il servizio dagli abusi: poche richieste all'ora per visitatore.",
|
||||||
|
"q8": "Perché il mio download è fallito?",
|
||||||
|
"a8": "Alcuni video sono privati, soggetti a restrizioni di età, bloccati geograficamente o limitati dalla piattaforma di origine e non possono essere scaricati. Anche le dirette in corso e le anteprime non ancora pubblicate non sono supportate."
|
||||||
|
},
|
||||||
|
"supportedSitesPage": {
|
||||||
|
"title": "Siti supportati -- Ombrora",
|
||||||
|
"description": "Elenco completo dei siti supportati per scaricare video e audio su Ombrora.",
|
||||||
|
"back": "← Torna alla home",
|
||||||
|
"heading": "Siti supportati",
|
||||||
|
"subheading": "Sfoglia o cerca nell'elenco completo dei siti da cui Ombrora può scaricare.",
|
||||||
|
"searchPlaceholder": "Cerca un sito...",
|
||||||
|
"resultCount": "{count} di {total} siti",
|
||||||
|
"noResults": "Nessun sito corrisponde alla tua ricerca.",
|
||||||
|
"note": "Questo elenco è indicativo ed evolve nel tempo: alcuni siti potrebbero smettere di funzionare se la piattaforma di origine modifica le proprie restrizioni, e nuovi siti vengono aggiunti regolarmente. Non trovi il tuo sito? Prova comunque a incollare il link: potrebbe funzionare lo stesso."
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "© 2026 Ombrora"
|
"copyright": "© 2026 Ombrora"
|
||||||
|
},
|
||||||
|
"downloaderPage": {
|
||||||
|
"metaTitle": "Downloader {platform} — Video e audio gratis",
|
||||||
|
"metaDescription": "Scarica video da {platform} gratis in MP4, MP3, WebM o MKV. Senza registrazione, senza watermark, veloce e sicuro.",
|
||||||
|
"heading": "Downloader {platform}",
|
||||||
|
"subheading": "Incolla un link {platform} qui sotto e ottieni il tuo file in pochi secondi — gratis, senza account.",
|
||||||
|
"aboutHeading": "Informazioni sullo scaricamento da {platform}",
|
||||||
|
"faqHeading": "FAQ downloader {platform}"
|
||||||
|
},
|
||||||
|
"downloaderPages": {
|
||||||
|
"youtube": {
|
||||||
|
"about": "YouTube è la più grande piattaforma video al mondo, con contenuti che vanno dai video musicali ai documentari completi. Ombrora ti permette di salvare qualsiasi video pubblico di YouTube in MP4 o di estrarne l’audio in MP3, nella risoluzione disponibile per il video originale.",
|
||||||
|
"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.",
|
||||||
|
"faqQ2": "Qual è la qualità massima disponibile?",
|
||||||
|
"faqA2": "Fino a 4K, in base alle risoluzioni disponibili per quel video specifico."
|
||||||
|
},
|
||||||
|
"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.",
|
||||||
|
"faqQ1": "Il video scaricato mantiene la filigrana di TikTok?",
|
||||||
|
"faqA1": "Ombrora salva il file video originale fornito da TikTok; la presenza della filigrana dipende da come il creatore lo ha pubblicato.",
|
||||||
|
"faqQ2": "Posso scaricare video privati di TikTok?",
|
||||||
|
"faqA2": "No, si possono scaricare solo i video pubblici con un link condivisibile."
|
||||||
|
},
|
||||||
|
"instagram": {
|
||||||
|
"about": "Che si tratti di un Reel, di un video IGTV o di un video condiviso in un post, Ombrora può recuperare il file corrispondente direttamente da un link pubblico di Instagram.",
|
||||||
|
"faqQ1": "Posso scaricare le Storie di Instagram?",
|
||||||
|
"faqA1": "Possono essere recuperate solo le Storie ancora pubblicamente disponibili al momento del download; le Storie scadute non possono essere recuperate.",
|
||||||
|
"faqQ2": "Funziona con i Reels di Instagram?",
|
||||||
|
"faqA2": "Sì, incolla il link del Reel come faresti con qualsiasi altro video di Instagram."
|
||||||
|
},
|
||||||
|
"facebook": {
|
||||||
|
"about": "I video di Facebook condivisi pubblicamente, su pagine o in gruppi, possono essere scaricati direttamente con Ombrora, senza bisogno di accedere a Facebook.",
|
||||||
|
"faqQ1": "Posso scaricare video da gruppi Facebook privati?",
|
||||||
|
"faqA1": "No, si possono scaricare solo video accessibili pubblicamente senza accedere.",
|
||||||
|
"faqQ2": "Sono supportati i video di Facebook Watch?",
|
||||||
|
"faqA2": "Sì, incolla il link del video di Facebook Watch come faresti con qualsiasi altro link video di Facebook."
|
||||||
|
},
|
||||||
|
"twitter": {
|
||||||
|
"about": "I video e le GIF pubblicati su X (ex Twitter) possono essere salvati direttamente dal link pubblico di un post, nella migliore qualità disponibile.",
|
||||||
|
"faqQ1": "Posso scaricare un video da un account protetto (privato)?",
|
||||||
|
"faqA1": "No, sono supportati solo i video di post pubblici visibili a chiunque.",
|
||||||
|
"faqQ2": "Funziona con le registrazioni degli X Spaces?",
|
||||||
|
"faqA2": "No, al momento sono supportati solo i post video, non gli Spaces audio."
|
||||||
|
},
|
||||||
|
"reddit": {
|
||||||
|
"about": "Reddit memorizza la traccia audio separatamente dal file video. Ombrora unisce automaticamente le due tracce in un unico file scaricabile.",
|
||||||
|
"faqQ1": "Il video di Reddit scaricato avrà l'audio?",
|
||||||
|
"faqA1": "Sì, Ombrora unisce automaticamente le tracce video e audio che Reddit memorizza separatamente.",
|
||||||
|
"faqQ2": "Posso scaricare un intero post di tipo galleria?",
|
||||||
|
"faqA2": "Viene scaricata solo la parte video di un post; le gallerie di sole immagini non sono supportate."
|
||||||
|
},
|
||||||
|
"pinterest": {
|
||||||
|
"about": "Molti pin di Pinterest sono in realtà brevi video. Ombrora estrae il file video dietro un pin pubblico, così puoi salvarlo al di fuori dell'app.",
|
||||||
|
"faqQ1": "Posso scaricare i pin immagine?",
|
||||||
|
"faqA1": "No, Ombrora scarica solo i pin video, non le immagini statiche.",
|
||||||
|
"faqQ2": "Mi serve un account Pinterest?",
|
||||||
|
"faqA2": "No, ti serve solo il link pubblico del pin."
|
||||||
|
},
|
||||||
|
"vimeo": {
|
||||||
|
"about": "Vimeo è apprezzato da registi e professionisti creativi per la qualità di riproduzione elevata. Ombrora scarica i video pubblici di Vimeo nel miglior formato disponibile.",
|
||||||
|
"faqQ1": "Posso scaricare video di Vimeo protetti da password?",
|
||||||
|
"faqA1": "No, si possono scaricare solo i video visibili pubblicamente senza password.",
|
||||||
|
"faqQ2": "Che qualità posso aspettarmi?",
|
||||||
|
"faqA2": "Dipende da cosa ha reso disponibile chi ha caricato il video, fino a 4K nella maggior parte dei casi."
|
||||||
|
},
|
||||||
|
"soundcloud": {
|
||||||
|
"about": "SoundCloud è pensato per l’audio: brani, remix, podcast e set DJ. Ombrora scarica i brani di SoundCloud direttamente come file MP3.",
|
||||||
|
"faqQ1": "Posso scaricare brani privati o riservati ai follower?",
|
||||||
|
"faqA1": "No, si possono scaricare solo i brani riproducibili pubblicamente.",
|
||||||
|
"faqQ2": "Otterrò il brano completo o solo un'anteprima?",
|
||||||
|
"faqA2": "Ombrora scarica il brano completo così come reso disponibile da chi lo ha caricato sulla pagina pubblica."
|
||||||
|
},
|
||||||
|
"twitch": {
|
||||||
|
"about": "Ombrora può scaricare le trasmissioni passate e le clip di Twitch, permettendoti di conservare una copia locale di uno stream dopo che scompare dalla piattaforma.",
|
||||||
|
"faqQ1": "Posso scaricare una diretta mentre è in corso?",
|
||||||
|
"faqA1": "No, si possono scaricare solo i video on demand (VOD) e le clip già elaborate.",
|
||||||
|
"faqQ2": "I VOD di Twitch scadono prima che io possa scaricarli?",
|
||||||
|
"faqA2": "I VOD di Twitch restano online solo per un periodo limitato stabilito da Twitch stessa, quindi scaricali prima che scadano."
|
||||||
|
},
|
||||||
|
"dailymotion": {
|
||||||
|
"about": "Dailymotion è una piattaforma video popolare in Europa per clip di notizie, intrattenimento ed highlights sportivi. Ombrora scarica direttamente i video pubblici di Dailymotion.",
|
||||||
|
"faqQ1": "Posso scaricare le playlist di Dailymotion?",
|
||||||
|
"faqA1": "No, sono supportati solo i link ai singoli video.",
|
||||||
|
"faqQ2": "C'è un limite di qualità?",
|
||||||
|
"faqA2": "Otterrai la migliore qualità resa disponibile da chi ha caricato quel video."
|
||||||
|
},
|
||||||
|
"linkedin": {
|
||||||
|
"about": "I video condivisi nei post di LinkedIn, come talk, demo di prodotto o interviste, possono essere scaricati direttamente dal loro link pubblico.",
|
||||||
|
"faqQ1": "Posso scaricare video da un gruppo LinkedIn privato?",
|
||||||
|
"faqA1": "No, si possono scaricare solo video da post visibili senza accedere.",
|
||||||
|
"faqQ2": "Funziona con i video di LinkedIn Learning?",
|
||||||
|
"faqA2": "No, i corsi LinkedIn Learning sono a pagamento e non sono supportati."
|
||||||
|
},
|
||||||
|
"tumblr": {
|
||||||
|
"about": "I post di Tumblr spesso incorporano video e GIF da fonti esterne, oltre a clip caricate nativamente. Ombrora scarica il file video dietro un post pubblico di Tumblr.",
|
||||||
|
"faqQ1": "Funziona con le GIF?",
|
||||||
|
"faqA1": "Ombrora si concentra sui file video; le GIF animate non vengono convertite né scaricate.",
|
||||||
|
"faqQ2": "Posso scaricare da un blog privato o protetto da password?",
|
||||||
|
"faqA2": "No, si possono scaricare solo i post di blog Tumblr accessibili pubblicamente."
|
||||||
|
},
|
||||||
|
"vk": {
|
||||||
|
"about": "VK (VKontakte) è uno dei più grandi social network dell'Europa dell'Est, con un'ampia libreria video nativa. Ombrora scarica direttamente i video pubblici di VK.",
|
||||||
|
"faqQ1": "Posso scaricare video che richiedono un account VK per essere visualizzati?",
|
||||||
|
"faqA1": "No, si possono scaricare solo i video accessibili senza accedere.",
|
||||||
|
"faqQ2": "Quali formati sono disponibili?",
|
||||||
|
"faqA2": "Gli stessi formati delle altre piattaforme: MP4, WebM, MKV o MP3 per il solo audio."
|
||||||
|
},
|
||||||
|
"snapchat": {
|
||||||
|
"about": "Ombrora supporta lo scaricamento dei video pubblici di Snapchat Spotlight, le clip brevi in stile TikTok condivise pubblicamente sulla piattaforma. Snap e Storie private degli amici non sono accessibili.",
|
||||||
|
"faqQ1": "Posso scaricare lo Snap o la Storia privata di un amico?",
|
||||||
|
"faqA1": "No, si possono scaricare solo i video pubblici di Spotlight con un link condivisibile.",
|
||||||
|
"faqQ2": "I Ricordi (Memories) di Snapchat sono supportati?",
|
||||||
|
"faqA2": "No, è supportato solo il contenuto pubblico di Spotlight, non i Ricordi personali."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { routing } from '@/i18n/routing'
|
||||||
|
import { getDownloaderPlatformBySlug } from '@/lib/downloader-platforms'
|
||||||
|
import { SubmitForm } from '@/components/SubmitForm'
|
||||||
|
import { ReassuranceSection } from '@/components/ReassuranceSection'
|
||||||
|
import { HowItWorksSection } from '@/components/HowItWorksSection'
|
||||||
|
import { SupportedPlatformsSection } from '@/components/SupportedPlatformsSection'
|
||||||
|
import { FaqAccordion } from '@/components/FaqAccordion'
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale, slug } = await params
|
||||||
|
const platform = getDownloaderPlatformBySlug(slug)
|
||||||
|
if (!platform) return {}
|
||||||
|
|
||||||
|
const t = await getTranslations({ locale, namespace: 'downloaderPage' })
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('metaTitle', { platform: platform.name }),
|
||||||
|
description: t('metaDescription', { platform: platform.name }),
|
||||||
|
alternates: {
|
||||||
|
canonical: `${baseUrl}/${locale}/${slug}`,
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
routing.locales.map((l) => [l, `/${l}/${slug}`])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DownloaderPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>
|
||||||
|
}) {
|
||||||
|
const { locale, slug } = await params
|
||||||
|
const platform = getDownloaderPlatformBySlug(slug)
|
||||||
|
if (!platform) notFound()
|
||||||
|
|
||||||
|
const t = await getTranslations({ locale, namespace: 'downloaderPage' })
|
||||||
|
const tPlatform = await getTranslations({ locale, namespace: `downloaderPages.${platform.id}` })
|
||||||
|
const tFaq = await getTranslations({ locale, namespace: 'faq' })
|
||||||
|
|
||||||
|
const faqItems = [
|
||||||
|
{ q: tPlatform('faqQ1'), a: tPlatform('faqA1') },
|
||||||
|
{ q: tPlatform('faqQ2'), a: tPlatform('faqA2') },
|
||||||
|
{ q: tFaq('q1'), a: tFaq('a1') },
|
||||||
|
{ q: tFaq('q2'), a: tFaq('a2') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: faqItems.map(({ q, a }) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: q,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: a },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<section
|
||||||
|
className="flex flex-col items-center justify-center px-6 pt-20 pb-16 text-center"
|
||||||
|
aria-labelledby="hero-heading"
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
id="hero-heading"
|
||||||
|
className="text-4xl md:text-5xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-4 max-w-2xl"
|
||||||
|
>
|
||||||
|
{t('heading', { platform: platform.name })}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-500 dark:text-gray-400 mb-10">
|
||||||
|
{t('subheading', { platform: platform.name })}
|
||||||
|
</p>
|
||||||
|
<SubmitForm examplePlaceholder={platform.exampleUrl} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ReassuranceSection locale={locale} />
|
||||||
|
<HowItWorksSection locale={locale} />
|
||||||
|
|
||||||
|
<section className="py-16 px-6" aria-labelledby="about-heading">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 id="about-heading" className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50 text-center mb-6">
|
||||||
|
{t('aboutHeading', { platform: platform.name })}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-center leading-relaxed">
|
||||||
|
{tPlatform('about')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-16 px-6 bg-gray-50 dark:bg-slate-900/50" aria-labelledby="faq-heading">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 id="faq-heading" className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50 text-center mb-10">
|
||||||
|
{t('faqHeading', { platform: platform.name })}
|
||||||
|
</h2>
|
||||||
|
<FaqAccordion items={faqItems} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SupportedPlatformsSection locale={locale} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,6 +1,9 @@
|
|||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
import { SubmitForm } from '@/components/SubmitForm'
|
import { SubmitForm } from '@/components/SubmitForm'
|
||||||
import { ReassuranceSection } from '@/components/ReassuranceSection'
|
import { ReassuranceSection } from '@/components/ReassuranceSection'
|
||||||
|
import { HowItWorksSection } from '@/components/HowItWorksSection'
|
||||||
|
import { SupportedPlatformsSection } from '@/components/SupportedPlatformsSection'
|
||||||
|
import { FaqSection } from '@/components/FaqSection'
|
||||||
|
|
||||||
export default async function Home({
|
export default async function Home({
|
||||||
params,
|
params,
|
||||||
@@ -32,6 +35,9 @@ export default async function Home({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ReassuranceSection locale={locale} />
|
<ReassuranceSection locale={locale} />
|
||||||
|
<HowItWorksSection locale={locale} />
|
||||||
|
<SupportedPlatformsSection locale={locale} />
|
||||||
|
<FaqSection locale={locale} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { Link } from '@/navigation'
|
||||||
|
import { SUPPORTED_SITES } from '@/lib/supported-sites'
|
||||||
|
import { SupportedSitesBrowser } from '@/components/SupportedSitesBrowser'
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params
|
||||||
|
const t = await getTranslations({ locale, namespace: 'supportedSitesPage' })
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('title'),
|
||||||
|
description: t('description'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SupportedSitesPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>
|
||||||
|
}) {
|
||||||
|
const { locale } = await params
|
||||||
|
const t = await getTranslations({ locale, namespace: 'supportedSitesPage' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="px-6 py-16">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-block text-sm text-violet-600 dark:text-violet-400 hover:underline"
|
||||||
|
>
|
||||||
|
{t('back')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 text-3xl md:text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-50 text-center">
|
||||||
|
{t('heading')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-gray-500 dark:text-gray-400 text-center max-w-2xl mx-auto">
|
||||||
|
{t('subheading')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<SupportedSitesBrowser
|
||||||
|
sites={SUPPORTED_SITES}
|
||||||
|
searchPlaceholder={t('searchPlaceholder')}
|
||||||
|
resultCountTemplate={t.raw('resultCount')}
|
||||||
|
noResultsLabel={t('noResults')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-12 text-xs text-gray-400 dark:text-slate-500 text-center max-w-2xl mx-auto">
|
||||||
|
{t('note')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
function ChevronIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 transition-transform duration-200 group-open:rotate-180"
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FaqAccordion({ items }: { items: { q: string; a: ReactNode }[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map(({ q, a }) => (
|
||||||
|
<details
|
||||||
|
key={q}
|
||||||
|
className="group rounded-xl border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-5 py-4"
|
||||||
|
>
|
||||||
|
<summary className="flex items-center justify-between gap-4 cursor-pointer select-none font-medium text-gray-900 dark:text-gray-50 list-none [&::-webkit-details-marker]:hidden">
|
||||||
|
{q}
|
||||||
|
<ChevronIcon />
|
||||||
|
</summary>
|
||||||
|
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{a}</p>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { Link } from '@/navigation'
|
||||||
|
import { FaqAccordion } from '@/components/FaqAccordion'
|
||||||
|
|
||||||
|
const QUESTION_COUNT = 8
|
||||||
|
|
||||||
|
function stripRichTags(text: string) {
|
||||||
|
return text.replace(/<link>(.*?)<\/link>/g, '$1')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function FaqSection({ locale }: { locale: string }) {
|
||||||
|
const t = await getTranslations({ locale, namespace: 'faq' })
|
||||||
|
|
||||||
|
const items = Array.from({ length: QUESTION_COUNT }, (_, i) => {
|
||||||
|
const key = `a${i + 1}` as const
|
||||||
|
return {
|
||||||
|
q: t(`q${i + 1}`),
|
||||||
|
aText: stripRichTags(t.raw(key) as string),
|
||||||
|
a: t.rich(key, {
|
||||||
|
link: (chunks) => (
|
||||||
|
<Link href="/supported-sites" className="text-violet-600 dark:text-violet-400 hover:underline">
|
||||||
|
{chunks}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: items.map(({ q, aText }) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: q,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: aText },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-6 bg-gray-50 dark:bg-slate-900/50" aria-labelledby="faq-heading">
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 id="faq-heading" className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50 text-center mb-10">
|
||||||
|
{t('heading')}
|
||||||
|
</h2>
|
||||||
|
<FaqAccordion items={items} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Link } from '@/navigation'
|
||||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher'
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher'
|
||||||
|
|
||||||
@@ -5,9 +6,9 @@ export function Header() {
|
|||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b border-gray-100 dark:border-slate-800 bg-white/80 dark:bg-slate-950/80 backdrop-blur-sm">
|
<header className="sticky top-0 z-50 border-b border-gray-100 dark:border-slate-800 bg-white/80 dark:bg-slate-950/80 backdrop-blur-sm">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-3 flex items-center justify-between">
|
<div className="max-w-5xl mx-auto px-6 py-3 flex items-center justify-between">
|
||||||
<span className="text-xl font-bold tracking-tight select-none">
|
<Link href="/" className="text-xl font-bold tracking-tight select-none">
|
||||||
<span className="text-violet-600 dark:text-violet-400">O</span>mbrora
|
<span className="text-violet-600 dark:text-violet-400">O</span>mbrora
|
||||||
</span>
|
</Link>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
|
||||||
|
function LinkIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M10 13a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07l-1.5 1.5"/>
|
||||||
|
<path d="M14 11a5 5 0 00-7.07 0l-2.83 2.83a5 5 0 007.07 7.07l1.5-1.5"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlidersIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<line x1="4" y1="6" x2="20" y2="6"/>
|
||||||
|
<line x1="4" y1="12" x2="20" y2="12"/>
|
||||||
|
<line x1="4" y1="18" x2="20" y2="18"/>
|
||||||
|
<circle cx="9" cy="6" r="2" fill="currentColor" stroke="none"/>
|
||||||
|
<circle cx="16" cy="12" r="2" fill="currentColor" stroke="none"/>
|
||||||
|
<circle cx="11" cy="18" r="2" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M12 3v12"/>
|
||||||
|
<path d="M7 10l5 5 5-5"/>
|
||||||
|
<path d="M4 21h16"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function HowItWorksSection({ locale }: { locale: string }) {
|
||||||
|
const t = await getTranslations({ locale, namespace: 'howItWorks' })
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ title: t('step1Title'), desc: t('step1Desc'), Icon: LinkIcon },
|
||||||
|
{ title: t('step2Title'), desc: t('step2Desc'), Icon: SlidersIcon },
|
||||||
|
{ title: t('step3Title'), desc: t('step3Desc'), Icon: DownloadIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-6 bg-gray-50 dark:bg-slate-900/50" aria-labelledby="how-it-works-heading">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 id="how-it-works-heading" className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50">
|
||||||
|
{t('heading')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-gray-500 dark:text-gray-400">{t('subheading')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||||
|
{steps.map(({ title, desc, Icon }, i) => (
|
||||||
|
<div key={title} className="text-center">
|
||||||
|
<div className="mx-auto inline-flex items-center justify-center w-14 h-14 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 mb-4">
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-semibold text-violet-600 dark:text-violet-400 mb-1">0{i + 1}</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-gray-50">{title}</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-xs mx-auto">{desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,44 +1,105 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useLocale } from 'next-intl'
|
import { useLocale } from 'next-intl'
|
||||||
import { useRouter, usePathname } from '@/navigation'
|
import { useRouter, usePathname } from '@/navigation'
|
||||||
import { routing } from '@/i18n/routing'
|
import { routing } from '@/i18n/routing'
|
||||||
|
|
||||||
const FLAG_LABELS: Record<string, string> = {
|
const LANGUAGE_LABELS: Record<string, string> = {
|
||||||
en: 'English',
|
en: 'English',
|
||||||
fr: 'Français',
|
fr: 'Français',
|
||||||
es: 'Español',
|
es: 'Español',
|
||||||
it: 'Italiano',
|
it: 'Italiano',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Flag({ locale, className }: { locale: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={`/flags/${locale}.svg`}
|
||||||
|
alt=""
|
||||||
|
className={['w-6 h-4 rounded-sm object-cover shrink-0', className].filter(Boolean).join(' ')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function LanguageSwitcher() {
|
export function LanguageSwitcher() {
|
||||||
const locale = useLocale()
|
const locale = useLocale()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerDown)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function selectLocale(loc: string) {
|
||||||
|
setOpen(false)
|
||||||
|
router.replace(pathname, { locale: loc })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div ref={containerRef} className="relative">
|
||||||
{routing.locales.map((loc) => (
|
|
||||||
<button
|
<button
|
||||||
key={loc}
|
type="button"
|
||||||
onClick={() => router.replace(pathname, { locale: loc })}
|
onClick={() => setOpen((value) => !value)}
|
||||||
aria-label={FLAG_LABELS[loc]}
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center gap-2 rounded-lg py-1.5 pl-2 pr-2.5 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Flag locale={locale} />
|
||||||
|
<ChevronIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<ul
|
||||||
|
role="listbox"
|
||||||
|
className="absolute right-0 top-full mt-2 min-w-[9rem] rounded-lg border border-gray-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-lg py-1 z-50"
|
||||||
|
>
|
||||||
|
{routing.locales.map((loc) => (
|
||||||
|
<li key={loc} role="option" aria-selected={loc === locale}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectLocale(loc)}
|
||||||
className={[
|
className={[
|
||||||
'rounded transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500',
|
'flex w-full items-center gap-2.5 px-3 py-2 text-sm text-left transition-colors cursor-pointer',
|
||||||
loc === locale
|
loc === locale
|
||||||
? 'opacity-100 ring-2 ring-violet-500 ring-offset-1 ring-offset-white dark:ring-offset-slate-950'
|
? 'bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-400 font-medium'
|
||||||
: 'opacity-40 hover:opacity-70',
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-800',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<img
|
<Flag locale={loc} />
|
||||||
src={`/flags/${loc}.svg`}
|
{LANGUAGE_LABELS[loc]}
|
||||||
alt={FLAG_LABELS[loc]}
|
|
||||||
width={28}
|
|
||||||
height={20}
|
|
||||||
className="rounded-sm block"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+210
-22
@@ -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'
|
||||||
|
|
||||||
export function SubmitForm() {
|
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 } = {}) {
|
||||||
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() {
|
|||||||
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() {
|
|||||||
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 */}
|
||||||
@@ -75,13 +177,13 @@ export function SubmitForm() {
|
|||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="https://www.youtube.com/watch?v=..."
|
placeholder={examplePlaceholder ?? 'https://www.youtube.com/watch?v=...'}
|
||||||
className="flex-1 px-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-400 dark:placeholder:text-slate-500"
|
className="flex-1 px-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-400 dark:placeholder:text-slate-500"
|
||||||
/>
|
/>
|
||||||
<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"
|
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 />}
|
||||||
{loading ? t('submitting') : t('submit')}
|
{loading ? t('submitting') : t('submit')}
|
||||||
@@ -89,30 +191,62 @@ export function SubmitForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Options row */}
|
{(probe.status === 'debouncing' || probe.status === 'probing') && (
|
||||||
|
<p className="mt-3 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<Spinner className="w-3.5 h-3.5" />
|
||||||
|
{t('analyzing')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
{t('retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capabilities && (
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
|
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{capabilities.isAudioOnly ? (
|
||||||
|
<span className="rounded-lg bg-gray-100 dark:bg-slate-800 px-3 py-1 text-xs">
|
||||||
|
{t('audioOnlySource')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<span>{t('format')}</span>
|
<span>{t('format')}</span>
|
||||||
<select
|
<select value={format} onChange={(e) => setFormat(e.target.value)} className={selectClass}>
|
||||||
value={format}
|
|
||||||
onChange={(e) => setFormat(e.target.value)}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{format !== 'mp3' && (
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<span>{t('quality')}</span>
|
<span>{t('quality')}</span>
|
||||||
<select
|
<select value={quality} onChange={(e) => setQuality(e.target.value)} className={selectClass}>
|
||||||
value={quality}
|
{capabilities.availableQualities.map((q) => <option key={q}>{q}</option>)}
|
||||||
onChange={(e) => setQuality(e.target.value)}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{format === 'mp3' && (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span>{t('audioQuality')}</span>
|
||||||
|
<select value={audioQuality} onChange={(e) => setAudioQuality(e.target.value)} className={selectClass}>
|
||||||
|
{AUDIO_QUALITIES.map((q) => <option key={q}>{q}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{capabilities.subtitleLangs.length > 0 && (
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -122,6 +256,59 @@ export function SubmitForm() {
|
|||||||
/>
|
/>
|
||||||
<span>{t('subtitles')}</span>
|
<span>{t('subtitles')}</span>
|
||||||
</label>
|
</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">
|
<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">
|
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
|
||||||
@@ -136,6 +323,7 @@ export function SubmitForm() {
|
|||||||
/>
|
/>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</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,40 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server'
|
||||||
|
import { Link } from '@/navigation'
|
||||||
|
import { DOWNLOADER_PLATFORMS } from '@/lib/downloader-platforms'
|
||||||
|
|
||||||
|
export async function SupportedPlatformsSection({ locale }: { locale: string }) {
|
||||||
|
const t = await getTranslations({ locale, namespace: 'platforms' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-6" aria-labelledby="platforms-heading">
|
||||||
|
<div className="max-w-5xl mx-auto text-center">
|
||||||
|
<h2 id="platforms-heading" className="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-50">
|
||||||
|
{t('heading')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-gray-500 dark:text-gray-400">{t('subheading')}</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||||
|
{DOWNLOADER_PLATFORMS.map(({ id, slug, name }) => (
|
||||||
|
<Link
|
||||||
|
key={id}
|
||||||
|
href={`/${slug}`}
|
||||||
|
className="px-4 py-2 rounded-full border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-medium text-gray-700 dark:text-gray-300 hover:border-violet-300 dark:hover:border-violet-700 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-gray-400 dark:text-slate-500">
|
||||||
|
{t.rich('more', {
|
||||||
|
link: (chunks) => (
|
||||||
|
<Link href="/supported-sites" className="text-violet-600 dark:text-violet-400 hover:underline">
|
||||||
|
{chunks}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="mt-6 text-xs text-gray-400 dark:text-slate-500 max-w-2xl mx-auto">{t('disclaimer')}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
function SearchIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportedSitesBrowser({
|
||||||
|
sites,
|
||||||
|
searchPlaceholder,
|
||||||
|
resultCountTemplate,
|
||||||
|
noResultsLabel,
|
||||||
|
}: {
|
||||||
|
sites: string[]
|
||||||
|
searchPlaceholder: string
|
||||||
|
resultCountTemplate: string
|
||||||
|
noResultsLabel: string
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return sites
|
||||||
|
return sites.filter((site) => site.toLowerCase().includes(q))
|
||||||
|
}, [sites, query])
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, string[]>()
|
||||||
|
for (const site of filtered) {
|
||||||
|
const first = site[0]?.toUpperCase() ?? '#'
|
||||||
|
const letter = /[A-Z]/.test(first) ? first : '#'
|
||||||
|
if (!map.has(letter)) map.set(letter, [])
|
||||||
|
map.get(letter)!.push(site)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="relative max-w-md mx-auto">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-slate-500">
|
||||||
|
<SearchIcon />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 pl-9 pr-4 py-2.5 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-400 dark:text-slate-500">
|
||||||
|
{resultCountTemplate
|
||||||
|
.replace('{count}', String(filtered.length))
|
||||||
|
.replace('{total}', String(sites.length))}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="mt-10 text-center text-gray-500 dark:text-gray-400">{noResultsLabel}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-8 space-y-8">
|
||||||
|
{grouped.map(([letter, names]) => (
|
||||||
|
<div key={letter}>
|
||||||
|
<h2 className="text-sm font-bold text-violet-600 dark:text-violet-400 mb-3">{letter}</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{names.map((name) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
className="px-3 py-1.5 rounded-full border border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,28 @@
|
|||||||
|
export type DownloaderPlatform = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
exampleUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOWNLOADER_PLATFORMS: DownloaderPlatform[] = [
|
||||||
|
{ id: 'youtube', slug: 'youtube-downloader', name: 'YouTube', exampleUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' },
|
||||||
|
{ id: 'tiktok', slug: 'tiktok-downloader', name: 'TikTok', exampleUrl: 'https://www.tiktok.com/@username/video/1234567890123456789' },
|
||||||
|
{ id: 'instagram', slug: 'instagram-downloader', name: 'Instagram', exampleUrl: 'https://www.instagram.com/reel/Cxxxxxxxxxx/' },
|
||||||
|
{ id: 'facebook', slug: 'facebook-downloader', name: 'Facebook', exampleUrl: 'https://www.facebook.com/watch/?v=1234567890123456' },
|
||||||
|
{ id: 'twitter', slug: 'twitter-downloader', name: 'X (Twitter)', exampleUrl: 'https://x.com/username/status/1234567890123456789' },
|
||||||
|
{ id: 'reddit', slug: 'reddit-downloader', name: 'Reddit', exampleUrl: 'https://www.reddit.com/r/videos/comments/abc123/example_post/' },
|
||||||
|
{ id: 'pinterest', slug: 'pinterest-downloader', name: 'Pinterest', exampleUrl: 'https://www.pinterest.com/pin/1234567890123456789/' },
|
||||||
|
{ id: 'vimeo', slug: 'vimeo-downloader', name: 'Vimeo', exampleUrl: 'https://vimeo.com/123456789' },
|
||||||
|
{ id: 'soundcloud', slug: 'soundcloud-downloader', name: 'SoundCloud', exampleUrl: 'https://soundcloud.com/artist/track-name' },
|
||||||
|
{ id: 'twitch', slug: 'twitch-downloader', name: 'Twitch', exampleUrl: 'https://www.twitch.tv/videos/1234567890' },
|
||||||
|
{ id: 'dailymotion', slug: 'dailymotion-downloader', name: 'Dailymotion', exampleUrl: 'https://www.dailymotion.com/video/x8abcde' },
|
||||||
|
{ id: 'linkedin', slug: 'linkedin-downloader', name: 'LinkedIn', exampleUrl: 'https://www.linkedin.com/posts/username_activity-1234567890123456789' },
|
||||||
|
{ id: 'tumblr', slug: 'tumblr-downloader', name: 'Tumblr', exampleUrl: 'https://username.tumblr.com/post/1234567890123' },
|
||||||
|
{ id: 'vk', slug: 'vk-downloader', name: 'VK', exampleUrl: 'https://vk.com/video-12345678_123456789' },
|
||||||
|
{ id: 'snapchat', slug: 'snapchat-downloader', name: 'Snapchat', exampleUrl: 'https://www.snapchat.com/spotlight/Xxxxxxxxxxxxxxxxxxxxxx' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getDownloaderPlatformBySlug(slug: string): DownloaderPlatform | undefined {
|
||||||
|
return DOWNLOADER_PLATFORMS.find((platform) => platform.slug === slug)
|
||||||
|
}
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
+18
-3
@@ -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 }
|
||||||
|
|
||||||
|
export function createRateLimiter(max: number, windowMs: number) {
|
||||||
const store = new Map<string, Entry>()
|
const store = new Map<string, Entry>()
|
||||||
|
|
||||||
export function isRateLimited(ip: string): boolean {
|
return {
|
||||||
|
isLimited(ip: string): boolean {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const entry = store.get(ip)
|
const entry = store.get(ip)
|
||||||
|
|
||||||
if (!entry || now > entry.resetAt) {
|
if (!entry || now > entry.resetAt) {
|
||||||
store.set(ip, { count: 1, resetAt: now + config.RATE_LIMIT_WINDOW_MS })
|
store.set(ip, { count: 1, resetAt: now + windowMs })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.count >= config.RATE_LIMIT_MAX) return true
|
if (entry.count >= max) return true
|
||||||
|
|
||||||
entry.count++
|
entry.count++
|
||||||
return false
|
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'
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+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