Download any video,
instantly.
- Paste a YouTube (or any supported) link below and grab your video.
+Téléchargez n'importe quel morceau en
MP3.
+ Collez un lien YouTube ci-dessous et récupérez l'audio en MP3.
diff --git a/Dockerfile b/Dockerfile index 55fbc8874..41518234a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.14.3-slim +FROM python:3.14-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 @@ -29,11 +29,14 @@ COPY yt-dlp.conf /home/appuser/.config/yt-dlp/config # Copy app COPY web /app/web +COPY entrypoint.sh /app/entrypoint.sh -RUN chown -R appuser:appuser /app/web/backend/downloads /home/appuser/.config/yt-dlp +RUN chmod +x /app/entrypoint.sh \ + && chown -R appuser:appuser /app/web/backend/downloads /home/appuser/.config/yt-dlp USER appuser EXPOSE 8000 +ENTRYPOINT ["/app/entrypoint.sh"] CMD ["python", "-m", "uvicorn", "web.backend.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md index 6813d2b50..926b1568b 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,51 @@ -# yt-dlp Web UI +# yt-mp3-ui -A beautiful, self-hosted web application for downloading YouTube (and 1000+ other sites) videos — powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp). +A self-hosted web app for downloading music as MP3 from YouTube, powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp).   - + + +--- + +## About this fork + +This is a fork of [github.com/Samive/yt-dlp-web](https://github.com/Samive/yt-dlp-web). It changes the upstream video downloader into a focused music downloader. The differences from upstream: + +- **Music only** — every download is extracted to 320K MP3 with metadata and an embedded thumbnail. The format selector is gone; there is nothing to choose. +- **French interface** — the whole UI and its messages are in French. +- **Light and dark mode** — a theme toggle in the header, defaulting to your operating system preference. +- **Auto-updating yt-dlp** — the latest yt-dlp is pulled on container startup, so downloads keep working as sites change. --- ## Features -- Paste any YouTube (or supported site) URL and fetch video info instantly +- Paste any YouTube (or supported site) URL and fetch track info instantly - View thumbnail, title, uploader, duration, views, likes, and upload date -- Select from all available formats grouped by quality (Video+Audio, Video only, Audio only) -- Highest quality format pre-selected automatically +- One-click download to 320K MP3 with metadata and embedded cover art - Live download progress with percentage, speed, and ETA -- Cancel download at any time -- Clean dark UI with animated progress bar +- Cancel a download at any time +- Light and dark themes following your OS preference, with a manual toggle --- -## Quick Start with Docker - -### Pull and run from Docker Hub +## Quick Start with Docker Compose ```bash -docker pull samive/yt-dlp-web:latest -docker run -d -p 8000:8000 --name yt-dlp-web samive/yt-dlp-web:latest +git clone https://git.riou.xyz/jriou/yt-mp3-ui.git +cd yt-mp3-ui +docker compose up -d --build ``` Then open **http://localhost:8000** in your browser. -### Persist downloads to your machine +Downloads are written to the `downloads` volume (see `docker-compose.yml`). yt-dlp is upgraded to the latest release each time the container starts. + +### Stop / remove ```bash -docker run -d \ - -p 8000:8000 \ - -v $(pwd)/downloads:/app/web/backend/downloads \ - --name yt-dlp-web \ - samive/yt-dlp-web:latest -``` - -### Stop / remove the container - -```bash -docker stop yt-dlp-web -docker rm yt-dlp-web +docker compose down -v ``` --- @@ -56,18 +56,12 @@ docker rm yt-dlp-web - [Docker](https://docs.docker.com/get-docker/) installed -### Build the image +### Build and run ```bash -git clone https://github.com/samive/yt-dlp-web.git -cd yt-dlp-web -docker build -t yt-dlp-web . -``` - -### Run the image - -```bash -docker run -d -p 8000:8000 --name yt-dlp-web yt-dlp-web +docker build -t yt-mp3-ui . +docker run -d -p 8000:8000 \ + --name yt-dlp-ui yt-dlp-ui ``` Open **http://localhost:8000**. @@ -93,15 +87,18 @@ python -m uvicorn web.backend.main:app --reload --port 8000 Open **http://localhost:8000**. +> Auto-update at startup is handled by the container entrypoint. When running the server directly, upgrade yt-dlp yourself with `pip install -U yt-dlp`. + --- ## Usage Guide -1. **Paste a URL** — Paste any YouTube or supported site link into the input field and click **Fetch** -2. **Review video info** — Thumbnail, title, uploader, duration, views, likes, and upload date are shown -3. **Select a format** — Choose from the dropdown (highest quality is pre-selected) -4. **Download** — Click the **Download** button and watch the live progress bar -5. **Cancel** — Click **Cancel** at any time to stop the download +1. **Paste a URL** — paste any YouTube or supported site link into the input field and click **Rechercher** +2. **Review track info** — thumbnail, title, uploader, duration, views, likes, and upload date are shown +3. **Download** — click **Télécharger** and watch the live progress bar. The file is saved as a 320K MP3 +4. **Cancel** — click **Annuler** at any time to stop the download + +Use the sun/moon button in the header to switch between light and dark modes. Your choice is remembered. ### YouTube authentication (optional) @@ -111,8 +108,8 @@ Some videos require sign-in. Export your cookies using the [Get cookies.txt LOCA docker run -d \ -p 8000:8000 \ -v $(pwd)/cookies.txt:/app/web/backend/cookies.txt:ro \ - --name yt-dlp-web \ - samive/yt-dlp-web:latest + --name yt-mp3-ui \ + yt-mp3-ui ``` > **Note:** Using cookies with Chrome on Windows may cause issues due to DPAPI encryption. Firefox or Edge cookies work more reliably. diff --git a/anubis/botPolicy.json b/anubis/botPolicy.json new file mode 100644 index 000000000..5c71b081f --- /dev/null +++ b/anubis/botPolicy.json @@ -0,0 +1,24 @@ +{ + "bots": [ + { + "name": "well-known", + "path_regex": "^/.well-known/.*$", + "action": "ALLOW" + }, + { + "name": "favicon", + "path_regex": "^/favicon.ico$", + "action": "ALLOW" + }, + { + "name": "robots-txt", + "path_regex": "^/robots.txt$", + "action": "ALLOW" + }, + { + "name": "generic-browser", + "user_agent_regex": "Mozilla", + "action": "CHALLENGE" + } + ] +} diff --git a/anubis/robots.txt b/anubis/robots.txt new file mode 100644 index 000000000..1f53798bb --- /dev/null +++ b/anubis/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/docker-compose.yml b/docker-compose.yml index aaafff484..14bd509a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,27 @@ services: - yt-dlp-web: + yt-mp3-ui: build: . - container_name: yt-dlp-web ports: - "8000:8000" + container_name: yt-mp3-ui + restart: unless-stopped volumes: - ./cookies.txt:/tmp/cookies.txt:ro - - ./downloads:/app/web/backend/downloads \ No newline at end of file + - downloads:/app/web/backend/downloads + + anubis: + image: ghcr.io/techarohq/anubis:latest + ports: + - "8923:8923" + environment: + BIND: ":8923" + DIFFICULTY: "5" + METRICS_BIND: ":9090" + SERVE_ROBOTS_TXT: "true" + TARGET: "http://yt-mp3-ui:8000" + volumes: + - ./anubis:/data/cfg + user: "1000:1000" + +volumes: + downloads: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 000000000..0b308e65f --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +# yt-dlp breaks whenever YouTube changes; refresh to latest before the server +# imports it. A failed update (e.g. no network) must not stop the app from +# starting with the version baked into the image. +python -m pip install --user --upgrade --disable-pip-version-check yt-dlp \ + || echo "yt-dlp auto-update failed; starting with the installed version" >&2 + +exec "$@" diff --git a/web/backend/main.py b/web/backend/main.py index a8c923ed0..b8e7d9741 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -2,6 +2,7 @@ import sys import json import uuid import asyncio +import logging import httpx import urllib.parse import mimetypes @@ -17,6 +18,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) import yt_dlp +logger = logging.getLogger("yt_dlp_web") + +GENERIC_ERROR = "Une erreur est survenue. Veuillez réessayer." + + app = FastAPI() app.add_middleware( @@ -29,7 +35,8 @@ app.add_middleware( @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): - return JSONResponse(status_code=500, content={"detail": str(exc)}) + logger.exception("Unhandled error on %s %s", request.method, request.url.path) + return JSONResponse(status_code=500, content={"detail": GENERIC_ERROR}) DOWNLOAD_DIR = Path(__file__).parent / "downloads" @@ -38,14 +45,6 @@ DOWNLOAD_DIR.mkdir(exist_ok=True) FRONTEND_DIR = Path(__file__).parent.parent / "frontend" -FORMAT_PRESETS = { - "best": "bestvideo+bestaudio/bestvideo/best", - "1080p": "bestvideo[height<=1080]+bestaudio/bestvideo[height<=1080]/best", - "720p": "bestvideo[height<=720]+bestaudio/bestvideo[height<=720]/best", - "480p": "bestvideo[height<=480]+bestaudio/bestvideo[height<=480]/best", - "audio": "bestaudio/best", -} - # file_id -> progress/meta _progress: dict[str, dict] = {} @@ -59,8 +58,6 @@ class InfoRequest(BaseModel): class DownloadRequest(BaseModel): url: str - preset: str = "best" - format_id: str | None = None file_id: str | None = None @@ -80,11 +77,12 @@ async def get_info(req: InfoRequest): try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = await asyncio.to_thread(ydl.extract_info, req.url, download=False) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + except Exception: + logger.exception("Info extraction failed for url=%s", req.url) + raise HTTPException(status_code=400, detail="Impossible d'extraire les infos de la vidéo.") if not info: - raise HTTPException(status_code=400, detail="Could not extract video info.") + raise HTTPException(status_code=400, detail="Impossible d'extraire les infos de la vidéo.") thumbnails = info.get("thumbnails") or [] thumbnail_url = None @@ -99,28 +97,6 @@ async def get_info(req: InfoRequest): thumbnail_url = thumbnail_url or info.get("thumbnail") - formats = [ - { - "format_id": f.get("format_id"), - "ext": f.get("ext"), - "resolution": f.get("resolution") or f.get("format_note") or "unknown", - "filesize": f.get("filesize") or f.get("filesize_approx"), - "vcodec": f.get("vcodec"), - "acodec": f.get("acodec"), - "fps": f.get("fps"), - "height": f.get("height") or 0, - "tbr": f.get("tbr") or 0, - } - for f in (info.get("formats") or []) - if f.get("ext") != "mhtml" - and f.get("protocol") != "mhtml" - and (f.get("vcodec", "none") != "none" or f.get("acodec", "none") != "none") - ] - formats.sort(key=lambda f: (f["height"], f["tbr"]), reverse=True) - - if not formats: - raise HTTPException(status_code=400, detail="No downloadable formats found.") - return { "title": info.get("title"), "thumbnail": f"/thumbnail?url={thumbnail_url}" if thumbnail_url else None, @@ -129,7 +105,6 @@ async def get_info(req: InfoRequest): "view_count": info.get("view_count"), "like_count": info.get("like_count"), "upload_date": info.get("upload_date"), - "formats": formats, } @@ -143,10 +118,11 @@ async def proxy_thumbnail(url: str): media_type=r.headers.get("content-type", "image/jpeg"), ) except Exception: - raise HTTPException(status_code=502, detail="Could not fetch thumbnail") + logger.exception("Thumbnail fetch failed for url=%s", url) + raise HTTPException(status_code=502, detail="Impossible de récupérer la miniature") -async def _run_download(file_id: str, url: str, fmt: str, output_template: str): +async def _run_download(file_id: str, url: str, output_template: str): cancelled = asyncio.Event() def progress_hook(d): @@ -185,14 +161,18 @@ async def _run_download(file_id: str, url: str, fmt: str, output_template: str): ydl_opts = { "quiet": True, "no_warnings": True, - "format": fmt, + "format": "bestaudio/best", "outtmpl": output_template, - "merge_output_format": "mp4", + "restrictfilenames": True, + "writethumbnail": True, "postprocessors": [ { - "key": "FFmpegVideoConvertor", - "preferedformat": "mp4", - } + "key": "FFmpegExtractAudio", + "preferredcodec": "mp3", + "preferredquality": "320", + }, + {"key": "FFmpegMetadata"}, + {"key": "EmbedThumbnail"}, ], "progress_hooks": [progress_hook], } @@ -208,10 +188,11 @@ async def _run_download(file_id: str, url: str, fmt: str, output_template: str): _progress[file_id]["status"] = "cancelled" raise - except Exception as e: + except Exception: + logger.exception("Download failed for file_id=%s url=%s", file_id, url) if file_id in _progress: _progress[file_id]["status"] = "error" - _progress[file_id]["error"] = str(e) + _progress[file_id]["error"] = GENERIC_ERROR raise @@ -219,10 +200,10 @@ def _finalize_download(file_id: str, info: dict): matches = list(DOWNLOAD_DIR.glob(f"{file_id}.*")) if not matches: _progress[file_id]["status"] = "error" - _progress[file_id]["error"] = "Download failed: file not found" + _progress[file_id]["error"] = "Échec du téléchargement : fichier introuvable" return - filepath = matches[0] + filepath = next((m for m in matches if m.suffix == ".mp3"), matches[0]) title = info.get("title") or file_id filename_ascii = "".join( @@ -249,7 +230,6 @@ def _finalize_download(file_id: str, info: dict): async def download_video(req: DownloadRequest): file_id = req.file_id or str(uuid.uuid4()) output_template = str(DOWNLOAD_DIR / f"{file_id}.%(ext)s") - fmt = req.format_id if req.format_id else FORMAT_PRESETS.get(req.preset, FORMAT_PRESETS["best"]) _progress[file_id] = { "status": "starting", @@ -257,11 +237,10 @@ async def download_video(req: DownloadRequest): "eta": None, "speed": None, "url": req.url, - "fmt": fmt, "output_template": output_template, } - task = asyncio.create_task(_run_download(file_id, req.url, fmt, output_template)) + task = asyncio.create_task(_run_download(file_id, req.url, output_template)) _tasks[file_id] = task def _done_callback(t: asyncio.Task): @@ -271,10 +250,10 @@ async def download_video(req: DownloadRequest): except asyncio.CancelledError: if file_id in _progress: _progress[file_id]["status"] = "cancelled" - except Exception as e: + except Exception: if file_id in _progress: _progress[file_id]["status"] = "error" - _progress[file_id]["error"] = str(e) + _progress[file_id]["error"] = GENERIC_ERROR finally: _tasks.pop(file_id, None) @@ -303,11 +282,11 @@ async def cancel_download(file_id: str): async def serve_file(file_id: str): meta = _progress.get(file_id) if not meta or meta.get("status") != "ready": - raise HTTPException(status_code=404, detail="File not ready") + raise HTTPException(status_code=404, detail="Fichier pas encore prêt") filepath = Path(meta["filepath"]) if not filepath.exists(): - raise HTTPException(status_code=404, detail="File not found") + raise HTTPException(status_code=404, detail="Fichier introuvable") filename_ascii = meta["filename_ascii"] filename_encoded = meta["filename_encoded"] diff --git a/web/frontend/app.js b/web/frontend/app.js index 9d669f7d1..0e7a5bdaf 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -9,13 +9,19 @@ const thumbnail = document.getElementById('thumbnail'); const videoTitle = document.getElementById('video-title'); const uploaderEl = document.getElementById('uploader'); const durationEl = document.getElementById('duration'); -const formatSection = document.getElementById('format-section'); -const formatSelect = document.getElementById('format-select'); const downloadBtn = document.getElementById('download-btn'); const progressWrap = document.getElementById('progress-wrap'); const progressFill = document.getElementById('progress-fill'); const progressLabel = document.getElementById('progress-label'); const cancelBtn = document.getElementById('cancel-btn'); +const themeToggle = document.getElementById('theme-toggle'); + +themeToggle.addEventListener('click', () => { + const next = + document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('theme', next); +}); let currentFileId = null; let currentAbort = null; @@ -50,7 +56,6 @@ function resetUI() { clearBtn.style.display = 'none'; errorMsg.style.display = 'none'; preview.style.display = 'none'; - formatSection.style.display = 'none'; downloadBtn.style.display = 'none'; progressWrap.style.display = 'none'; thumbnail.src = ''; @@ -80,7 +85,7 @@ function setLoading(btn, loading, text) { btn.textContent = text; } else if (!loading && btn.dataset.label) { if (btn.id === 'download-btn') { - btn.innerHTML = ` Download`; + btn.innerHTML = ` Télécharger`; } else { btn.textContent = btn.dataset.label; } @@ -106,32 +111,6 @@ function formatCount(n) { return n.toString(); } -function formatBytes(bytes) { - if (!bytes) return null; - if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB'; - if (bytes >= 1e6) return (bytes / 1e6).toFixed(1) + ' MB'; - return (bytes / 1e3).toFixed(0) + ' KB'; -} - -function formatLabel(f) { - const parts = []; - const res = f.resolution || ''; - - if (res && res !== 'unknown') parts.push(res); - if (f.ext) parts.push(f.ext.toUpperCase()); - - const hasVideo = f.vcodec && f.vcodec !== 'none'; - const hasAudio = f.acodec && f.acodec !== 'none'; - - if (!hasVideo && hasAudio) parts.push('audio only'); - if (f.fps && hasVideo) parts.push(`${f.fps}fps`); - - const size = formatBytes(f.filesize); - if (size) parts.push(`~${size}`); - - return parts.join(' · ') || f.format_id; -} - function setProgress(pct, label) { progressFill.style.width = pct + '%'; progressLabel.innerHTML = label; @@ -139,11 +118,11 @@ function setProgress(pct, label) { function formatEta(secs) { if (secs == null || secs < 0) return ''; - if (secs < 60) return `ETA ${secs}s`; + if (secs < 60) return `Reste ${secs}s`; const m = Math.floor(secs / 60); const s = secs % 60; - return `ETA ${m}m ${s}s`; + return `Reste ${m}m ${s}s`; } urlInput.addEventListener('input', () => { @@ -163,16 +142,15 @@ fetchBtn.addEventListener('click', async () => { const url = urlInput.value.trim(); if (!url) { - showError('Please paste a video URL first.'); + showError('Veuillez d\'abord coller une URL de vidéo.'); return; } hideError(); preview.style.display = 'none'; - formatSection.style.display = 'none'; downloadBtn.style.display = 'none'; progressWrap.style.display = 'none'; - setLoading(fetchBtn, true, 'Fetching…'); + setLoading(fetchBtn, true, 'Recherche…'); try { const res = await fetch(`${API}/info`, { @@ -184,16 +162,14 @@ fetchBtn.addEventListener('click', async () => { const data = await res.json(); if (!res.ok) { - throw new Error(data.detail || 'Failed to fetch video info.'); + throw new Error(data.detail || 'Échec de la récupération des infos de la vidéo.'); } renderPreview(data); - renderFormats(data.formats || []); preview.style.display = 'flex'; - formatSection.style.display = 'flex'; downloadBtn.style.display = 'flex'; } catch (err) { - showError(err.message || 'Failed to fetch video info.'); + showError(err.message || 'Échec de la récupération des infos de la vidéo.'); } finally { setLoading(fetchBtn, false); } @@ -201,10 +177,10 @@ fetchBtn.addEventListener('click', async () => { function renderPreview(info) { thumbnail.src = info.thumbnail || ''; - thumbnail.alt = info.title || 'Video thumbnail'; + thumbnail.alt = info.title || 'Miniature de la vidéo'; thumbnail.style.display = info.thumbnail ? 'block' : 'none'; - videoTitle.textContent = info.title || 'Unknown title'; + videoTitle.textContent = info.title || 'Titre inconnu'; uploaderEl.textContent = info.uploader || ''; uploaderEl.style.display = info.uploader ? '' : 'none'; @@ -227,7 +203,7 @@ function renderPreview(info) { const d = info.upload_date; const formatted = new Date( `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` - ).toLocaleDateString(undefined, { + ).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric', @@ -240,71 +216,14 @@ function renderPreview(info) { } } -function renderFormats(formats) { - formatSelect.innerHTML = ''; - - const sorted = [...formats].sort( - (a, b) => (b.height || 0) - (a.height || 0) || (b.tbr || 0) - (a.tbr || 0) - ); - - const combined = sorted.filter((f) => f.vcodec !== 'none' && f.acodec !== 'none'); - const videoOnly = sorted.filter((f) => f.vcodec !== 'none' && f.acodec === 'none'); - const audioOnly = sorted.filter((f) => f.vcodec === 'none' && f.acodec !== 'none'); - - const addGroup = (label, items, transformValue = null) => { - if (!items.length) return; - - const group = document.createElement('optgroup'); - group.label = label; - - items.forEach((f) => { - const opt = document.createElement('option'); - opt.value = transformValue ? transformValue(f) : f.format_id; - opt.textContent = formatLabel(f); - group.appendChild(opt); - }); - - formatSelect.appendChild(group); - }; - - addGroup( - 'Video only', - videoOnly, - (f) => `${f.format_id}+bestaudio[ext=m4a]/bestaudio` - ); - - addGroup('Video + Audio', combined); - addGroup('Audio only', audioOnly); - - // Prefer highest MP4 video-only first, because backend can merge audio into MP4 - const bestMp4VideoOnly = videoOnly.find((f) => f.ext === 'mp4'); - - // Fallback to highest combined MP4 - const bestMp4Combined = combined.find((f) => f.ext === 'mp4'); - - // Fallback to any top combined format - const bestCombined = combined[0]; - - // Final fallback - const bestDefault = - (bestMp4VideoOnly && `${bestMp4VideoOnly.format_id}+bestaudio[ext=m4a]/bestaudio`) || - (bestMp4Combined && bestMp4Combined.format_id) || - (bestCombined && bestCombined.format_id) || - (sorted[0] && sorted[0].format_id); - - if (bestDefault) { - formatSelect.value = bestDefault; - } -} - downloadBtn.addEventListener('click', async () => { const url = urlInput.value.trim(); if (!url) return; hideError(); - setLoading(downloadBtn, true, 'Downloading…'); + setLoading(downloadBtn, true, 'Téléchargement…'); progressWrap.style.display = 'block'; - setProgress(0, ''); + setProgress(0, ''); const fileId = crypto.randomUUID(); currentFileId = fileId; @@ -316,7 +235,7 @@ downloadBtn.addEventListener('click', async () => { const d = JSON.parse(e.data); if (d.status === 'starting') { - setProgress(0, ''); + setProgress(0, ''); } else if (d.status === 'downloading') { const pct = d.percent || 0; const eta = d.eta != null ? formatEta(d.eta) : ''; @@ -330,7 +249,7 @@ downloadBtn.addEventListener('click', async () => { } else if (d.status === 'finished') { setProgress( 100, - '100% ' + '100% ' ); } else if (d.status === 'ready') { sse.close(); @@ -340,7 +259,7 @@ downloadBtn.addEventListener('click', async () => { if (currentFileId === fileId) { setProgress( 100, - '✓ ' + '✓ ' ); window.location.href = `${API}/file/${fileId}`; @@ -363,7 +282,7 @@ downloadBtn.addEventListener('click', async () => { sse.close(); currentSse = null; currentAbort = null; - showError(d.error || 'Download failed.'); + showError(d.error || 'Échec du téléchargement.'); progressWrap.style.display = 'none'; currentFileId = null; setLoading(downloadBtn, false); @@ -384,7 +303,6 @@ downloadBtn.addEventListener('click', async () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, - format_id: formatSelect.value, file_id: fileId, }), signal: abort.signal, @@ -393,11 +311,11 @@ downloadBtn.addEventListener('click', async () => { const data = await res.json().catch(() => ({})); if (!res.ok) { - throw new Error(data.detail || 'Download failed.'); + throw new Error(data.detail || 'Échec du téléchargement.'); } if (data.status !== 'started') { - throw new Error('Unexpected server response.'); + throw new Error('Réponse inattendue du serveur.'); } } catch (err) { if (currentSse) { @@ -408,7 +326,7 @@ downloadBtn.addEventListener('click', async () => { currentAbort = null; if (err.name !== 'AbortError') { - showError(err.message || 'Download failed.'); + showError(err.message || 'Échec du téléchargement.'); progressWrap.style.display = 'none'; } diff --git a/web/frontend/index.html b/web/frontend/index.html index 34f682a0b..a14f94404 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -1,14 +1,22 @@ - +
-Paste a YouTube (or any supported) link below and grab your video.
+Collez un lien YouTube ci-dessous et récupérez l'audio en MP3.