Fork to yt-mp3-ui
Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
parent
84a8e0faa7
commit
9c6360e9c4
10 changed files with 243 additions and 288 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.14.3-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
@ -29,11 +29,14 @@ COPY yt-dlp.conf /home/appuser/.config/yt-dlp/config
|
||||||
|
|
||||||
# Copy app
|
# Copy app
|
||||||
COPY web /app/web
|
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
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
CMD ["python", "-m", "uvicorn", "web.backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["python", "-m", "uvicorn", "web.backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
83
README.md
83
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
|
## 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
|
- View thumbnail, title, uploader, duration, views, likes, and upload date
|
||||||
- Select from all available formats grouped by quality (Video+Audio, Video only, Audio only)
|
- One-click download to 320K MP3 with metadata and embedded cover art
|
||||||
- Highest quality format pre-selected automatically
|
|
||||||
- Live download progress with percentage, speed, and ETA
|
- Live download progress with percentage, speed, and ETA
|
||||||
- Cancel download at any time
|
- Cancel a download at any time
|
||||||
- Clean dark UI with animated progress bar
|
- Light and dark themes following your OS preference, with a manual toggle
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start with Docker
|
## Quick Start with Docker Compose
|
||||||
|
|
||||||
### Pull and run from Docker Hub
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull samive/yt-dlp-web:latest
|
git clone https://git.riou.xyz/jriou/yt-mp3-ui.git
|
||||||
docker run -d -p 8000:8000 --name yt-dlp-web samive/yt-dlp-web:latest
|
cd yt-mp3-ui
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open **http://localhost:8000** in your browser.
|
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
|
```bash
|
||||||
docker run -d \
|
docker compose down -v
|
||||||
-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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -56,18 +56,12 @@ docker rm yt-dlp-web
|
||||||
|
|
||||||
- [Docker](https://docs.docker.com/get-docker/) installed
|
- [Docker](https://docs.docker.com/get-docker/) installed
|
||||||
|
|
||||||
### Build the image
|
### Build and run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/samive/yt-dlp-web.git
|
docker build -t yt-mp3-ui .
|
||||||
cd yt-dlp-web
|
docker run -d -p 8000:8000 \
|
||||||
docker build -t yt-dlp-web .
|
--name yt-dlp-ui yt-dlp-ui
|
||||||
```
|
|
||||||
|
|
||||||
### Run the image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d -p 8000:8000 --name yt-dlp-web yt-dlp-web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **http://localhost:8000**.
|
Open **http://localhost:8000**.
|
||||||
|
|
@ -93,15 +87,18 @@ python -m uvicorn web.backend.main:app --reload --port 8000
|
||||||
|
|
||||||
Open **http://localhost: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
|
## Usage Guide
|
||||||
|
|
||||||
1. **Paste a URL** — Paste any YouTube or supported site link into the input field and click **Fetch**
|
1. **Paste a URL** — paste any YouTube or supported site link into the input field and click **Rechercher**
|
||||||
2. **Review video info** — Thumbnail, title, uploader, duration, views, likes, and upload date are shown
|
2. **Review track 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)
|
3. **Download** — click **Télécharger** and watch the live progress bar. The file is saved as a 320K MP3
|
||||||
4. **Download** — Click the **Download** button and watch the live progress bar
|
4. **Cancel** — click **Annuler** at any time to stop the download
|
||||||
5. **Cancel** — Click **Cancel** 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)
|
### YouTube authentication (optional)
|
||||||
|
|
||||||
|
|
@ -111,8 +108,8 @@ Some videos require sign-in. Export your cookies using the [Get cookies.txt LOCA
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 8000:8000 \
|
-p 8000:8000 \
|
||||||
-v $(pwd)/cookies.txt:/app/web/backend/cookies.txt:ro \
|
-v $(pwd)/cookies.txt:/app/web/backend/cookies.txt:ro \
|
||||||
--name yt-dlp-web \
|
--name yt-mp3-ui \
|
||||||
samive/yt-dlp-web:latest
|
yt-mp3-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Using cookies with Chrome on Windows may cause issues due to DPAPI encryption. Firefox or Edge cookies work more reliably.
|
> **Note:** Using cookies with Chrome on Windows may cause issues due to DPAPI encryption. Firefox or Edge cookies work more reliably.
|
||||||
|
|
|
||||||
24
anubis/botPolicy.json
Normal file
24
anubis/botPolicy.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2
anubis/robots.txt
Normal file
2
anubis/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
@ -1,9 +1,27 @@
|
||||||
services:
|
services:
|
||||||
yt-dlp-web:
|
yt-mp3-ui:
|
||||||
build: .
|
build: .
|
||||||
container_name: yt-dlp-web
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
container_name: yt-mp3-ui
|
||||||
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./cookies.txt:/tmp/cookies.txt:ro
|
- ./cookies.txt:/tmp/cookies.txt:ro
|
||||||
- ./downloads:/app/web/backend/downloads
|
- 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:
|
||||||
|
|
|
||||||
10
entrypoint.sh
Executable file
10
entrypoint.sh
Executable file
|
|
@ -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 "$@"
|
||||||
|
|
@ -2,6 +2,7 @@ import sys
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
@ -17,6 +18,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("yt_dlp_web")
|
||||||
|
|
||||||
|
GENERIC_ERROR = "Une erreur est survenue. Veuillez réessayer."
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
@ -29,7 +35,8 @@ app.add_middleware(
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def global_exception_handler(request: Request, exc: 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"
|
DOWNLOAD_DIR = Path(__file__).parent / "downloads"
|
||||||
|
|
@ -38,14 +45,6 @@ DOWNLOAD_DIR.mkdir(exist_ok=True)
|
||||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
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
|
# file_id -> progress/meta
|
||||||
_progress: dict[str, dict] = {}
|
_progress: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
@ -59,8 +58,6 @@ class InfoRequest(BaseModel):
|
||||||
|
|
||||||
class DownloadRequest(BaseModel):
|
class DownloadRequest(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
preset: str = "best"
|
|
||||||
format_id: str | None = None
|
|
||||||
file_id: str | None = None
|
file_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -80,11 +77,12 @@ async def get_info(req: InfoRequest):
|
||||||
try:
|
try:
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = await asyncio.to_thread(ydl.extract_info, req.url, download=False)
|
info = await asyncio.to_thread(ydl.extract_info, req.url, download=False)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
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:
|
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 []
|
thumbnails = info.get("thumbnails") or []
|
||||||
thumbnail_url = None
|
thumbnail_url = None
|
||||||
|
|
@ -99,28 +97,6 @@ async def get_info(req: InfoRequest):
|
||||||
|
|
||||||
thumbnail_url = thumbnail_url or info.get("thumbnail")
|
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 {
|
return {
|
||||||
"title": info.get("title"),
|
"title": info.get("title"),
|
||||||
"thumbnail": f"/thumbnail?url={thumbnail_url}" if thumbnail_url else None,
|
"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"),
|
"view_count": info.get("view_count"),
|
||||||
"like_count": info.get("like_count"),
|
"like_count": info.get("like_count"),
|
||||||
"upload_date": info.get("upload_date"),
|
"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"),
|
media_type=r.headers.get("content-type", "image/jpeg"),
|
||||||
)
|
)
|
||||||
except Exception:
|
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()
|
cancelled = asyncio.Event()
|
||||||
|
|
||||||
def progress_hook(d):
|
def progress_hook(d):
|
||||||
|
|
@ -185,14 +161,18 @@ async def _run_download(file_id: str, url: str, fmt: str, output_template: str):
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"no_warnings": True,
|
"no_warnings": True,
|
||||||
"format": fmt,
|
"format": "bestaudio/best",
|
||||||
"outtmpl": output_template,
|
"outtmpl": output_template,
|
||||||
"merge_output_format": "mp4",
|
"restrictfilenames": True,
|
||||||
|
"writethumbnail": True,
|
||||||
"postprocessors": [
|
"postprocessors": [
|
||||||
{
|
{
|
||||||
"key": "FFmpegVideoConvertor",
|
"key": "FFmpegExtractAudio",
|
||||||
"preferedformat": "mp4",
|
"preferredcodec": "mp3",
|
||||||
}
|
"preferredquality": "320",
|
||||||
|
},
|
||||||
|
{"key": "FFmpegMetadata"},
|
||||||
|
{"key": "EmbedThumbnail"},
|
||||||
],
|
],
|
||||||
"progress_hooks": [progress_hook],
|
"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"
|
_progress[file_id]["status"] = "cancelled"
|
||||||
raise
|
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:
|
if file_id in _progress:
|
||||||
_progress[file_id]["status"] = "error"
|
_progress[file_id]["status"] = "error"
|
||||||
_progress[file_id]["error"] = str(e)
|
_progress[file_id]["error"] = GENERIC_ERROR
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -219,10 +200,10 @@ def _finalize_download(file_id: str, info: dict):
|
||||||
matches = list(DOWNLOAD_DIR.glob(f"{file_id}.*"))
|
matches = list(DOWNLOAD_DIR.glob(f"{file_id}.*"))
|
||||||
if not matches:
|
if not matches:
|
||||||
_progress[file_id]["status"] = "error"
|
_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
|
return
|
||||||
|
|
||||||
filepath = matches[0]
|
filepath = next((m for m in matches if m.suffix == ".mp3"), matches[0])
|
||||||
title = info.get("title") or file_id
|
title = info.get("title") or file_id
|
||||||
|
|
||||||
filename_ascii = "".join(
|
filename_ascii = "".join(
|
||||||
|
|
@ -249,7 +230,6 @@ def _finalize_download(file_id: str, info: dict):
|
||||||
async def download_video(req: DownloadRequest):
|
async def download_video(req: DownloadRequest):
|
||||||
file_id = req.file_id or str(uuid.uuid4())
|
file_id = req.file_id or str(uuid.uuid4())
|
||||||
output_template = str(DOWNLOAD_DIR / f"{file_id}.%(ext)s")
|
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] = {
|
_progress[file_id] = {
|
||||||
"status": "starting",
|
"status": "starting",
|
||||||
|
|
@ -257,11 +237,10 @@ async def download_video(req: DownloadRequest):
|
||||||
"eta": None,
|
"eta": None,
|
||||||
"speed": None,
|
"speed": None,
|
||||||
"url": req.url,
|
"url": req.url,
|
||||||
"fmt": fmt,
|
|
||||||
"output_template": output_template,
|
"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
|
_tasks[file_id] = task
|
||||||
|
|
||||||
def _done_callback(t: asyncio.Task):
|
def _done_callback(t: asyncio.Task):
|
||||||
|
|
@ -271,10 +250,10 @@ async def download_video(req: DownloadRequest):
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
if file_id in _progress:
|
if file_id in _progress:
|
||||||
_progress[file_id]["status"] = "cancelled"
|
_progress[file_id]["status"] = "cancelled"
|
||||||
except Exception as e:
|
except Exception:
|
||||||
if file_id in _progress:
|
if file_id in _progress:
|
||||||
_progress[file_id]["status"] = "error"
|
_progress[file_id]["status"] = "error"
|
||||||
_progress[file_id]["error"] = str(e)
|
_progress[file_id]["error"] = GENERIC_ERROR
|
||||||
finally:
|
finally:
|
||||||
_tasks.pop(file_id, None)
|
_tasks.pop(file_id, None)
|
||||||
|
|
||||||
|
|
@ -303,11 +282,11 @@ async def cancel_download(file_id: str):
|
||||||
async def serve_file(file_id: str):
|
async def serve_file(file_id: str):
|
||||||
meta = _progress.get(file_id)
|
meta = _progress.get(file_id)
|
||||||
if not meta or meta.get("status") != "ready":
|
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"])
|
filepath = Path(meta["filepath"])
|
||||||
if not filepath.exists():
|
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_ascii = meta["filename_ascii"]
|
||||||
filename_encoded = meta["filename_encoded"]
|
filename_encoded = meta["filename_encoded"]
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,19 @@ const thumbnail = document.getElementById('thumbnail');
|
||||||
const videoTitle = document.getElementById('video-title');
|
const videoTitle = document.getElementById('video-title');
|
||||||
const uploaderEl = document.getElementById('uploader');
|
const uploaderEl = document.getElementById('uploader');
|
||||||
const durationEl = document.getElementById('duration');
|
const durationEl = document.getElementById('duration');
|
||||||
const formatSection = document.getElementById('format-section');
|
|
||||||
const formatSelect = document.getElementById('format-select');
|
|
||||||
const downloadBtn = document.getElementById('download-btn');
|
const downloadBtn = document.getElementById('download-btn');
|
||||||
const progressWrap = document.getElementById('progress-wrap');
|
const progressWrap = document.getElementById('progress-wrap');
|
||||||
const progressFill = document.getElementById('progress-fill');
|
const progressFill = document.getElementById('progress-fill');
|
||||||
const progressLabel = document.getElementById('progress-label');
|
const progressLabel = document.getElementById('progress-label');
|
||||||
const cancelBtn = document.getElementById('cancel-btn');
|
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 currentFileId = null;
|
||||||
let currentAbort = null;
|
let currentAbort = null;
|
||||||
|
|
@ -50,7 +56,6 @@ function resetUI() {
|
||||||
clearBtn.style.display = 'none';
|
clearBtn.style.display = 'none';
|
||||||
errorMsg.style.display = 'none';
|
errorMsg.style.display = 'none';
|
||||||
preview.style.display = 'none';
|
preview.style.display = 'none';
|
||||||
formatSection.style.display = 'none';
|
|
||||||
downloadBtn.style.display = 'none';
|
downloadBtn.style.display = 'none';
|
||||||
progressWrap.style.display = 'none';
|
progressWrap.style.display = 'none';
|
||||||
thumbnail.src = '';
|
thumbnail.src = '';
|
||||||
|
|
@ -80,7 +85,7 @@ function setLoading(btn, loading, text) {
|
||||||
btn.textContent = text;
|
btn.textContent = text;
|
||||||
} else if (!loading && btn.dataset.label) {
|
} else if (!loading && btn.dataset.label) {
|
||||||
if (btn.id === 'download-btn') {
|
if (btn.id === 'download-btn') {
|
||||||
btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Download`;
|
btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Télécharger`;
|
||||||
} else {
|
} else {
|
||||||
btn.textContent = btn.dataset.label;
|
btn.textContent = btn.dataset.label;
|
||||||
}
|
}
|
||||||
|
|
@ -106,32 +111,6 @@ function formatCount(n) {
|
||||||
return n.toString();
|
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) {
|
function setProgress(pct, label) {
|
||||||
progressFill.style.width = pct + '%';
|
progressFill.style.width = pct + '%';
|
||||||
progressLabel.innerHTML = label;
|
progressLabel.innerHTML = label;
|
||||||
|
|
@ -139,11 +118,11 @@ function setProgress(pct, label) {
|
||||||
|
|
||||||
function formatEta(secs) {
|
function formatEta(secs) {
|
||||||
if (secs == null || secs < 0) return '';
|
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 m = Math.floor(secs / 60);
|
||||||
const s = secs % 60;
|
const s = secs % 60;
|
||||||
return `ETA ${m}m ${s}s`;
|
return `Reste ${m}m ${s}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
urlInput.addEventListener('input', () => {
|
urlInput.addEventListener('input', () => {
|
||||||
|
|
@ -163,16 +142,15 @@ fetchBtn.addEventListener('click', async () => {
|
||||||
const url = urlInput.value.trim();
|
const url = urlInput.value.trim();
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showError('Please paste a video URL first.');
|
showError('Veuillez d\'abord coller une URL de vidéo.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
hideError();
|
hideError();
|
||||||
preview.style.display = 'none';
|
preview.style.display = 'none';
|
||||||
formatSection.style.display = 'none';
|
|
||||||
downloadBtn.style.display = 'none';
|
downloadBtn.style.display = 'none';
|
||||||
progressWrap.style.display = 'none';
|
progressWrap.style.display = 'none';
|
||||||
setLoading(fetchBtn, true, 'Fetching…');
|
setLoading(fetchBtn, true, 'Recherche…');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/info`, {
|
const res = await fetch(`${API}/info`, {
|
||||||
|
|
@ -184,16 +162,14 @@ fetchBtn.addEventListener('click', async () => {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
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);
|
renderPreview(data);
|
||||||
renderFormats(data.formats || []);
|
|
||||||
preview.style.display = 'flex';
|
preview.style.display = 'flex';
|
||||||
formatSection.style.display = 'flex';
|
|
||||||
downloadBtn.style.display = 'flex';
|
downloadBtn.style.display = 'flex';
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(fetchBtn, false);
|
setLoading(fetchBtn, false);
|
||||||
}
|
}
|
||||||
|
|
@ -201,10 +177,10 @@ fetchBtn.addEventListener('click', async () => {
|
||||||
|
|
||||||
function renderPreview(info) {
|
function renderPreview(info) {
|
||||||
thumbnail.src = info.thumbnail || '';
|
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';
|
thumbnail.style.display = info.thumbnail ? 'block' : 'none';
|
||||||
|
|
||||||
videoTitle.textContent = info.title || 'Unknown title';
|
videoTitle.textContent = info.title || 'Titre inconnu';
|
||||||
|
|
||||||
uploaderEl.textContent = info.uploader || '';
|
uploaderEl.textContent = info.uploader || '';
|
||||||
uploaderEl.style.display = info.uploader ? '' : 'none';
|
uploaderEl.style.display = info.uploader ? '' : 'none';
|
||||||
|
|
@ -227,7 +203,7 @@ function renderPreview(info) {
|
||||||
const d = info.upload_date;
|
const d = info.upload_date;
|
||||||
const formatted = new Date(
|
const formatted = new Date(
|
||||||
`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`
|
`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`
|
||||||
).toLocaleDateString(undefined, {
|
).toLocaleDateString('fr-FR', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
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 () => {
|
downloadBtn.addEventListener('click', async () => {
|
||||||
const url = urlInput.value.trim();
|
const url = urlInput.value.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
hideError();
|
hideError();
|
||||||
setLoading(downloadBtn, true, 'Downloading…');
|
setLoading(downloadBtn, true, 'Téléchargement…');
|
||||||
progressWrap.style.display = 'block';
|
progressWrap.style.display = 'block';
|
||||||
setProgress(0, '<span class="meta">Starting…</span>');
|
setProgress(0, '<span class="meta">Démarrage…</span>');
|
||||||
|
|
||||||
const fileId = crypto.randomUUID();
|
const fileId = crypto.randomUUID();
|
||||||
currentFileId = fileId;
|
currentFileId = fileId;
|
||||||
|
|
@ -316,7 +235,7 @@ downloadBtn.addEventListener('click', async () => {
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
|
|
||||||
if (d.status === 'starting') {
|
if (d.status === 'starting') {
|
||||||
setProgress(0, '<span class="meta">Starting…</span>');
|
setProgress(0, '<span class="meta">Démarrage…</span>');
|
||||||
} else if (d.status === 'downloading') {
|
} else if (d.status === 'downloading') {
|
||||||
const pct = d.percent || 0;
|
const pct = d.percent || 0;
|
||||||
const eta = d.eta != null ? formatEta(d.eta) : '';
|
const eta = d.eta != null ? formatEta(d.eta) : '';
|
||||||
|
|
@ -330,7 +249,7 @@ downloadBtn.addEventListener('click', async () => {
|
||||||
} else if (d.status === 'finished') {
|
} else if (d.status === 'finished') {
|
||||||
setProgress(
|
setProgress(
|
||||||
100,
|
100,
|
||||||
'<span class="pct">100%</span> <span class="meta">Processing…</span>'
|
'<span class="pct">100%</span> <span class="meta">Traitement…</span>'
|
||||||
);
|
);
|
||||||
} else if (d.status === 'ready') {
|
} else if (d.status === 'ready') {
|
||||||
sse.close();
|
sse.close();
|
||||||
|
|
@ -340,7 +259,7 @@ downloadBtn.addEventListener('click', async () => {
|
||||||
if (currentFileId === fileId) {
|
if (currentFileId === fileId) {
|
||||||
setProgress(
|
setProgress(
|
||||||
100,
|
100,
|
||||||
'<span class="pct">✓</span> <span class="meta">Done — saving to your downloads</span>'
|
'<span class="pct">✓</span> <span class="meta">Terminé — enregistrement dans vos téléchargements</span>'
|
||||||
);
|
);
|
||||||
|
|
||||||
window.location.href = `${API}/file/${fileId}`;
|
window.location.href = `${API}/file/${fileId}`;
|
||||||
|
|
@ -363,7 +282,7 @@ downloadBtn.addEventListener('click', async () => {
|
||||||
sse.close();
|
sse.close();
|
||||||
currentSse = null;
|
currentSse = null;
|
||||||
currentAbort = null;
|
currentAbort = null;
|
||||||
showError(d.error || 'Download failed.');
|
showError(d.error || 'Échec du téléchargement.');
|
||||||
progressWrap.style.display = 'none';
|
progressWrap.style.display = 'none';
|
||||||
currentFileId = null;
|
currentFileId = null;
|
||||||
setLoading(downloadBtn, false);
|
setLoading(downloadBtn, false);
|
||||||
|
|
@ -384,7 +303,6 @@ downloadBtn.addEventListener('click', async () => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url,
|
url,
|
||||||
format_id: formatSelect.value,
|
|
||||||
file_id: fileId,
|
file_id: fileId,
|
||||||
}),
|
}),
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
|
|
@ -393,11 +311,11 @@ downloadBtn.addEventListener('click', async () => {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.detail || 'Download failed.');
|
throw new Error(data.detail || 'Échec du téléchargement.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.status !== 'started') {
|
if (data.status !== 'started') {
|
||||||
throw new Error('Unexpected server response.');
|
throw new Error('Réponse inattendue du serveur.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (currentSse) {
|
if (currentSse) {
|
||||||
|
|
@ -408,7 +326,7 @@ downloadBtn.addEventListener('click', async () => {
|
||||||
currentAbort = null;
|
currentAbort = null;
|
||||||
|
|
||||||
if (err.name !== 'AbortError') {
|
if (err.name !== 'AbortError') {
|
||||||
showError(err.message || 'Download failed.');
|
showError(err.message || 'Échec du téléchargement.');
|
||||||
progressWrap.style.display = 'none';
|
progressWrap.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>yt-dlp Web</title>
|
<title>YouTube MP3</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect width='36' height='36' rx='10' fill='%236366f1'/><path d='M11 12l14 6-14 6V12z' fill='white'/></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36'><rect width='36' height='36' rx='10' fill='%236366f1'/><path d='M11 12l14 6-14 6V12z' fill='white'/></svg>" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const saved = localStorage.getItem('theme');
|
||||||
|
const theme = saved
|
||||||
|
|| (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="bg-orbs">
|
<div class="bg-orbs">
|
||||||
|
|
@ -19,24 +27,20 @@
|
||||||
|
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<header>
|
<header>
|
||||||
<div class="logo">
|
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="Changer de thème" title="Changer de thème">
|
||||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
|
<svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
<rect width="36" height="36" rx="10" fill="url(#grad)"/>
|
<circle cx="12" cy="12" r="4"/>
|
||||||
<path d="M11 12l14 6-14 6V12z" fill="white"/>
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
|
||||||
<defs>
|
|
||||||
<linearGradient id="grad" x1="0" y1="0" x2="36" y2="36" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#6366f1"/>
|
|
||||||
<stop offset="1" stop-color="#8b5cf6"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span>yt-dlp web</span>
|
<svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
</div>
|
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>Download any video,<br /><span class="gradient-text">instantly.</span></h1>
|
<h1>Téléchargez n'importe quel morceau en<br /><span class="gradient-text">MP3.</span></h1>
|
||||||
<p class="subtitle">Paste a YouTube (or any supported) link below and grab your video.</p>
|
<p class="subtitle">Collez un lien YouTube ci-dessous et récupérez l'audio en MP3.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="card" id="main-card">
|
<div class="card" id="main-card">
|
||||||
|
|
@ -52,16 +56,16 @@
|
||||||
placeholder="https://www.youtube.com/watch?v=..."
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
aria-label="Video URL"
|
aria-label="URL de la vidéo"
|
||||||
/>
|
/>
|
||||||
<button class="clear-btn" id="clear-btn" aria-label="Clear input">
|
<button class="clear-btn" id="clear-btn" aria-label="Effacer">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary" id="fetch-btn" aria-label="Fetch video info">
|
<button class="btn-primary" id="fetch-btn" aria-label="Récupérer les infos de la vidéo">
|
||||||
Fetch
|
Rechercher
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -69,7 +73,7 @@
|
||||||
|
|
||||||
<!-- Video preview -->
|
<!-- Video preview -->
|
||||||
<div id="preview" class="preview" hidden>
|
<div id="preview" class="preview" hidden>
|
||||||
<img id="thumbnail" class="thumbnail" alt="Video thumbnail" />
|
<img id="thumbnail" class="thumbnail" alt="Miniature de la vidéo" />
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<h2 id="video-title" class="video-title"></h2>
|
<h2 id="video-title" class="video-title"></h2>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
|
|
@ -82,25 +86,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Format selector -->
|
|
||||||
<div id="format-section" class="format-section" hidden>
|
|
||||||
<label for="format-select" class="format-label">Format</label>
|
|
||||||
<div class="select-wrap">
|
|
||||||
<select id="format-select" aria-label="Select download format"></select>
|
|
||||||
<svg class="select-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
||||||
<polyline points="6 9 12 15 18 9"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Download button -->
|
<!-- Download button -->
|
||||||
<button class="btn-download" id="download-btn" hidden aria-label="Download video">
|
<button class="btn-download" id="download-btn" hidden aria-label="Télécharger le MP3">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
||||||
<polyline points="7 10 12 15 17 10"/>
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
</svg>
|
</svg>
|
||||||
Download
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Progress -->
|
<!-- Progress -->
|
||||||
|
|
@ -110,17 +103,18 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-row">
|
<div class="progress-row">
|
||||||
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
|
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
|
||||||
<button id="cancel-btn" type="button" class="progress-btn cancel" aria-label="Cancel download">
|
<button id="cancel-btn" type="button" class="progress-btn cancel" aria-label="Annuler le téléchargement">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
Cancel
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>Powered by <a href="https://github.com/yt-dlp/yt-dlp" target="_blank" rel="noopener">yt-dlp</a></p>
|
<p>Propulsé par <a href="https://github.com/yt-dlp/yt-dlp" target="_blank" rel="noopener">yt-dlp</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -130,6 +124,6 @@
|
||||||
if (input) input.value = '';
|
if (input) input.value = '';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/app.js?v=4"></script>
|
<script src="/static/app.js?v=7"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@
|
||||||
--border-focus: rgba(99,102,241,0.6);
|
--border-focus: rgba(99,102,241,0.6);
|
||||||
--text: #f1f1f5;
|
--text: #f1f1f5;
|
||||||
--text-muted: #8b8b9e;
|
--text-muted: #8b8b9e;
|
||||||
|
--input-bg: rgba(255,255,255,0.05);
|
||||||
|
--tag-bg: rgba(255,255,255,0.07);
|
||||||
|
--track: rgba(255,255,255,0.07);
|
||||||
|
--card-shadow: none;
|
||||||
|
--orb-opacity: 0.18;
|
||||||
--primary: #6366f1;
|
--primary: #6366f1;
|
||||||
--primary-hover: #4f52e0;
|
--primary-hover: #4f52e0;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
|
|
@ -16,6 +21,20 @@
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--bg: #f4f5fb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-hover: rgba(0,0,0,0.04);
|
||||||
|
--border: rgba(0,0,0,0.09);
|
||||||
|
--text: #16161f;
|
||||||
|
--text-muted: #5c5c72;
|
||||||
|
--input-bg: #ffffff;
|
||||||
|
--tag-bg: rgba(0,0,0,0.05);
|
||||||
|
--track: rgba(0,0,0,0.09);
|
||||||
|
--card-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||||
|
--orb-opacity: 0.12;
|
||||||
|
}
|
||||||
|
|
||||||
html { font-size: 16px; }
|
html { font-size: 16px; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -27,6 +46,7 @@ body {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
transition: background 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ambient background orbs */
|
/* Ambient background orbs */
|
||||||
|
|
@ -35,7 +55,7 @@ body {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
filter: blur(80px);
|
filter: blur(80px);
|
||||||
opacity: 0.18;
|
opacity: var(--orb-opacity);
|
||||||
}
|
}
|
||||||
.orb-1 { width: 500px; height: 500px; background: #6366f1; top: -150px; left: -100px; }
|
.orb-1 { width: 500px; height: 500px; background: #6366f1; top: -150px; left: -100px; }
|
||||||
.orb-2 { width: 400px; height: 400px; background: #8b5cf6; bottom: -100px; right: -80px; }
|
.orb-2 { width: 400px; height: 400px; background: #8b5cf6; bottom: -100px; right: -80px; }
|
||||||
|
|
@ -53,7 +73,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
header { display: flex; justify-content: center; }
|
header { display: flex; justify-content: center; position: relative; }
|
||||||
.logo {
|
.logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -63,6 +83,28 @@ header { display: flex; justify-content: center; }
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { color: var(--text); border-color: var(--border-focus); }
|
||||||
|
.theme-toggle svg { display: block; }
|
||||||
|
.theme-toggle .icon-moon { display: none; }
|
||||||
|
:root[data-theme="light"] .theme-toggle .icon-sun { display: none; }
|
||||||
|
:root[data-theme="light"] .theme-toggle .icon-moon { display: block; }
|
||||||
|
|
||||||
/* Hero */
|
/* Hero */
|
||||||
.hero { text-align: center; }
|
.hero { text-align: center; }
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
|
|
@ -95,6 +137,7 @@ header { display: flex; justify-content: center; }
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input row */
|
/* Input row */
|
||||||
|
|
@ -114,7 +157,7 @@ header { display: flex; justify-content: center; }
|
||||||
}
|
}
|
||||||
#url-input {
|
#url-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: rgba(255,255,255,0.05);
|
background: var(--input-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|
@ -237,7 +280,7 @@ header { display: flex; justify-content: center; }
|
||||||
}
|
}
|
||||||
.meta-row { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
.meta-row { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
|
||||||
.tag {
|
.tag {
|
||||||
background: rgba(255,255,255,0.07);
|
background: var(--tag-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
|
|
@ -245,39 +288,6 @@ header { display: flex; justify-content: center; }
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Format selector */
|
|
||||||
.format-section { display: flex; flex-direction: column; gap: 10px; }
|
|
||||||
.format-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
||||||
.select-wrap { position: relative; }
|
|
||||||
.select-wrap select {
|
|
||||||
width: 100%;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding: 11px 40px 11px 14px;
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
.select-wrap select:focus {
|
|
||||||
border-color: var(--border-focus);
|
|
||||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
|
|
||||||
}
|
|
||||||
.select-wrap select option { background: #1a1a2e; color: var(--text); }
|
|
||||||
.select-arrow {
|
|
||||||
position: absolute;
|
|
||||||
right: 12px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: var(--text-muted);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress */
|
/* Progress */
|
||||||
.progress-wrap {
|
.progress-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -303,7 +313,7 @@ header { display: flex; justify-content: center; }
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: rgba(255,255,255,0.07);
|
background: var(--track);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
@ -330,7 +340,7 @@ header { display: flex; justify-content: center; }
|
||||||
}
|
}
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: rgba(255,255,255,0.07);
|
background: var(--track);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue