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