Primo Caricamento

This commit is contained in:
2026-01-10 13:21:36 +01:00
commit a36973ae12
10 changed files with 630 additions and 0 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
WEB_PORT=8080
API_PORT=5000
MAX_DURATION_SECONDS=5400

60
README.md Normal file
View File

@@ -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/<nome-file>` 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`

19
api/Dockerfile Normal file
View File

@@ -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"]

185
api/app.py Normal file
View File

@@ -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")))

4
api/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
Flask==3.0.0
gunicorn==21.2.0
yt-dlp==2024.03.10
python-dotenv==1.0.1

28
docker-compose.yml Normal file
View File

@@ -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:

68
frontend/index.html Normal file
View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YT Downloader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="shell">
<header>
<h1>YT Downloader</h1>
<p>Inserisci il link, scegli video o solo audio, poi scarica.</p>
</header>
<section class="card">
<label for="url">Link YouTube</label>
<div class="inline">
<input id="url" type="url" placeholder="https://www.youtube.com/watch?v=..." />
<button id="fetchInfo">Carica formati</button>
</div>
<div class="options">
<div>
<label for="mode">Tipo</label>
<select id="mode">
<option value="video">Video</option>
<option value="audio">Solo audio</option>
</select>
</div>
<div>
<label for="videoFormat">Risoluzione video</label>
<select id="videoFormat" disabled>
<option value="">Best disponibile</option>
</select>
</div>
<div>
<label for="audioFormat">Formato audio</label>
<select id="audioFormat" disabled>
<option value="">Best disponibile</option>
</select>
</div>
<div>
<label for="audioExt">Estensione audio</label>
<select id="audioExt" disabled>
<option value="mp3">mp3</option>
<option value="m4a">m4a</option>
<option value="opus">opus</option>
<option value="aac">aac</option>
</select>
</div>
</div>
<div class="actions">
<button id="download">Scarica</button>
<span id="status"></span>
</div>
<div id="result" class="result hidden"></div>
</section>
</main>
<script src="main.js"></script>
</body>
</html>

118
frontend/main.js Normal file
View File

@@ -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 = '<option value="">Best disponibile</option>';
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: <strong>${data.title || 'sconosciuto'}</strong><br/>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 = `<a href="${data.url}" download>${data.file}</a>`;
showResult(`File pronto: ${link}`);
setStatus('Completo');
} catch (err) {
showResult(`Errore: ${err.message}`);
setStatus('');
}
}
fetchInfoBtn.addEventListener('click', fetchInfo);
downloadBtn.addEventListener('click', startDownload);

118
frontend/style.css Normal file
View File

@@ -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%;
}
}

27
nginx/default.conf Normal file
View File

@@ -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;
}
}