Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d9c05b151 |
No files matched your search
+114
-2
@@ -13,6 +13,7 @@ from core.config import load_config, save_config, VERSION
|
||||
from core.downloader import (
|
||||
download_playlist,
|
||||
download_direct_url,
|
||||
download_video,
|
||||
request_stop as request_download_stop,
|
||||
reset_stop as reset_download_stop,
|
||||
is_stopped as download_is_stopped,
|
||||
@@ -55,6 +56,7 @@ class Api:
|
||||
self.window = None # impostato dopo create_window
|
||||
self._download_thread: Optional[threading.Thread] = None
|
||||
self._upgrade_thread: Optional[threading.Thread] = None
|
||||
self._video_thread: Optional[threading.Thread] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -188,9 +190,15 @@ class Api:
|
||||
# ------------------------------------------------------------------
|
||||
# DOWNLOAD
|
||||
# ------------------------------------------------------------------
|
||||
def _any_job_running(self) -> bool:
|
||||
return (
|
||||
(self._download_thread and self._download_thread.is_alive()) or
|
||||
(self._video_thread and self._video_thread.is_alive())
|
||||
)
|
||||
|
||||
def start_download(self, payload: dict) -> dict:
|
||||
if self._download_thread and self._download_thread.is_alive():
|
||||
return {"ok": False, "error": "Download gia in corso"}
|
||||
if self._any_job_running():
|
||||
return {"ok": False, "error": "Un download gia in corso"}
|
||||
|
||||
urls = payload.get("urls") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
@@ -388,3 +396,107 @@ class Api:
|
||||
|
||||
upgrade_folder(directory, threshold, cookies_path, recursive, progress_cb)
|
||||
self._emit("upgrade:done", {"ok": True})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# VIDEO (YouTube, TikTok, Instagram, Facebook)
|
||||
# ------------------------------------------------------------------
|
||||
def start_video_download(self, payload: dict) -> dict:
|
||||
if self._any_job_running():
|
||||
return {"ok": False, "error": "Un download gia in corso"}
|
||||
|
||||
urls = payload.get("urls") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
quality = payload.get("quality") or "1080p"
|
||||
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessun URL fornito"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
self._video_thread = threading.Thread(
|
||||
target=self._video_worker,
|
||||
args=(list(urls), output_dir, quality),
|
||||
daemon=True,
|
||||
)
|
||||
self._video_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def stop_video_download(self) -> dict:
|
||||
request_download_stop()
|
||||
self._log("video", "[INFO] Interruzione richiesta...")
|
||||
return {"ok": True}
|
||||
|
||||
def _video_worker(self, urls: list, output_dir: str, quality: str) -> None:
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
total_urls = len(urls)
|
||||
multi = total_urls > 1
|
||||
view = "video"
|
||||
|
||||
for url_idx, url in enumerate(urls):
|
||||
if download_is_stopped():
|
||||
self._log(view, "[INFO] Download interrotto.")
|
||||
break
|
||||
|
||||
if multi:
|
||||
self._log(view, f"\n{'='*50}")
|
||||
self._log(view, f"[LISTA] URL {url_idx + 1} di {total_urls}")
|
||||
self._log(view, f"{'='*50}")
|
||||
|
||||
_last = [0.0]
|
||||
_THROTTLE = 0.10
|
||||
url_base = url_idx / total_urls if multi else 0
|
||||
url_weight = 1 / total_urls if multi else 1
|
||||
|
||||
def progress_cb(idx, total, track_name, status, pct,
|
||||
_base=url_base, _weight=url_weight, _uidx=url_idx):
|
||||
if status == "downloading":
|
||||
now = time.monotonic()
|
||||
if now - _last[0] < _THROTTLE:
|
||||
return
|
||||
_last[0] = now
|
||||
|
||||
payload_evt = {
|
||||
"idx": idx, "total": total, "track": track_name,
|
||||
"status": status, "pct": pct,
|
||||
"url_idx": _uidx, "url_total": total_urls,
|
||||
}
|
||||
|
||||
if status == "skipped":
|
||||
self._log(view, f"[SKIP] {track_name} (gia scaricato)")
|
||||
track_progress = (idx + 1) / total if total > 0 else 1
|
||||
payload_evt["overall"] = min(_base + track_progress * _weight, 1.0)
|
||||
elif status == "downloading":
|
||||
track_progress = (idx / total) + (pct / 100 / total) if total > 0 else 0
|
||||
payload_evt["overall"] = min(_base + track_progress * _weight, 1.0)
|
||||
elif status == "done":
|
||||
self._log(view, f"[OK] {track_name}")
|
||||
track_progress = (idx + 1) / total if total > 0 else 1
|
||||
payload_evt["overall"] = min(_base + track_progress * _weight, 1.0)
|
||||
elif status == "stopped":
|
||||
self._log(view, "[INFO] Download interrotto.")
|
||||
elif status == "completed":
|
||||
if not multi:
|
||||
payload_evt["overall"] = 1.0
|
||||
self._log(view, "[INFO] Download completato!")
|
||||
elif status.startswith("error"):
|
||||
self._log(view, f"[ERRORE] {track_name}: {status}")
|
||||
|
||||
self._emit("video:progress", payload_evt)
|
||||
|
||||
self._log(view, f"[INFO] Video: {url}")
|
||||
try:
|
||||
download_video(url, output_dir, quality, cookies_path, progress_cb)
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
|
||||
if multi and not download_is_stopped():
|
||||
self._log(view, f"\n[INFO] Completate tutte le {total_urls} URL!")
|
||||
self._emit("video:progress", {
|
||||
"status": "completed", "overall": 1.0,
|
||||
"url_total": total_urls,
|
||||
})
|
||||
|
||||
self._emit("video:done", {"ok": True})
|
||||
@@ -393,3 +393,149 @@ def download_direct_url(
|
||||
|
||||
if progress_callback and not is_stopped():
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
|
||||
|
||||
def _video_format(quality: str) -> str:
|
||||
"""Costruisce la format string yt-dlp per la qualita richiesta."""
|
||||
if not quality or quality == "best":
|
||||
return "bv*+ba/b"
|
||||
h = quality.rstrip("pP")
|
||||
try:
|
||||
h_int = int(h)
|
||||
except ValueError:
|
||||
h_int = 1080
|
||||
return (
|
||||
f"bv*[height<=?{h_int}][ext=mp4]+ba[ext=m4a]/"
|
||||
f"bv*[height<=?{h_int}]+ba/"
|
||||
f"b[height<=?{h_int}][ext=mp4]/"
|
||||
f"b[height<=?{h_int}]/b"
|
||||
)
|
||||
|
||||
|
||||
def download_video(
|
||||
url: str,
|
||||
output_dir: str,
|
||||
quality: str = "1080p",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Scarica video (con audio) da YouTube/TikTok/Instagram/Facebook/etc.
|
||||
|
||||
quality: "best" | "1080p" | "720p" | "480p"
|
||||
Per Instagram/Facebook privati servono cookies validi.
|
||||
"""
|
||||
global _current_process
|
||||
reset_stop()
|
||||
out_path = Path(output_dir)
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
done_file = out_path / ".downloaded_videos"
|
||||
done_set = _load_done_set(done_file)
|
||||
ytdlp = find_ytdlp()
|
||||
|
||||
probe_cmd = [
|
||||
ytdlp, "--dump-json", "--flat-playlist", "--no-download", "--no-warnings", url,
|
||||
]
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
try:
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(result.stderr.strip())
|
||||
except Exception as e:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, url, f"error: {e}", 0)
|
||||
return
|
||||
|
||||
entries = []
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if line.strip():
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not entries:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, url, "error: Nessun contenuto trovato", 0)
|
||||
return
|
||||
|
||||
total = len(entries)
|
||||
fmt = _video_format(quality)
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
if is_stopped():
|
||||
if progress_callback:
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
title = entry.get("title", f"Video {i + 1}")
|
||||
entry_url = entry.get("url") or entry.get("webpage_url") or entry.get("original_url") or url
|
||||
if total == 1:
|
||||
entry_url = url
|
||||
|
||||
track_key = entry.get("id") or title
|
||||
if track_key in done_set:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "skipped", 100)
|
||||
continue
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "downloading", 0)
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"-f", fmt,
|
||||
"--merge-output-format", "mp4",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
|
||||
entry_url,
|
||||
]
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
if is_stopped():
|
||||
_current_process.terminate()
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "stopped", 0)
|
||||
return
|
||||
|
||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||
if pct_match and progress_callback:
|
||||
pct = int(float(pct_match.group(1)))
|
||||
progress_callback(i, total, title, "downloading", pct)
|
||||
|
||||
return_code = _current_process.wait()
|
||||
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
if return_code == 0:
|
||||
_mark_done(done_file, track_key)
|
||||
done_set.add(track_key)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "done", 100)
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, f"error: yt-dlp exit {return_code}", 0)
|
||||
|
||||
except Exception as e:
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, f"error: {e}", 0)
|
||||
|
||||
if progress_callback and not is_stopped():
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
@@ -26,6 +26,10 @@
|
||||
--purple-hover: #C084FC;
|
||||
--purple-dim: #2D1B4D;
|
||||
|
||||
--pink: #EC4899;
|
||||
--pink-hover: #F472B6;
|
||||
--pink-dim: #831843;
|
||||
|
||||
--red: #E22134;
|
||||
--red-hover: #FF4055;
|
||||
|
||||
@@ -225,6 +229,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
background: linear-gradient(135deg, #5D2C9C 0%, #2D1B4D 60%, #1A0F2E 100%);
|
||||
}
|
||||
|
||||
.hero-pink {
|
||||
background: linear-gradient(135deg, #EC4899 0%, #831843 60%, #3F0A23 100%);
|
||||
}
|
||||
|
||||
.hero-content { position: relative; z-index: 2; max-width: 70%; }
|
||||
|
||||
.hero-eyebrow {
|
||||
@@ -237,6 +245,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
|
||||
.hero-eyebrow.blue { color: var(--blue); }
|
||||
.hero-eyebrow.purple { color: var(--purple); }
|
||||
.hero-eyebrow.pink { color: var(--pink-hover); }
|
||||
|
||||
.hero-title {
|
||||
font-size: 36px;
|
||||
@@ -449,6 +458,11 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-pink {
|
||||
background: var(--pink);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #00CEC9;
|
||||
color: #000;
|
||||
@@ -512,6 +526,7 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
|
||||
.percent.green { color: var(--green); }
|
||||
.percent.blue { color: var(--blue); }
|
||||
.percent.pink { color: var(--pink-hover); }
|
||||
|
||||
.progress {
|
||||
width: 100%;
|
||||
@@ -536,6 +551,10 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
background: linear-gradient(90deg, var(--blue), var(--blue-hover));
|
||||
}
|
||||
|
||||
.progress-fill.pink {
|
||||
background: linear-gradient(90deg, var(--pink), var(--pink-hover));
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
LOG BOX
|
||||
=============================== */
|
||||
@@ -559,6 +578,30 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
.log .l-warn { color: #FDCB6E; }
|
||||
.log .l-muted { color: var(--text-3); }
|
||||
|
||||
/* ===============================
|
||||
HINT BOX
|
||||
=============================== */
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(236, 72, 153, 0.08);
|
||||
border: 1px solid rgba(236, 72, 153, 0.2);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.hint-ico {
|
||||
color: var(--pink-hover);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.hint strong { color: var(--text); font-weight: 700; }
|
||||
|
||||
/* ===============================
|
||||
CHECKBOX
|
||||
=============================== */
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
<span class="nav-icon">⬇</span>
|
||||
<span>Scarica</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="video">
|
||||
<span class="nav-icon">🎬</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
@@ -105,6 +109,74 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: VIDEO ===== -->
|
||||
<section class="view" id="view-video">
|
||||
<header class="hero hero-pink">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow pink">VIDEO</div>
|
||||
<h1 class="hero-title">Scarica video</h1>
|
||||
<p class="hero-subtitle">YouTube · TikTok · Instagram · Facebook · qualsiasi link supportato</p>
|
||||
</div>
|
||||
<div class="hero-deco">🎬</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Sorgente</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">URL</label>
|
||||
<input type="text" id="videoUrlInput" class="input pill" placeholder="https://youtube.com/watch?v=… · https://tiktok.com/… · https://instagram.com/reel/…" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Cartella output</label>
|
||||
<div class="row">
|
||||
<div class="path-display" id="videoPathDisplay">Nessuna cartella selezionata</div>
|
||||
<button class="btn btn-ghost pill btn-sm" id="videoBrowseBtn">Sfoglia</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field inline">
|
||||
<label class="field-label">Qualità</label>
|
||||
<select id="videoQuality" class="input pill input-sm">
|
||||
<option value="best">Migliore disponibile</option>
|
||||
<option value="1080p" selected>1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
<button class="btn btn-pink pill btn-lg" id="videoDownloadBtn">
|
||||
<span class="ico">▶</span> Avvia Download
|
||||
</button>
|
||||
<button class="btn btn-danger pill btn-lg" id="stopVideoBtn" disabled>
|
||||
<span class="ico">◼</span> Stop
|
||||
</button>
|
||||
<div class="counter" id="videoCounter"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="progress-head">
|
||||
<span class="micro-label">Progresso</span>
|
||||
<span class="percent pink" id="videoPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-fill pink" id="videoProgressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="videoLog"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: UPGRADE ===== -->
|
||||
<section class="view" id="view-upgrade">
|
||||
<header class="hero hero-blue">
|
||||
|
||||
+80
-1
@@ -10,9 +10,11 @@ const state = {
|
||||
urlList: [],
|
||||
dlOutputDir: "",
|
||||
upDir: "",
|
||||
videoOutputDir: "",
|
||||
showSecret: false,
|
||||
downloading: false,
|
||||
upgrading: false,
|
||||
videoDownloading: false,
|
||||
};
|
||||
|
||||
// Wait for pywebview ready
|
||||
@@ -80,12 +82,37 @@ const bridgeHandlers = {
|
||||
$("#upgradeBtn").disabled = false;
|
||||
$("#stopUpgradeBtn").disabled = true;
|
||||
},
|
||||
|
||||
"video:progress": (p) => {
|
||||
if (typeof p.overall === "number") {
|
||||
const pct = Math.round(p.overall * 100);
|
||||
$("#videoProgressFill").style.width = pct + "%";
|
||||
$("#videoPercent").textContent = pct + "%";
|
||||
}
|
||||
if (p.status === "downloading" && typeof p.idx === "number") {
|
||||
const prefix = p.url_total > 1 ? `[${(p.url_idx ?? 0) + 1}/${p.url_total}] ` : "";
|
||||
$("#videoCounter").textContent = `${prefix}Video ${p.idx + 1} di ${p.total}`;
|
||||
}
|
||||
if (p.status === "completed" && p.url_total > 1) {
|
||||
$("#videoCounter").textContent = `Completato: ${p.url_total} URL`;
|
||||
}
|
||||
},
|
||||
|
||||
"video:done": () => {
|
||||
state.videoDownloading = false;
|
||||
$("#videoDownloadBtn").disabled = false;
|
||||
$("#stopVideoBtn").disabled = true;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Logging
|
||||
// ============================================================
|
||||
const logEls = { download: () => $("#dlLog"), upgrade: () => $("#upLog") };
|
||||
const logEls = {
|
||||
download: () => $("#dlLog"),
|
||||
upgrade: () => $("#upLog"),
|
||||
video: () => $("#videoLog"),
|
||||
};
|
||||
|
||||
function classifyLog(msg) {
|
||||
if (msg.startsWith("[ERRORE]")) return "l-err";
|
||||
@@ -159,6 +186,13 @@ async function init() {
|
||||
$("#dlPathDisplay").classList.remove("empty");
|
||||
}
|
||||
|
||||
// Video tab — usa output_dir come default
|
||||
state.videoOutputDir = state.config.output_dir || "";
|
||||
if (state.videoOutputDir) {
|
||||
$("#videoPathDisplay").textContent = state.videoOutputDir;
|
||||
$("#videoPathDisplay").classList.remove("empty");
|
||||
}
|
||||
|
||||
// Upgrade tab — default threshold
|
||||
$("#upThreshold").value = state.config.hq_threshold || 310;
|
||||
}
|
||||
@@ -235,6 +269,51 @@ $("#stopDownloadBtn").addEventListener("click", async () => {
|
||||
$("#stopDownloadBtn").disabled = true;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// VIDEO tab
|
||||
// ============================================================
|
||||
$("#videoBrowseBtn").addEventListener("click", async () => {
|
||||
const path = await window.pywebview.api.browse_directory();
|
||||
if (path) {
|
||||
state.videoOutputDir = path;
|
||||
$("#videoPathDisplay").textContent = path;
|
||||
$("#videoPathDisplay").classList.remove("empty");
|
||||
}
|
||||
});
|
||||
|
||||
$("#videoDownloadBtn").addEventListener("click", async () => {
|
||||
const url = $("#videoUrlInput").value.trim();
|
||||
if (!url) {
|
||||
toast("Inserisci un URL", "error");
|
||||
return;
|
||||
}
|
||||
if (!state.videoOutputDir) {
|
||||
toast("Seleziona una cartella di destinazione", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
state.videoDownloading = true;
|
||||
$("#videoDownloadBtn").disabled = true;
|
||||
$("#stopVideoBtn").disabled = false;
|
||||
$("#videoProgressFill").style.width = "0%";
|
||||
$("#videoPercent").textContent = "0%";
|
||||
|
||||
const res = await window.pywebview.api.start_video_download({
|
||||
urls: [url],
|
||||
output_dir: state.videoOutputDir,
|
||||
quality: $("#videoQuality").value,
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["video:done"]();
|
||||
}
|
||||
});
|
||||
|
||||
$("#stopVideoBtn").addEventListener("click", async () => {
|
||||
await window.pywebview.api.stop_video_download();
|
||||
$("#stopVideoBtn").disabled = true;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// UPGRADE tab
|
||||
// ============================================================
|
||||
|
||||
Reference in new issue
Block a user