diff --git a/README.md b/README.md index adaeb20e3..6813d2b50 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,8 @@ A beautiful, self-hosted web application for downloading YouTube (and 1000+ othe ### Pull and run from Docker Hub ```bash -<<<<<<< HEAD docker pull samive/yt-dlp-web:latest docker run -d -p 8000:8000 --name yt-dlp-web samive/yt-dlp-web:latest -======= -docker pull YOUR_DOCKERHUB_USERNAME/yt-dlp-web:latest -docker run -d -p 8000:8000 --name yt-dlp-web YOUR_DOCKERHUB_USERNAME/yt-dlp-web:latest ->>>>>>> 831b5f7dab502c2631355cc9a518dcd5d832690b ``` Then open **http://localhost:8000** in your browser. @@ -43,11 +38,7 @@ docker run -d \ -p 8000:8000 \ -v $(pwd)/downloads:/app/web/backend/downloads \ --name yt-dlp-web \ -<<<<<<< HEAD samive/yt-dlp-web:latest -======= - YOUR_DOCKERHUB_USERNAME/yt-dlp-web:latest ->>>>>>> 831b5f7dab502c2631355cc9a518dcd5d832690b ``` ### Stop / remove the container @@ -68,7 +59,7 @@ docker rm yt-dlp-web ### Build the image ```bash -git clone https://github.com/YOUR_GITHUB_USERNAME/yt-dlp-web.git +git clone https://github.com/samive/yt-dlp-web.git cd yt-dlp-web docker build -t yt-dlp-web . ``` @@ -93,11 +84,8 @@ Open **http://localhost:8000**. ### Setup ```bash -# Install yt-dlp from source -pip install -e ".[default]" - -# Install backend dependencies -pip install fastapi "uvicorn[standard]" httpx +# Install dependencies +pip install yt-dlp fastapi "uvicorn[standard]" httpx # Start the server python -m uvicorn web.backend.main:app --reload --port 8000 @@ -109,7 +97,7 @@ Open **http://localhost:8000**. ## 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 **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 @@ -124,73 +112,13 @@ docker run -d \ -p 8000:8000 \ -v $(pwd)/cookies.txt:/app/web/backend/cookies.txt:ro \ --name yt-dlp-web \ -<<<<<<< HEAD samive/yt-dlp-web:latest -======= - YOUR_DOCKERHUB_USERNAME/yt-dlp-web:latest ->>>>>>> 831b5f7dab502c2631355cc9a518dcd5d832690b ``` > **Note:** Using cookies with Chrome on Windows may cause issues due to DPAPI encryption. Firefox or Edge cookies work more reliably. --- -## Publish to Docker Hub - -### 1. Create a Docker Hub account - -Sign up at [hub.docker.com](https://hub.docker.com) and create a public repository named `yt-dlp-web`. - -### 2. Log in from terminal - -```bash -docker login -``` - -### 3. Build and tag the image - -```bash -<<<<<<< HEAD -docker build -t samive/yt-dlp-web:latest . -======= -docker build -t YOUR_DOCKERHUB_USERNAME/yt-dlp-web:latest . ->>>>>>> 831b5f7dab502c2631355cc9a518dcd5d832690b -``` - -### 4. Push to Docker Hub - -```bash -<<<<<<< HEAD -docker push samive/yt-dlp-web:latest -======= -docker push YOUR_DOCKERHUB_USERNAME/yt-dlp-web:latest ->>>>>>> 831b5f7dab502c2631355cc9a518dcd5d832690b -``` - -### 5. (Optional) Tag a version - -```bash -<<<<<<< HEAD -docker tag samive/yt-dlp-web:latest samive/yt-dlp-web:1.0.0 -docker push samive/yt-dlp-web:1.0.0 -======= -docker tag YOUR_DOCKERHUB_USERNAME/yt-dlp-web:latest YOUR_DOCKERHUB_USERNAME/yt-dlp-web:1.0.0 -docker push YOUR_DOCKERHUB_USERNAME/yt-dlp-web:1.0.0 ->>>>>>> 831b5f7dab502c2631355cc9a518dcd5d832690b -``` - -Anyone can now pull and run your image with: - -```bash -<<<<<<< HEAD -docker pull samive/yt-dlp-web:latest -======= -docker pull YOUR_DOCKERHUB_USERNAME/yt-dlp-web:latest ->>>>>>> 831b5f7dab502c2631355cc9a518dcd5d832690b -``` - ---- - ## Tech Stack - **Backend** — Python, [FastAPI](https://fastapi.tiangolo.com/), [yt-dlp](https://github.com/yt-dlp/yt-dlp) diff --git a/web/backend/main.py b/web/backend/main.py index 342b1a8dc..e69de29bb 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -1,296 +0,0 @@ -<<<<<<< HEAD -======= -import sys ->>>>>>> 831b5f7dab502c2631355cc9a518dcd5d832690b -import json -import uuid -import asyncio -import httpx -import urllib.parse -from pathlib import Path -from fastapi import FastAPI, HTTPException, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, Response, JSONResponse, FileResponse -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel - -<<<<<<< HEAD -======= -sys.path.insert(0, str(Path(__file__).resolve().parents[2])) ->>>>>>> 831b5f7dab502c2631355cc9a518dcd5d832690b -import yt_dlp - -app = FastAPI() - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - -@app.exception_handler(Exception) -async def global_exception_handler(request: Request, exc: Exception): - return JSONResponse(status_code=500, content={"detail": str(exc)}) - -DOWNLOAD_DIR = Path(__file__).parent / "downloads" -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 -> { status, percent, eta, speed, filepath, ... } -_progress: dict[str, dict] = {} -# file_id -> asyncio.Task -_tasks: dict[str, asyncio.Task] = {} - - -class InfoRequest(BaseModel): - url: str - -class DownloadRequest(BaseModel): - url: str - preset: str = "best" - format_id: str | None = None - file_id: str | None = None - - -@app.get("/") -async def index(): - return FileResponse(FRONTEND_DIR / "index.html") - - -@app.post("/info") -async def get_info(req: InfoRequest): - ydl_opts = {"quiet": True, "no_warnings": True, "skip_download": True} - info = None - try: - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = await asyncio.to_thread(ydl.extract_info, req.url, download=False) - except Exception: - pass - if not info: - 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)) - if not info: - raise HTTPException(status_code=400, detail="Could not extract video info.") - - thumbnails = info.get("thumbnails") or [] - thumbnail_url = None - if thumbnails: - ranked = sorted( - (t for t in thumbnails if t.get("url")), - key=lambda t: (t.get("width") or 0) * (t.get("height") or 0), - reverse=True, - ) - thumbnail_url = ranked[0]["url"] if ranked else None - 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, - "duration": info.get("duration"), - "uploader": info.get("uploader"), - "view_count": info.get("view_count"), - "like_count": info.get("like_count"), - "upload_date": info.get("upload_date"), - "formats": formats, - } - - -@app.get("/thumbnail") -async def proxy_thumbnail(url: str): - try: - async with httpx.AsyncClient(follow_redirects=True, timeout=10) as client: - r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}) - return Response(content=r.content, media_type=r.headers.get("content-type", "image/jpeg")) - except Exception: - raise HTTPException(status_code=502, detail="Could not fetch thumbnail") - - -async def _run_download(file_id: str, url: str, fmt: str, output_template: str): - """Runs yt-dlp in a thread and updates _progress. Cancellable via task cancellation.""" - cancelled = asyncio.Event() - - def progress_hook(d): - if cancelled.is_set(): - raise yt_dlp.utils.DownloadError("Cancelled") - status = d.get("status") - if status == "downloading": - downloaded = d.get("downloaded_bytes", 0) - total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0 - pct = round((downloaded / total) * 100, 1) if total else 0 - eta = d.get("eta") - speed = d.get("speed") - _progress[file_id].update({ - "status": "downloading", - "percent": pct, - "eta": int(eta) if eta else None, - "speed": round(speed / 1024 / 1024, 2) if speed else None, - }) - elif status == "finished": - _progress[file_id].update({"status": "finished", "percent": 100, "eta": 0}) - - ydl_opts = { - "quiet": True, - "no_warnings": True, - "format": fmt, - "outtmpl": output_template, - "merge_output_format": "mp4", - "progress_hooks": [progress_hook], - } - - try: - with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = await asyncio.to_thread(ydl.extract_info, url, download=True) - return info - except asyncio.CancelledError: - cancelled.set() - _progress[file_id]["status"] = "cancelled" - raise - except Exception as e: - _progress[file_id]["status"] = "error" - _progress[file_id]["error"] = str(e) - raise - - -@app.post("/download") -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", "percent": 0, "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)) - _tasks[file_id] = task - - try: - info = await task - except asyncio.CancelledError: - return JSONResponse({"file_id": file_id, "status": "cancelled"}) - except Exception as e: - _tasks.pop(file_id, None) - raise HTTPException(status_code=400, detail=_progress.get(file_id, {}).get("error", str(e))) - - _tasks.pop(file_id, None) - - matches = list(DOWNLOAD_DIR.glob(f"{file_id}.*")) - if not matches: - raise HTTPException(status_code=500, detail="Download failed: file not found") - - filepath = matches[0] - title = info.get("title") or file_id - filename_ascii = "".join(c for c in title if c.isascii() and (c.isalnum() or c in " ._-()")).strip() - if not filename_ascii: - filename_ascii = file_id - filename_ascii += filepath.suffix - filename_encoded = urllib.parse.quote(title + filepath.suffix) - - _progress[file_id].update({ - "status": "ready", - "percent": 100, - "filepath": str(filepath), - "filename_ascii": filename_ascii, - "filename_encoded": filename_encoded, - }) - - return JSONResponse({"file_id": file_id, "status": "ready"}) - - -@app.post("/cancel/{file_id}") -async def cancel_download(file_id: str): - task = _tasks.get(file_id) - if task and not task.done(): - task.cancel() - # Clean up partial files - for f in DOWNLOAD_DIR.glob(f"{file_id}*"): - f.unlink(missing_ok=True) - _progress[file_id] = {"status": "cancelled", "percent": 0} - return JSONResponse({"status": "cancelled"}) - return JSONResponse({"status": "not_found"}) - - -@app.get("/file/{file_id}") -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 or already downloaded") - - filepath = Path(meta["filepath"]) - if not filepath.exists(): - raise HTTPException(status_code=404, detail="File not found") - - filename_ascii = meta["filename_ascii"] - filename_encoded = meta["filename_encoded"] - - def iterfile(): - with open(filepath, "rb") as f: - yield from f - filepath.unlink(missing_ok=True) - _progress.pop(file_id, None) - - return StreamingResponse( - iterfile(), - media_type="video/mp4", - headers={ - "Content-Disposition": f"attachment; filename=\"{filename_ascii}\"; filename*=UTF-8''{filename_encoded}", - }, - ) - - -@app.get("/progress/{file_id}") -async def progress_stream(file_id: str, request: Request): - async def event_generator(): - while True: - if await request.is_disconnected(): - break - data = _progress.get(file_id) - if data: - yield f"data: {json.dumps(data)}\n\n" - if data.get("status") in ("ready", "cancelled", "error"): - break - await asyncio.sleep(0.5) - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) - - -app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")