Compare commits

..
25 Commits
Author SHA1 Message Date
anthonyandClaude Sonnet 5 771297cdf1 fix: rename pm2 worker and invoke tsx directly instead of npm
pm2 fork mode require()s the resolved script rather than spawning a
shell, and on o2switch `npm` resolves to CloudLinux's bash npm_wrapper,
so pm2 crashed instantly (SyntaxError) and restart-looped forever.
Point pm2 at tsx's real ESM entry (node_modules/tsx/dist/cli.mjs)
instead. Also renames the pm2 process to video-downloader-worker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 16:21:25 +02:00
anthonyandClaude Sonnet 5 fd0bc092bd fix: fix o2switch deployment build and yt-dlp Python compatibility
Turbopack requires native SWC bindings unavailable on o2switch's old
glibc, so force webpack for production builds. NODE_ENV=production on
o2switch's shell also caused npm to skip devDependencies needed at
build time (Tailwind, PostCSS), so deploy now installs with
--include=dev.

yt-dlp's standalone PyInstaller binary self-extracts to noexec /tmp
and fails to mmap its bundled shared libs there, and o2switch's system
python3 (3.6) is too old for the plain zipapp. Revert to the zipapp
and invoke it explicitly through a configurable PYTHON_BIN, set to
o2switch's newer Python 3.11 in .env.prod.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 16:13:12 +02:00
anthonyandClaude Sonnet 5 38e52374c9 feat: name downloaded file after the video title and add a back-to-home link
The download endpoint served files under their internal uuid-based
storage name; it now builds the Content-Disposition filename from the
probed video title (persisted on submit) with the original extension,
falling back to the old behavior when no title is available. The
status page also gets an always-visible link back to the homepage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 15:53:14 +02:00
anthony ecdfc95ff3 feat: add Passenger-compatible server.js startup file for o2switch
Passenger runs a literal JS entry file, not the next start CLI or an
npm script, so a custom server was needed for cPanel's Node.js App
startup file field.
2026-08-11 15:24:30 +02:00
anthony 09600118a5 docs: add implementation plan for 4K quality support 2026-08-11 15:20:25 +02:00
anthony 6603ad556e docs: advertise 4K support in marketing copy across all locales 2026-08-11 15:19:59 +02:00
anthony e23bd6b235 test: cover 4K format selector in buildYtdlpArgs 2026-08-11 15:19:02 +02:00
anthony 0d46a9a099 feat: add 4K and 2K quality tiers 2026-08-11 15:18:35 +02:00
anthony e0ef1dee77 docs: add design spec for 4K quality support 2026-08-11 15:12:51 +02:00
anthony 00068e8ed2 Claude > Add Plugins 2026-08-11 15:08:11 +02:00
anthonyandClaude Sonnet 5 f9f2934154 fix: hide quality selector when format is mp3
Quality/resolution options don't apply to audio-only output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 15:05:58 +02:00
anthonyandClaude Sonnet 5 1f51ec7a2b docs: refresh CLAUDE.md and README with current project state, drop dead legacy pages
Both docs still described a frontend/-subfolder Vite/react-router-dom
setup that never existed in this repo's history, and were missing the
yt-dlp probe gating, SEO landing pages, sitemap, and 4-locale i18n
shipped in recent commits. Also removes src/app/page.tsx and
src/app/status/[uuid]/page.tsx, superseded by the [locale]-based
routes and unreachable since the next-intl middleware redirects all
non-API traffic into [locale]/...

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 15:02:37 +02:00
anthonyandClaude Sonnet 5 d2e491c762 feat: add npm run deploy script for o2switch deployment
Consolidates the manual deploy steps (env copy, install, prisma
generate/migrate deploy, build, pm2 startOrRestart+save) documented
in the README into a single scripts/deploy.sh run over SSH.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 14:55:05 +02:00
anthonyandClaude Sonnet 5 2b5f68752f feat: supervise worker with pm2 instead of cron PID-check
Passenger only manages the Next.js app process; the worker now gets
the same crash-recovery/auto-restart via pm2, replacing the cron-driven
PID-file restart hack. Updates README deployment steps accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 14:49:49 +02:00
anthony 65f92abe7b feat: add Linux ffprobe and Windows ffmpeg/ffprobe binaries to bin/
yt-dlp needs ffprobe alongside ffmpeg for postprocessing; only ffmpeg was
bundled for the Linux/o2switch prod deploy. Also add Windows builds so
local dev on Windows has a working ffmpeg/ffprobe pair, and gitignore
.env.prod/.env.local so real deployment credentials never get committed.
2026-08-11 14:39:27 +02:00
anthonyandClaude Sonnet 5 837dc954c2 fix: replace ts-node with tsx for worker scripts
TypeScript 7's CommonJS export no longer populates ts.sys, which
ts-node's config loader depends on, breaking `npm run worker` with
a TypeError. tsx (esbuild-based) doesn't rely on that API and
natively resolves tsconfig.json path aliases, so tsconfig-paths is
also no longer needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 14:22:46 +02:00
anthonyandClaude Sonnet 5 27ba48d181 feat: gate download options on real-time yt-dlp video analysis
Options (quality, subtitles) are now derived from a yt-dlp -J probe of
the submitted URL instead of a static list, so users can't pick
combinations the source video doesn't actually support. Submission is
blocked until the probe succeeds.

- New /api/probe endpoint + src/lib/ytdlp-probe.ts, with its own rate
  limiter (rate-limit.ts refactored into a createRateLimiter factory).
- New options: subtitle language selection, clip start/end trim, MP3
  audio quality.
- Fixed buildYtdlpArgs: format=mp3 never triggered audio extraction
  (-x/--audio-format), and quality=best never applied --merge-output-format,
  so the chosen container had no real effect.
- Cross-platform yt-dlp invocation: the bundled bin/yt-dlp is a Python
  zipapp relying on a shebang, which Windows' spawn() can't run
  directly. buildYtdlpCommand() runs it through `python` on Windows and
  execs it directly on Linux/o2switch, where the shebang works natively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 14:13:38 +02:00
anthonyandClaude Sonnet 5 f949566f76 docs: document NEXT_PUBLIC_BASE_URL in .env.example
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 12:05:44 +02:00
anthonyandClaude Sonnet 5 1d27d22581 feat: add sitemap.xml generation and sitemap <link> tag
Add app/sitemap.ts covering the homepage, supported-sites page, and
all 15 per-platform downloader pages across every locale, with
hreflang alternates between locales. Reference it from <head> via
<link rel="sitemap"> in the locale layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 12:01:14 +02:00
anthonyandClaude Sonnet 5 fd7515f8c2 feat: make Ombrora logo in header link to homepage
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 11:58:06 +02:00
anthonyandClaude Sonnet 5 f1a2639866 feat: show full supported-platforms block on each downloader page
Replace the plain "looking for another platform?" text link at the
bottom of each per-platform page with the same SupportedPlatformsSection
used on the homepage, giving stronger internal linking between all
platform pages. Drop the now-unused otherPlatforms/browseAll strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 11:55:48 +02:00
anthonyandClaude Sonnet 5 948b77cef4 feat: add per-platform SEO landing pages (/youtube-downloader, etc.)
Create a dedicated, dynamically-routed page for each of 15 curated
platforms (YouTube, TikTok, Instagram, Facebook, X, Reddit, Pinterest,
Vimeo, SoundCloud, Twitch, Dailymotion, LinkedIn, Tumblr, VK, Snapchat)
under /[locale]/[slug], with a tailored hero, an example URL matched
to the platform, a unique "about" paragraph, and a mix of
platform-specific and shared FAQ items, all translated across
en/fr/es/it. Unknown slugs 404.

Kept the scope to a curated list rather than all 1380 yt-dlp
extractors to avoid thin/duplicate "doorway page" content that search
engines penalize.

The homepage's platform badges and the "+1000 sites" links now point
to these pages, extracted the FAQ accordion markup into a shared
FaqAccordion component reused by both the homepage FAQ and the new
platform pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 11:53:36 +02:00
anthonyandClaude Sonnet 5 bd863056a3 feat: add supported sites page, remove yt-dlp mentions from public copy
yt-dlp is an internal implementation detail and should not be exposed
on the public site. Replace direct mentions in the platforms and FAQ
sections with a link to a new /supported-sites page listing every
extractor from yt-dlp's supported-sites doc (searchable, grouped
alphabetically), translated across en/fr/es/it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 11:36:29 +02:00
anthonyandClaude Sonnet 5 c8e991a8f7 feat: add how-it-works, supported platforms and FAQ sections to homepage
Enrich the landing page with reassurance/trust content inspired by
competitor downloader sites: a 3-step "how it works" guide, a
supported-platforms grid with a non-affiliation disclaimer, and an
8-question FAQ (with FAQPage JSON-LD) -- all translated across en/fr/es/it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 11:24:00 +02:00
anthony 1963521818 feat: redesign language switcher as dropdown, add cursor pointers
Replace flag-row selector with a dropdown to fix distorted flag icons
and remove the colored ring border. Add cursor-pointer to the language
menu and download submit button.
2026-08-11 10:51:53 +02:00
67 changed files with 5425 additions and 488 deletions
+5
View File
@@ -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
View File
@@ -1,2 +1,3 @@
DATABASE_URL="mysql://ombrora:ombrora@localhost:3306/ombrora"
STORAGE_PATH="/absolute/path/to/local/storage"
NEXT_PUBLIC_BASE_URL="https://example.com"
+2 -1
View File
@@ -1,8 +1,9 @@
node_modules/
.next/
.env
.env.prod
.env.local
*.log
storage/
worker/worker.pid
# Added by code-review-graph
.code-review-graph/
+8 -9
View File
@@ -10,23 +10,22 @@
- 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
- 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
- Interface web publique permettant de soumettre des URLs de videos a telecharger via yt-dlp.
- Les telechargements sont geres via une file d'attente (queue) executee par Passenger en arriere-plan avec support du multithreading.
- 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.
- 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).
- 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)
- 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.
- **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"`.
- **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`.
- 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`.
<!-- BEGIN:nextjs-agent-rules -->
+85 -41
View File
@@ -4,12 +4,13 @@ Interface web de téléchargement de vidéos via [yt-dlp](https://github.com/yt-
## Fonctionnement
1. L'utilisateur soumet une URL depuis l'interface web.
2. La demande est enregistrée en base (statut `PENDING`).
3. Le worker Passenger (Node.js, arrière-plan) dépile les jobs et exécute `yt-dlp` en parallèle (concurrence configurable).
4. Le fichier téléchargé est stocké localement ; un token signé (24h) est généré.
5. L'utilisateur est redirigé vers une page de statut qui se rafraîchit jusqu'à ce que le fichier soit prêt.
6. Un cron quotidien supprime les fichiers expirés et marque les entrées `FILE_DELETED` — la DB conserve l'historique complet.
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. 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. La demande est enregistrée en base (statut `PENDING`).
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. Le fichier téléchargé est stocké localement ; un token signé (24h) est généré.
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
@@ -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) |
| Base de données | MariaDB 11 via Prisma 7 |
| Frontend | React 19, TypeScript |
| Worker | Node.js (ts-node) |
| Frontend | React 19, TypeScript, Tailwind CSS v4 |
| 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 |
## Prérequis
- Node.js 20+
- 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
@@ -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 |
| `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
@@ -68,31 +74,41 @@ L'interface est accessible sur [http://localhost:3000](http://localhost:3000).
|--------|-------------|
| `npm run dev` | Serveur de développement Next.js |
| `npm run 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 run worker` | Worker de téléchargement (boucle de polling) |
| `npm run worker:check` | Vérifie si le worker tourne, le démarre sinon (usage cron) |
| `npm run worker` | Worker de téléchargement (boucle de polling, exécution directe/dev) |
| `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 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 deploy` | Déploiement complet sur o2switch (voir section ci-dessous) |
## Configuration
Les paramètres applicatifs sont centralisés dans `config/app.config.ts` :
```typescript
DOWNLOAD_LINK_TTL_HOURS: 24 // Durée de validité du token de téléchargement
WORKER_CONCURRENCY: 3 // Téléchargements parallèles maximum
WORKER_POLL_INTERVAL_MS: 10000 // Intervalle de polling du worker (ms)
RATE_LIMIT_MAX: 5 // Soumissions max par IP par fenêtre
RATE_LIMIT_WINDOW_MS: 3600000 // Fenêtre de rate-limit (1h)
DOWNLOAD_LINK_TTL_HOURS: 24 // Durée de validité du token de téléchargement
WORKER_CONCURRENCY: 3 // Téléchargements parallèles maximum
WORKER_POLL_INTERVAL_MS: 10000 // Intervalle de polling du worker (ms)
RATE_LIMIT_MAX: 5 // Soumissions max par IP par fenêtre
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
| 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/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/
├── app/
│ ├── page.tsx # Page d'accueil (formulaire de soumission)
│ ├── status/[uuid]/page.tsx # Page de suivi
│ ├── [locale]/
│ ├── 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/
│ ├── downloads/route.ts # POST — création
│ ├── downloads/route.ts # POST — création
│ ├── 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/
│ ├── SubmitForm.tsx # Formulaire de soumission
── StatusView.tsx # Vue de suivi (polling)
│ ├── SubmitForm.tsx # Formulaire de soumission (options dérivées du probe)
── 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/
├── prisma.ts # Client Prisma (singleton)
├── rate-limit.ts # Rate limiting par IP
├── token.ts # Génération/validation de tokens
── ytdlp.ts # Construction des arguments yt-dlp
├── prisma.ts # Client Prisma (singleton)
├── rate-limit.ts # Factory createRateLimiter (downloads + probe)
├── token.ts # Génération/validation de tokens
── 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/
├── index.ts # Boucle de polling principale
├── processor.ts # Exécution de yt-dlp, mise à jour DB
── cron-cleanup.ts # Nettoyage des fichiers expirés
└── cron-check.ts # Supervision du worker (cron)
── cron-cleanup.ts # Nettoyage des fichiers expirés
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/
└── schema.prisma # Modèles : Download, DownloadToken
@@ -130,20 +167,27 @@ prisma/
### 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`.
- **`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`).
- **Worker** : lancer `npm run worker:check` depuis un cron cPanel pour maintenir le worker actif.
- **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. Le projet n'a qu'un seul `package.json` (pas de sous-dossier frontend séparé à synchroniser).
- **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
1. Uploader les sources (hors `node_modules`, `.env`, `storage/`).
2. Déposer `yt-dlp` et `ffmpeg` dans `BIN_DIR`.
3. Créer `.env` avec les valeurs de production.
4. Via cPanel > "Setup Node.js App" : pointer sur le dépôt, lancer `npm install` puis `npm run build`.
5. Appliquer les migrations : `npm run db:migrate`.
6. Configurer deux crons cPanel :
- `npm run worker:check` toutes les 5 minutes (redémarre le worker si arrêté).
- `npm run worker:cleanup` une fois par jour (suppression des fichiers expirés).
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" : 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. En SSH, dans l'environnement Node fourni par cPanel (celui activé par le lien "Enter to the virtual environment" de cPanel) :
```bash
npm run deploy
```
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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+4
View File
@@ -7,5 +7,9 @@ export const config = {
STORAGE_PATH: process.env.STORAGE_PATH ?? '',
RATE_LIMIT_MAX: 5,
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'),
PYTHON_BIN: process.env.PYTHON_BIN ?? (process.platform === 'win32' ? 'python' : 'python3'),
} 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 12'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.
+16
View File
@@ -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
View File
@@ -9,15 +9,26 @@
"urlLabel": "Video URL",
"format": "Format",
"quality": "Quality",
"subtitles": "Subtitles (fr, en)",
"subtitles": "Subtitles",
"subtitleLanguages": "Subtitle languages",
"advanced": "Advanced options",
"formats": "mp4 · mp3 · webm · mkv",
"submit": "Download",
"submitting": "Sending...",
"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": {
"backHome": "Back to homepage",
"heading": "Download status",
"notFound": "Download not found.",
"loading": "Loading...",
@@ -39,9 +50,170 @@
"private": "Private",
"privateDesc": "No data retained",
"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": {
"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
View File
@@ -9,15 +9,26 @@
"urlLabel": "URL del vídeo",
"format": "Formato",
"quality": "Calidad",
"subtitles": "Subtítulos (fr, en)",
"subtitles": "Subtítulos",
"subtitleLanguages": "Idiomas de los subtítulos",
"advanced": "Opciones avanzadas",
"formats": "mp4 · mp3 · webm · mkv",
"submit": "Descargar",
"submitting": "Enviando...",
"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": {
"backHome": "Volver al inicio",
"heading": "Estado de la descarga",
"notFound": "Descarga no encontrada.",
"loading": "Cargando...",
@@ -39,9 +50,170 @@
"private": "Privado",
"privateDesc": "Sin datos retenidos",
"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": {
"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
View File
@@ -9,15 +9,26 @@
"urlLabel": "URL de la vidéo",
"format": "Format",
"quality": "Qualité",
"subtitles": "Sous-titres (fr, en)",
"subtitles": "Sous-titres",
"subtitleLanguages": "Langues des sous-titres",
"advanced": "Options avancées",
"formats": "mp4 · mp3 · webm · mkv",
"submit": "Télécharger",
"submitting": "Envoi en cours...",
"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": {
"backHome": "Retour à l'accueil",
"heading": "Statut du téléchargement",
"notFound": "Téléchargement introuvable.",
"loading": "Chargement...",
@@ -39,9 +50,170 @@
"private": "Privé",
"privateDesc": "Aucune donnée conservée",
"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": {
"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 dun 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 dune publication est téléchargée ; les galeries composées uniquement dimages 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 laudio : 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 quil 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 dun 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 dun 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
View File
@@ -9,15 +9,26 @@
"urlLabel": "URL del video",
"format": "Formato",
"quality": "Qualità",
"subtitles": "Sottotitoli (fr, en)",
"subtitles": "Sottotitoli",
"subtitleLanguages": "Lingue dei sottotitoli",
"advanced": "Opzioni avanzate",
"formats": "mp4 · mp3 · webm · mkv",
"submit": "Scarica",
"submitting": "Invio in corso...",
"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": {
"backHome": "Torna alla home",
"heading": "Stato del download",
"notFound": "Download non trovato.",
"loading": "Caricamento...",
@@ -39,9 +50,170 @@
"private": "Privato",
"privateDesc": "Nessun dato conservato",
"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": {
"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 laudio 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 laudio: 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."
}
}
}
+1255 -206
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -8,14 +8,20 @@
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"build": "next build --webpack",
"start": "node server.js",
"test": "jest",
"worker": "ts-node -r tsconfig-paths/register worker/index.ts",
"worker:cleanup": "ts-node -r tsconfig-paths/register worker/cron-cleanup.ts",
"worker:check": "ts-node -r tsconfig-paths/register worker/cron-check.ts",
"worker": "tsx worker/index.ts",
"worker:cleanup": "tsx worker/cron-cleanup.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:generate": "prisma generate"
"db:migrate:deploy": "prisma migrate deploy",
"db:generate": "prisma generate",
"deploy": "bash scripts/deploy.sh"
},
"jest": {
"testEnvironment": "node",
@@ -40,6 +46,7 @@
"next": "^16.3.0",
"next-intl": "^4.13.6",
"next-themes": "^0.4.6",
"pm2": "^7.0.3",
"prisma": "^7.9.1",
"react": "^19.2.8",
"react-dom": "^19.2.8"
@@ -55,8 +62,7 @@
"jest": "^30.4.2",
"jest-environment-node": "^30.4.1",
"tailwindcss": "^4.3.3",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.23.12",
"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;
+5
View File
@@ -14,7 +14,12 @@ model Download {
format String
quality String
subtitles Boolean @default(false)
subtitleLangs String? @db.Text
clipStart Int?
clipEnd Int?
audioQuality String?
extraArgs String? @db.Text
title String? @db.Text
filePath String? @db.Text
fileName String?
+26
View File
@@ -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."
+14
View File
@@ -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}`)
})
})
+114
View File
@@ -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>
)
}
+1
View File
@@ -75,6 +75,7 @@ export default async function LocaleLayout({
return (
<html lang={locale} suppressHydrationWarning>
<head>
<link rel="sitemap" type="application/xml" title="Sitemap" href="/sitemap.xml" />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
+6
View File
@@ -1,6 +1,9 @@
import { getTranslations } from 'next-intl/server'
import { SubmitForm } from '@/components/SubmitForm'
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({
params,
@@ -32,6 +35,9 @@ export default async function Home({
</section>
<ReassuranceSection locale={locale} />
<HowItWorksSection locale={locale} />
<SupportedPlatformsSection locale={locale} />
<FaqSection locale={locale} />
</main>
)
}
+9
View File
@@ -1,4 +1,5 @@
import { getTranslations } from 'next-intl/server'
import { Link } from '@/navigation'
import { StatusView } from '@/components/StatusView'
export default async function StatusPage({
@@ -11,6 +12,14 @@ export default async function StatusPage({
return (
<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"
>
&larr; {t('backHome')}
</Link>
</div>
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-10">
{t('heading')}
</h1>
+61
View File
@@ -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 * as tokenLib from '@/lib/token'
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/prisma', () => ({
@@ -8,12 +11,17 @@ jest.mock('@/lib/prisma', () => ({
}))
const mockValidate = tokenLib.validateToken as jest.Mock
const mockTokenUpdate = prisma.downloadToken.update as jest.Mock
function req(token: string) {
return new Request(`http://localhost/api/download/${token}`)
}
describe('GET /api/download/[token]', () => {
beforeEach(() => {
mockTokenUpdate.mockResolvedValue({})
})
it('returns 404 for invalid or expired token', async () => {
mockValidate.mockResolvedValue(null)
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 () => {
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' }) })
expect(res.status).toBe(404)
const body = await res.json()
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)
})
})
+4 -3
View File
@@ -3,6 +3,7 @@ import { createReadStream, statSync } from 'fs'
import path from 'path'
import { validateToken } from '@/lib/token'
import { prisma } from '@/lib/prisma'
import { buildDownloadFilename, contentDispositionHeader } from '@/lib/filename'
export async function GET(
_req: NextRequest,
@@ -15,7 +16,7 @@ export async function GET(
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 404 })
}
const { filePath } = result
const { filePath, title } = result
let stat: ReturnType<typeof statSync>
try {
@@ -29,12 +30,12 @@ export async function GET(
data: { usedAt: new Date() },
})
const fileName = path.basename(filePath)
const fileName = buildDownloadFilename(title, path.basename(filePath))
const stream = createReadStream(filePath)
return new NextResponse(stream as unknown as ReadableStream, {
headers: {
'Content-Disposition': `attachment; filename="${fileName}"`,
'Content-Disposition': contentDispositionHeader(fileName),
'Content-Length': String(stat.size),
'Content-Type': 'application/octet-stream',
},
@@ -40,6 +40,20 @@ describe('GET /api/downloads/[uuid]', () => {
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 () => {
mockFindUnique.mockResolvedValue({
...baseDownload,
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { buildDownloadFilename } from '@/lib/filename'
export async function GET(
_req: NextRequest,
@@ -28,7 +29,7 @@ export async function GET(
format: download.format,
quality: download.quality,
subtitles: download.subtitles,
fileName: download.fileName,
fileName: download.fileName ? buildDownloadFilename(download.title, download.fileName) : null,
fileSize: download.fileSize?.toString() ?? null,
errorMsg: download.errorMsg,
submittedAt: download.submittedAt,
+52 -5
View File
@@ -1,14 +1,16 @@
import { POST } from '../route'
import { prisma } from '@/lib/prisma'
import * as rateLimit from '@/lib/rate-limit'
import { downloadRateLimiter } from '@/lib/rate-limit'
jest.mock('@/lib/prisma', () => ({
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 mockIsRateLimited = rateLimit.isRateLimited as jest.Mock
const mockIsLimited = downloadRateLimiter.isLimited as jest.Mock
function req(body: object, ip = '1.2.3.4') {
return new Request('http://localhost/api/downloads', {
@@ -20,12 +22,12 @@ function req(body: object, ip = '1.2.3.4') {
describe('POST /api/downloads', () => {
beforeEach(() => {
mockIsRateLimited.mockReturnValue(false)
mockIsLimited.mockReturnValue(false)
mockCreate.mockResolvedValue({ uuid: 'test-uuid' })
})
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 }))
expect(res.status).toBe(429)
})
@@ -40,6 +42,14 @@ describe('POST /api/downloads', () => {
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 () => {
const res = await POST(req({ url: 'https://youtube.com/watch?v=abc', format: 'mp4', quality: 'best', subtitles: false }))
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 }),
})
})
})
+23 -4
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'
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) {
const ip =
req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '0.0.0.0'
const ip = getClientIp(req)
if (isRateLimited(ip)) {
if (downloadRateLimiter.isLimited(ip)) {
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({
data: {
url: String(body.url),
format: String(body.format),
quality: String(body.quality),
subtitles: Boolean(body.subtitles),
subtitleLangs: subtitleLangs ? subtitleLangs.join(',') : null,
clipStart,
clipEnd,
audioQuality: body.audioQuality ? String(body.audioQuality) : null,
extraArgs: body.extraArgs ?? null,
title: body.title ? String(body.title) : null,
ipAddress: ip,
},
})
+63
View File
@@ -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)
})
})
+31
View File
@@ -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 })
}
}
-10
View File
@@ -1,10 +0,0 @@
import { SubmitForm } from '@/components/SubmitForm'
export default function Home() {
return (
<main style={{ padding: '2rem' }}>
<h1>Ombrora</h1>
<SubmitForm />
</main>
)
}
+33
View File
@@ -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
}
-15
View File
@@ -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>
)
}
+39
View File
@@ -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>
)
}
+53
View File
@@ -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>
)
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { Link } from '@/navigation'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageSwitcher } from '@/components/LanguageSwitcher'
@@ -5,9 +6,9 @@ export function Header() {
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">
<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>
</Link>
<div className="flex items-center gap-3">
<ThemeToggle />
<LanguageSwitcher />
+68
View File
@@ -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>
)
}
+83 -22
View File
@@ -1,44 +1,105 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useLocale } from 'next-intl'
import { useRouter, usePathname } from '@/navigation'
import { routing } from '@/i18n/routing'
const FLAG_LABELS: Record<string, string> = {
const LANGUAGE_LABELS: Record<string, string> = {
en: 'English',
fr: 'Français',
es: 'Español',
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() {
const locale = useLocale()
const router = useRouter()
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 (
<div className="flex items-center gap-1">
{routing.locales.map((loc) => (
<button
key={loc}
onClick={() => router.replace(pathname, { locale: loc })}
aria-label={FLAG_LABELS[loc]}
className={[
'rounded transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500',
loc === locale
? 'opacity-100 ring-2 ring-violet-500 ring-offset-1 ring-offset-white dark:ring-offset-slate-950'
: 'opacity-40 hover:opacity-70',
].join(' ')}
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setOpen((value) => !value)}
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"
>
<img
src={`/flags/${loc}.svg`}
alt={FLAG_LABELS[loc]}
width={28}
height={20}
className="rounded-sm block"
/>
</button>
))}
{routing.locales.map((loc) => (
<li key={loc} role="option" aria-selected={loc === locale}>
<button
type="button"
onClick={() => selectLocale(loc)}
className={[
'flex w-full items-center gap-2.5 px-3 py-2 text-sm text-left transition-colors cursor-pointer',
loc === locale
? 'bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-400 font-medium'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-800',
].join(' ')}
>
<Flag locale={loc} />
{LANGUAGE_LABELS[loc]}
</button>
</li>
))}
</ul>
)}
</div>
)
}
+241 -53
View File
@@ -1,15 +1,28 @@
'use client'
import { useState, FormEvent } from 'react'
import { useTranslations } from 'next-intl'
import { useEffect, useRef, useState, FormEvent } from 'react'
import { useTranslations, useLocale } from 'next-intl'
import { useRouter } from '@/navigation'
import { FORMATS, AUDIO_QUALITIES } from '@/lib/ytdlp-options'
const FORMATS = ['mp4', 'mp3', 'webm', 'mkv']
const QUALITIES = ['best', '1080p', '720p', '480p', '360p']
type ProbeCapabilities = {
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 (
<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" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
@@ -19,19 +32,100 @@ function Spinner() {
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'
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 locale = useLocale()
const router = useRouter()
const [url, setUrl] = useState('')
const [probe, setProbe] = useState<ProbeState>({ status: 'idle' })
const [retryToken, setRetryToken] = useState(0)
const [format, setFormat] = useState('mp4')
const [quality, setQuality] = useState('best')
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 [error, setError] = useState<string | null>(null)
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) {
e.preventDefault()
if (probe.status !== 'ready') return
setError(null)
setLoading(true)
@@ -42,7 +136,12 @@ export function SubmitForm() {
url,
format,
quality,
title: probe.data.title || null,
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,
}),
})
@@ -63,6 +162,9 @@ export function SubmitForm() {
router.push(`/status/${uuid}`)
}
const capabilities = probe.status === 'ready' ? probe.data : null
const submitDisabled = loading || probe.status !== 'ready'
return (
<form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
{/* URL input with inline submit button */}
@@ -75,13 +177,13 @@ export function SubmitForm() {
value={url}
onChange={(e) => setUrl(e.target.value)}
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"
/>
<button
type="submit"
disabled={loading}
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"
disabled={submitDisabled}
className="flex items-center gap-2 px-5 py-3 bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white text-sm font-medium transition-colors disabled:opacity-60 cursor-pointer disabled:cursor-not-allowed"
>
{loading && <Spinner />}
{loading ? t('submitting') : t('submit')}
@@ -89,53 +191,139 @@ export function SubmitForm() {
</div>
</div>
{/* Options row */}
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
<label className="flex items-center gap-2">
<span>{t('format')}</span>
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className={selectClass}
{(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"
>
{FORMATS.map((f) => <option key={f}>{f}</option>)}
</select>
</label>
{t('retry')}
</button>
</div>
)}
<label className="flex items-center gap-2">
<span>{t('quality')}</span>
<select
value={quality}
onChange={(e) => setQuality(e.target.value)}
className={selectClass}
>
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
</select>
</label>
{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">
{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">
<span>{t('format')}</span>
<select value={format} onChange={(e) => setFormat(e.target.value)} className={selectClass}>
{FORMATS.map((f) => <option key={f}>{f}</option>)}
</select>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={subtitles}
onChange={(e) => setSubtitles(e.target.checked)}
className="accent-violet-600 w-4 h-4"
/>
<span>{t('subtitles')}</span>
</label>
{format !== 'mp3' && (
<label className="flex items-center gap-2">
<span>{t('quality')}</span>
<select value={quality} onChange={(e) => setQuality(e.target.value)} className={selectClass}>
{capabilities.availableQualities.map((q) => <option key={q}>{q}</option>)}
</select>
</label>
)}
</>
)}
<details className="w-full mt-1">
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
{t('advanced')}
</summary>
<input
type="text"
value={extraArgs}
onChange={(e) => setExtraArgs(e.target.value)}
placeholder='["--sponsorblock-remove","all"]'
className="mt-2 w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
/>
</details>
</div>
{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">
<input
type="checkbox"
checked={subtitles}
onChange={(e) => setSubtitles(e.target.checked)}
className="accent-violet-600 w-4 h-4"
/>
<span>{t('subtitles')}</span>
</label>
)}
{subtitles && capabilities.subtitleLangs.length > 0 && (
<div className="w-full flex flex-wrap items-center gap-x-4 gap-y-1 pl-1">
<span className="text-xs text-gray-400 dark:text-slate-500">{t('subtitleLanguages')}:</span>
{capabilities.subtitleLangs.map((lang) => (
<label key={lang} className="flex items-center gap-1.5 cursor-pointer text-xs">
<input
type="checkbox"
checked={subtitleLangs.includes(lang)}
onChange={(e) =>
setSubtitleLangs((prev) =>
e.target.checked ? [...prev, lang] : prev.filter((l) => l !== lang)
)
}
className="accent-violet-600 w-3.5 h-3.5"
/>
{langLabel(lang, locale)}
</label>
))}
</div>
)}
<div className="w-full flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xs text-gray-400 dark:text-slate-500">{t('clipRange')}</span>
<label className="flex items-center gap-1.5">
<span className="text-xs">{t('clipStart')}</span>
<input
type="number"
min={0}
max={capabilities.durationSeconds ?? undefined}
value={clipStart}
onChange={(e) => setClipStart(e.target.value)}
className={numberInputClass}
/>
</label>
<label className="flex items-center gap-1.5">
<span className="text-xs">{t('clipEnd')}</span>
<input
type="number"
min={0}
max={capabilities.durationSeconds ?? undefined}
value={clipEnd}
onChange={(e) => setClipEnd(e.target.value)}
className={numberInputClass}
/>
</label>
{capabilities.durationSeconds != null && (
<span className="text-xs text-gray-400 dark:text-slate-500">
{t('videoLength', { duration: formatDuration(capabilities.durationSeconds) })}
</span>
)}
</div>
<details className="w-full mt-1">
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
{t('advanced')}
</summary>
<input
type="text"
value={extraArgs}
onChange={(e) => setExtraArgs(e.target.value)}
placeholder='["--sponsorblock-remove","all"]'
className="mt-2 w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
/>
</details>
</div>
)}
{error && (
<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>
)
}
+88
View File
@@ -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>
)
}
+43
View File
@@ -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')}`)
})
})
+24 -12
View File
@@ -1,41 +1,53 @@
import { isRateLimited } from '../rate-limit'
import { createRateLimiter } from '../rate-limit'
beforeEach(() => {
jest.useFakeTimers()
jest.resetModules()
})
afterEach(() => {
jest.useRealTimers()
})
describe('isRateLimited', () => {
describe('createRateLimiter', () => {
it('allows requests under the limit', () => {
const limiter = createRateLimiter(5, 3_600_000)
const ip = '1.2.3.4'
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', () => {
const limiter = createRateLimiter(5, 3_600_000)
const ip = '10.0.0.1'
for (let i = 0; i < 5; i++) isRateLimited(ip)
expect(isRateLimited(ip)).toBe(true)
for (let i = 0; i < 5; i++) limiter.isLimited(ip)
expect(limiter.isLimited(ip)).toBe(true)
})
it('resets after the window expires', () => {
const limiter = createRateLimiter(5, 3_600_000)
const ip = '10.0.0.2'
for (let i = 0; i < 5; i++) isRateLimited(ip)
expect(isRateLimited(ip)).toBe(true)
for (let i = 0; i < 5; i++) limiter.isLimited(ip)
expect(limiter.isLimited(ip)).toBe(true)
jest.advanceTimersByTime(3_600_001)
expect(isRateLimited(ip)).toBe(false)
expect(limiter.isLimited(ip)).toBe(false)
})
it('tracks different IPs independently', () => {
const limiter = createRateLimiter(5, 3_600_000)
const ipA = '192.168.1.1'
const ipB = '192.168.1.2'
for (let i = 0; i < 5; i++) isRateLimited(ipA)
expect(isRateLimited(ipA)).toBe(true)
expect(isRateLimited(ipB)).toBe(false)
for (let i = 0; i < 5; i++) limiter.isLimited(ipA)
expect(limiter.isLimited(ipA)).toBe(true)
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)
})
})
+12
View File
@@ -60,4 +60,16 @@ describe('validateToken', () => {
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',
})
})
})
+26
View File
@@ -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'])
})
})
+97
View File
@@ -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()
})
})
+84 -7
View File
@@ -6,6 +6,10 @@ const base = {
format: 'mp4',
quality: 'best',
subtitles: false,
subtitleLangs: null,
clipStart: null,
clipEnd: null,
audioQuality: null,
extraArgs: null,
}
@@ -22,23 +26,96 @@ describe('buildYtdlpArgs', () => {
expect(args[idx + 1]).toContain('%(ext)s')
})
it('adds format filter when quality is not "best"', () => {
const args = buildYtdlpArgs({ ...base, quality: '1080p' })
expect(args).toContain('-f')
it('always sets --merge-output-format to the chosen video format', () => {
const args = buildYtdlpArgs(base)
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"', () => {
expect(buildYtdlpArgs(base)).not.toContain('-f')
it('adds a height filter to -f when quality is not "best"', () => {
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', () => {
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')
})
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', () => {
+28
View File
@@ -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)
}
+19
View File
@@ -0,0 +1,19 @@
import path from 'path'
const ILLEGAL_CHARS = /[<>:"/\\|?*\x00-\x1F]/g
export function sanitizeFilenameStem(title: string): string {
const cleaned = title.replace(ILLEGAL_CHARS, ' ').replace(/\s+/g, ' ').trim()
return cleaned.slice(0, 150) || 'download'
}
export function buildDownloadFilename(title: string | null, storedFileName: string): string {
if (!title) return storedFileName
const ext = path.extname(storedFileName)
return `${sanitizeFilenameStem(title)}${ext}`
}
export function contentDispositionHeader(filename: string): string {
const asciiFallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'")
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`
}
+27 -12
View File
@@ -1,19 +1,34 @@
import { config } from '../../config/app.config'
type Entry = { count: number; resetAt: number }
const store = new Map<string, Entry>()
export function isRateLimited(ip: string): boolean {
const now = Date.now()
const entry = store.get(ip)
export function createRateLimiter(max: number, windowMs: number) {
const store = new Map<string, Entry>()
if (!entry || now > entry.resetAt) {
store.set(ip, { count: 1, resetAt: now + config.RATE_LIMIT_WINDOW_MS })
return false
return {
isLimited(ip: string): boolean {
const now = Date.now()
const entry = store.get(ip)
if (!entry || now > entry.resetAt) {
store.set(ip, { count: 1, resetAt: now + windowMs })
return false
}
if (entry.count >= max) return true
entry.count++
return false
},
}
if (entry.count >= config.RATE_LIMIT_MAX) return true
entry.count++
return false
}
export const downloadRateLimiter = createRateLimiter(
config.RATE_LIMIT_MAX,
config.RATE_LIMIT_WINDOW_MS
)
export const probeRateLimiter = createRateLimiter(
config.PROBE_RATE_LIMIT_MAX,
config.PROBE_RATE_LIMIT_WINDOW_MS
)
+5
View File
@@ -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
View File
@@ -13,13 +13,17 @@ export async function createToken(downloadId: string): Promise<string> {
export async function validateToken(
token: string
): Promise<{ downloadId: string; filePath: string } | null> {
): Promise<{ downloadId: string; filePath: string; title: string | null } | null> {
const record = await prisma.downloadToken.findUnique({
where: { token },
include: { download: { select: { id: true, filePath: true } } },
include: { download: { select: { id: true, filePath: true, title: true } } },
})
if (!record) return null
if (record.expiresAt < new Date()) 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,
}
}
+3
View File
@@ -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
+125
View File
@@ -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
View File
@@ -8,27 +8,79 @@ export type YtdlpParams = {
format: string
quality: string
subtitles: boolean
subtitleLangs: string[] | null
clipStart: number | null
clipEnd: number | null
audioQuality: 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[] {
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 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)) {
args.push('--ffmpeg-location', ffmpegBin)
}
if (quality !== 'best') {
const height = quality.replace('p', '')
args.push('-f', `${format}[height<=?${height}]+bestaudio/best[height<=?${height}]`)
if (format === 'mp3') {
args.push('-x', '--audio-format', 'mp3')
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) {
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) {
-6
View File
@@ -28,12 +28,6 @@
}
]
},
"ts-node": {
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node"
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -29,6 +29,10 @@ const baseDownload = {
format: 'mp4',
quality: 'best',
subtitles: false,
subtitleLangs: null,
clipStart: null,
clipEnd: null,
audioQuality: null,
extraArgs: null,
}
-40
View File
@@ -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
View File
@@ -1,16 +1,11 @@
import { spawn } from 'child_process'
import { existsSync, readdirSync, statSync } from 'fs'
import { readdirSync, statSync } from 'fs'
import path from 'path'
import { prisma } from '@/lib/prisma'
import { buildYtdlpArgs } from '@/lib/ytdlp'
import { buildYtdlpArgs, buildYtdlpCommand } from '@/lib/ytdlp'
import { createToken } from '@/lib/token'
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> {
const download = await prisma.download.findUnique({ where: { id: downloadId } })
if (!download) return
@@ -21,13 +16,18 @@ export async function processDownload(downloadId: string): Promise<void> {
format: download.format,
quality: download.quality,
subtitles: download.subtitles,
subtitleLangs: download.subtitleLangs ? download.subtitleLangs.split(',') : null,
clipStart: download.clipStart,
clipEnd: download.clipEnd,
audioQuality: download.audioQuality,
extraArgs: download.extraArgs,
})
let stderr = ''
const { command, args: spawnArgs } = buildYtdlpCommand(args)
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.on('close', async (code) => {