From a36973ae12d655ed671014f698eded9b6c75acd0 Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Sat, 10 Jan 2026 13:21:36 +0100 Subject: [PATCH] Primo Caricamento --- .env | 3 + README.md | 60 ++++++++++++++ api/Dockerfile | 19 +++++ api/app.py | 185 +++++++++++++++++++++++++++++++++++++++++++ api/requirements.txt | 4 + docker-compose.yml | 28 +++++++ frontend/index.html | 68 ++++++++++++++++ frontend/main.js | 118 +++++++++++++++++++++++++++ frontend/style.css | 118 +++++++++++++++++++++++++++ nginx/default.conf | 27 +++++++ 10 files changed, 630 insertions(+) create mode 100644 .env create mode 100644 README.md create mode 100644 api/Dockerfile create mode 100644 api/app.py create mode 100644 api/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/main.js create mode 100644 frontend/style.css create mode 100644 nginx/default.conf diff --git a/.env b/.env new file mode 100644 index 0000000..4934d3f --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +WEB_PORT=8080 +API_PORT=5000 +MAX_DURATION_SECONDS=5400 diff --git a/README.md b/README.md new file mode 100644 index 0000000..77af704 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# YouTube Downloader (Flask + nginx) + +Servizio minimale per scaricare video o solo audio da YouTube tramite interfaccia web. Basato su Flask + yt-dlp dietro nginx, con download disponibili tramite browser. + +## Stack + +- Python 3.11, Flask, gunicorn +- yt-dlp + ffmpeg per download e conversione +- nginx per servire la UI statica e fare proxy verso l'API +- Docker Compose per orchestrare servizi e volume condiviso dei file + +## Prerequisiti + +- Docker + Docker Compose + +## Configurazione ambiente + +Variabili in `.env` (già fornito un esempio): + +| Variabile | Default | Descrizione | +| --- | --- | --- | +| `WEB_PORT` | 8080 | Porta esposta pubblicamente da nginx (UI + API) | +| `API_PORT` | 5000 | Porta interna dell'API Flask (dietro nginx) | +| `MAX_DURATION_SECONDS` | 5400 | Durata massima del video in secondi (default 1h30) | + +## Avvio rapido + +```bash +docker compose up --build +``` + +Poi apri: [http://localhost:8080](http://localhost:8080) (o la `WEB_PORT` impostata). + +## Uso + +1. Inserisci un link YouTube. +2. Premi "Carica formati" per vedere risoluzioni/video e formati audio disponibili (yt-dlp). +3. Scegli video o solo audio, risoluzione/formato desiderato e avvia "Scarica". +4. Il file generato è servito da nginx in `/downloads/` e il link è mostrato nell'interfaccia. + +## API (dietro nginx) + +- `POST /api/info` body: `{ "url": "..." }` → restituisce formati video/audio e durata (rispetta limite `MAX_DURATION_SECONDS`). +- `POST /api/download` body: `{ url, mode: "video"|"audio", format_id?, audio_ext? }` → scarica e restituisce `{ file, url }` con link al file. +- `GET /api/health` → stato servizio. + +## Volumi e file + +- I file scaricati vivono nel volume `downloads` montato su `/downloads` in entrambi i container. Rimangono finché non si rimuove il volume (`docker compose down -v`). + +## Note e limiti + +- Il limite durata è bloccante: se il video supera `MAX_DURATION_SECONDS`, l'API risponde 400. +- Non è implementato un sistema di queue o rate limiting: pensato per uso singolo/privato. +- yt-dlp segue le policy di YouTube; l'uso è responsabilità dell'utente. + +## Pulizia + +- Fermare: `docker compose down` +- Fermare e rimuovere volume download: `docker compose down -v` diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..ebd6405 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +CMD ["gunicorn", "-b", "0.0.0.0:${API_PORT}", "app:app"] diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..822e949 --- /dev/null +++ b/api/app.py @@ -0,0 +1,185 @@ +import os +from pathlib import Path +from typing import Dict, List, Optional + +from dotenv import load_dotenv +from flask import Flask, jsonify, request +from yt_dlp import YoutubeDL + +load_dotenv() + +app = Flask(__name__) + +DOWNLOAD_DIR = Path(os.getenv("DOWNLOAD_DIR", "/downloads")).resolve() +MAX_DURATION_SECONDS = int(os.getenv("MAX_DURATION_SECONDS", "5400")) + +DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True) + + +def _format_size(bytes_count: Optional[int]) -> Optional[str]: + if bytes_count is None: + return None + for unit in ["B", "KB", "MB", "GB"]: + if bytes_count < 1024 or unit == "GB": + return f"{bytes_count:.1f} {unit}" + bytes_count /= 1024 + return None + + +def _extract_info(url: str) -> Dict: + ydl_opts = { + "quiet": True, + "skip_download": True, + "noprogress": True, + } + with YoutubeDL(ydl_opts) as ydl: + return ydl.extract_info(url, download=False) + + +def _filter_video_formats(formats: List[Dict]) -> List[Dict]: + videos = [] + for f in formats: + if f.get("vcodec") == "none": + continue + resolution = None + if f.get("height") and f.get("width"): + resolution = f"{f['height']}p" + videos.append( + { + "id": f.get("format_id"), + "note": f.get("format_note"), + "ext": f.get("ext"), + "resolution": resolution, + "fps": f.get("fps"), + "size": _format_size(f.get("filesize")), + } + ) + return videos + + +def _filter_audio_formats(formats: List[Dict]) -> List[Dict]: + audios = [] + for f in formats: + if f.get("acodec") == "none": + continue + audios.append( + { + "id": f.get("format_id"), + "ext": f.get("ext"), + "abr": f.get("abr"), + "size": _format_size(f.get("filesize")), + } + ) + return audios + + +def _ensure_duration_allowed(info: Dict): + duration = info.get("duration") + if duration and duration > MAX_DURATION_SECONDS: + raise ValueError( + f"Il video dura {duration // 60} minuti, supera il limite di {MAX_DURATION_SECONDS // 60} minuti." + ) + + +def _pick_output_path(info: Dict) -> Optional[Path]: + requested = info.get("requested_downloads") or [] + for item in requested: + filepath = item.get("filepath") + if filepath: + return Path(filepath) + filename = info.get("_filename") + if filename: + return Path(filename) + return None + + +@app.get("/api/health") +def health(): + return jsonify({"status": "ok"}) + + +@app.post("/api/info") +def info(): + data = request.get_json(force=True, silent=True) or {} + url = data.get("url") + if not url: + return jsonify({"error": "URL mancante"}), 400 + try: + info = _extract_info(url) + _ensure_duration_allowed(info) + return jsonify( + { + "title": info.get("title"), + "duration": info.get("duration"), + "thumbnails": info.get("thumbnails", []), + "video_formats": _filter_video_formats(info.get("formats", [])), + "audio_formats": _filter_audio_formats(info.get("formats", [])), + } + ) + except ValueError as ve: + return jsonify({"error": str(ve)}), 400 + except Exception as e: # noqa: BLE001 + return jsonify({"error": "Errore nel recupero informazioni", "detail": str(e)}), 500 + + +@app.post("/api/download") +def download(): + data = request.get_json(force=True, silent=True) or {} + url = data.get("url") + mode = data.get("mode", "video") + format_id = data.get("format_id") + audio_ext = data.get("audio_ext", "mp3") + + if not url: + return jsonify({"error": "URL mancante"}), 400 + if mode not in {"video", "audio"}: + return jsonify({"error": "Mode non valido"}), 400 + + try: + meta = _extract_info(url) + _ensure_duration_allowed(meta) + + ydl_opts = { + "quiet": True, + "outtmpl": str(DOWNLOAD_DIR / "%(title)s.%(ext)s"), + "noprogress": True, + "restrictfilenames": True, + "merge_output_format": "mp4", + } + + if mode == "video": + if format_id: + ydl_opts["format"] = format_id + else: + ydl_opts["format"] = "bestvideo*+bestaudio/best" + else: + if format_id: + ydl_opts["format"] = format_id + else: + ydl_opts["format"] = "bestaudio/best" + if audio_ext: + ydl_opts["postprocessors"] = [ + { + "key": "FFmpegExtractAudio", + "preferredcodec": audio_ext, + "preferredquality": "0", + } + ] + + with YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=True) + output_path = _pick_output_path(info) + + if not output_path: + raise RuntimeError("Impossibile determinare il file scaricato") + + download_url = f"/downloads/{output_path.name}" + return jsonify({"file": output_path.name, "url": download_url}) + except ValueError as ve: + return jsonify({"error": str(ve)}), 400 + except Exception as e: # noqa: BLE001 + return jsonify({"error": "Download fallito", "detail": str(e)}), 500 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.getenv("API_PORT", "5000"))) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..512b173 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.0 +gunicorn==21.2.0 +yt-dlp==2024.03.10 +python-dotenv==1.0.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4f64410 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.9' +services: + api: + build: + context: ./api + dockerfile: Dockerfile + env_file: + - .env + environment: + - DOWNLOAD_DIR=/downloads + volumes: + - downloads:/downloads + expose: + - ${API_PORT:-5000} + + nginx: + image: nginx:stable-alpine + depends_on: + - api + ports: + - "${WEB_PORT:-8080}:80" + volumes: + - ./frontend:/usr/share/nginx/html:ro + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - downloads:/downloads:ro + +volumes: + downloads: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..481a514 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,68 @@ + + + + + + YT Downloader + + + + + + +
+
+

YT Downloader

+

Inserisci il link, scegli video o solo audio, poi scarica.

+
+ +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+ + + + diff --git a/frontend/main.js b/frontend/main.js new file mode 100644 index 0000000..02e4a45 --- /dev/null +++ b/frontend/main.js @@ -0,0 +1,118 @@ +const urlInput = document.getElementById('url'); +const fetchInfoBtn = document.getElementById('fetchInfo'); +const downloadBtn = document.getElementById('download'); +const statusEl = document.getElementById('status'); +const resultEl = document.getElementById('result'); +const modeSelect = document.getElementById('mode'); +const videoSelect = document.getElementById('videoFormat'); +const audioSelect = document.getElementById('audioFormat'); +const audioExt = document.getElementById('audioExt'); + +let lastFormats = { video: [], audio: [] }; + +function setStatus(text) { + statusEl.textContent = text || ''; +} + +function showResult(html) { + if (!html) { + resultEl.classList.add('hidden'); + resultEl.innerHTML = ''; + return; + } + resultEl.classList.remove('hidden'); + resultEl.innerHTML = html; +} + +function fillSelect(select, items, formatter) { + select.innerHTML = ''; + items.forEach(item => { + const opt = document.createElement('option'); + opt.value = item.id; + opt.textContent = formatter(item); + select.appendChild(opt); + }); + select.disabled = items.length === 0; +} + +async function fetchInfo() { + const url = urlInput.value.trim(); + if (!url) { + showResult('Inserisci un URL.'); + return; + } + setStatus('Recupero formati...'); + showResult(''); + try { + const res = await fetch('/api/info', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Errore'); + + lastFormats.video = data.video_formats || []; + lastFormats.audio = data.audio_formats || []; + + fillSelect(videoSelect, lastFormats.video, (f) => { + const reso = f.resolution ? `${f.resolution}` : 'Auto'; + const fps = f.fps ? `${f.fps}fps` : ''; + const size = f.size ? ` · ${f.size}` : ''; + return `${reso} ${fps}${size} (${f.ext})`; + }); + + fillSelect(audioSelect, lastFormats.audio, (f) => { + const abr = f.abr ? `${f.abr}kbps` : 'bitrate auto'; + const size = f.size ? ` · ${f.size}` : ''; + return `${f.ext} ${abr}${size}`; + }); + + audioExt.disabled = false; + + const minutes = data.duration ? Math.round(data.duration / 60) : '?'; + showResult(`Titolo: ${data.title || 'sconosciuto'}
Durata: ${minutes} min`); + setStatus('Pronto al download'); + } catch (err) { + showResult(`Errore: ${err.message}`); + setStatus(''); + } +} + +async function startDownload() { + const url = urlInput.value.trim(); + if (!url) { + showResult('Inserisci un URL.'); + return; + } + setStatus('Scarico...'); + showResult(''); + + const mode = modeSelect.value; + const payload = { + url, + mode, + format_id: mode === 'video' ? videoSelect.value : audioSelect.value, + audio_ext: mode === 'audio' ? audioExt.value : undefined, + }; + + try { + const res = await fetch('/api/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Errore'); + + const link = `${data.file}`; + showResult(`File pronto: ${link}`); + setStatus('Completo'); + } catch (err) { + showResult(`Errore: ${err.message}`); + setStatus(''); + } +} + +fetchInfoBtn.addEventListener('click', fetchInfo); +downloadBtn.addEventListener('click', startDownload); diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..aff381b --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,118 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: 'Space Grotesk', system-ui, -apple-system, sans-serif; + background: radial-gradient(circle at 20% 20%, #f4f7ff, #e8ecf7 25%, #f8fbff 50%); + color: #0d1b2a; +} + +.shell { + max-width: 880px; + margin: 48px auto; + padding: 0 20px; +} + +header h1 { + margin: 0; + font-size: 32px; +} + +header p { + margin: 6px 0 18px; + color: #4a6072; +} + +.card { + background: #fff; + border: 1px solid #e4e9f2; + border-radius: 16px; + padding: 20px; + box-shadow: 0 12px 32px rgba(13, 27, 42, 0.06); +} + +label { + display: block; + font-weight: 600; + margin-bottom: 6px; +} + +input, select { + width: 100%; + padding: 12px; + border: 1px solid #cdd7e0; + border-radius: 10px; + font-size: 15px; +} + +.inline { + display: grid; + grid-template-columns: 1fr 140px; + gap: 12px; + align-items: center; + margin-bottom: 16px; +} + +button { + padding: 12px 16px; + border: none; + border-radius: 12px; + background: linear-gradient(120deg, #0e7afe, #5ad4f7); + color: #fff; + font-weight: 700; + cursor: pointer; + transition: transform 0.1s ease, box-shadow 0.2s ease; +} + +button:active { + transform: translateY(1px); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} + +.actions { + margin-top: 16px; + display: flex; + align-items: center; + gap: 12px; +} + +#status { + color: #0e7afe; + font-weight: 600; +} + +.result { + margin-top: 16px; + padding: 12px; + border-radius: 10px; + background: #f0f6ff; + border: 1px solid #cde1ff; + color: #0d1b2a; +} + +.hidden { + display: none; +} + +@media (max-width: 640px) { + .inline { + grid-template-columns: 1fr; + } + + button { + width: 100%; + } +} diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..8d8e1c3 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + + # Serve static UI + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:5000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /downloads/ { + alias /downloads/; + autoindex on; + types { + application/octet-stream bin; + } + add_header Content-Disposition "attachment"; + } + + location / { + try_files $uri $uri/ /index.html; + } +}