Primo Caricamento
This commit is contained in:
60
README.md
Normal file
60
README.md
Normal 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
19
api/Dockerfile
Normal 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
185
api/app.py
Normal 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
4
api/requirements.txt
Normal 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
28
docker-compose.yml
Normal 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
68
frontend/index.html
Normal 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
118
frontend/main.js
Normal 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
118
frontend/style.css
Normal 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
27
nginx/default.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user