yt-dlp-gui/templates/index.html
codex-pve 1885684e1a Исправлены четыре находки аудита: пагинация, сроки хранения, ссылки, плагин
Пагинация ленты. Листание шло через OFFSET, а last_seen меняется при
каждом скачивании: запись, обновлённая между запросами страниц, сдвигала
выборку, и одни записи попадали на две страницы, а другие не попадали ни
на одну. Теперь листание по ключу сортировки через непрозрачный курсор.
Одиночный индекс по count не покрывал вторую часть сортировки, поэтому
для «популярных» заведён составной.

Сроки хранения. Таблицы ленты и суточных счётчиков росли бессрочно, а
разовые миграции перечитывали всю ленту в память при КАЖДОМ старте —
время запуска росло вместе с таблицей. Добавлены сроки хранения и отметка
о выполненных миграциях: теперь они отрабатывают один раз.

Канонизация ссылок теряла порт, схему и якорь. Из-за этого ролики с
разных портов схлопывались в одну запись ленты, http-only сайт получал
нерабочую https-ссылку, а одностраничные приложения, где идентификатор
живёт в якоре, сливались все вместе. IPv6-литерал вдобавок терял скобки и
становился неразбираемым.

Плагин pmvhaven — два дефекта, оба с последствиями для безопасности:

  * Бралась ПЕРВАЯ подходящая ссылка со страницы без проверки адреса.
    Рекламной вставки или пользовательского текста хватало, чтобы увести
    загрузку на чужой адрес, в том числе внутренний: проверка адреса в
    приложении покрывает только ссылку ОТ пользователя, а не то, что
    выковыряно со страницы. Теперь кандидаты ранжируются, хост проверяется
    на публичность, плейлисты предпочитаются готовым файлам.

  * Шаблон исключал обратный слэш, поэтому экранированную форму
    (`https:\/\/...`) не находил вовсе, а строка с обратной заменой была
    недостижимым кодом. Вдобавок нежадный квантификатор обрывал ссылку на
    первом расширении, а в путях CDN оно встречается в середине
    (.../x.mp4/master.m3u8). Расширение проверяется после разэкранирования.

Добавлена страница исходников на GitHub как зеркало, знак взят из набора
Simple Icons под CC0 (сам знак остаётся товарным знаком GitHub и стоит
здесь только как ссылка на репозиторий). Заголовок секции с переключателем
рядом больше не распирает узкий экран.

Тестов 224: пагинация проверяется на том самом сценарии со сдвигом записи
между страницами, плагин — на экранированных ссылках и приватных адресах.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwY2Cg54RK7WZdvMaLV5Di
2026-09-21 19:06:21 +03:00

642 lines
31 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<!-- data-theme НЕ задан: иначе правило prefers-color-scheme
никогда не срабатывало и системная светлая тема игнорировалась -->
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>yt.zapret.moe — скачать видео и аудио</title>
<meta name="description" content="Скачивание видео и аудио по ссылке. Настройки и история хранятся только в вашем браузере.">
<!-- Шрифт размещён локально: внешний запрос к Google блокировал отрисовку
и попутно отдавал им IP каждого посетителя. -->
<link rel="preload" as="font" type="font/woff2" crossorigin
href="{{ url_for('static', filename='fonts/inter-400-cyrillic.woff2') }}">
<link rel="preload" as="font" type="font/woff2" crossorigin
href="{{ url_for('static', filename='fonts/inter-600-cyrillic.woff2') }}">
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="topbar">
<div class="wrap topbar__in">
<div class="brand">
<span class="brand__mark" aria-hidden="true"></span>
<span>yt-grab <span class="brand__sub">· zapret.moe</span></span>
</div>
<nav class="topnav">
<a href="/stats">Статистика</a>
<a class="topnav__ch" href="https://t.me/bypassblock" target="_blank" rel="noopener noreferrer" title="Наш канал в Telegram"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11.5 20 5l-2.5 14-5-4-2.5 3-.5-4.5L18 7"/></svg><span>Канал</span></a>
<a class="topnav__gh" href="https://github.com/zapret-discordyoutube/yt-dlp-gui" target="_blank" rel="noopener noreferrer" title="Зеркало на GitHub"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg><span>GitHub</span></a>
<a class="topnav__repo" href="https://git.zapret.moe/zapretdiscordyoutube/yt-dlp-gui" target="_blank" rel="noopener noreferrer"
title="Исходный код проекта"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="5" r="2.5"/><circle cx="6" cy="19" r="2.5"/><circle cx="18" cy="9" r="2.5"/><path d="M6 7.5v9M8.5 5H14a4 4 0 0 1 4 4v0"/></svg><span>Исходники</span></a>
<a class="topnav__donate" href="https://t.me/zapretvpns_bot" target="_blank" rel="noopener noreferrer"
title="Поддержать проект"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 20s-7-4.3-7-9.2A4 4 0 0 1 12 8a4 4 0 0 1 7 2.8C19 15.7 12 20 12 20z"/></svg><span>Поддержать</span></a>
<button class="icon-btn" id="theme-toggle" aria-label="Сменить тему" title="Сменить тему"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>
</nav>
</div>
</header>
<main class="wrap">
<section class="hero">
<h1>Скачивай видео и&nbsp;аудио<br>по одной ссылке</h1>
<p>Выбирай качество и кодек — MP4, MP3, AAC, Opus и всё, что отдаёт источник.</p>
<form class="urlbar" id="url-form">
<label class="sr" for="url">Ссылка на видео</label>
<input class="input" id="url" name="url" type="url" inputmode="url"
placeholder="https://… вставь ссылку" autocomplete="off" required>
<button class="btn btn--primary btn--lg" id="fetch-btn" type="submit">Проверить</button>
</form>
<p class="hint">YouTube, Vimeo, Twitter/X, TikTok, SoundCloud и сотни других сайтов.</p>
<div class="banner hidden" id="fetch-error" role="alert"><span></span><span id="fetch-error-text"></span></div>
</section>
<!-- Skeleton при загрузке -->
<section id="skeleton" class="card hidden" aria-hidden="true">
<div class="preview">
<div class="skel" style="aspect-ratio:16/9; border-radius:var(--r-md)"></div>
<div>
<div class="skel" style="height:18px; width:75%; margin-bottom:10px"></div>
<div class="skel" style="height:13px; width:45%; margin-bottom:20px"></div>
<div class="skel" style="height:40px; width:100%"></div>
</div>
</div>
</section>
<!-- Превью + выбор формата -->
<section id="preview" class="card hidden">
<div class="preview">
<div class="thumb">
<img id="pv-thumb" alt="" loading="lazy">
<span class="thumb__badge hidden" id="pv-duration"></span>
</div>
<div>
<div class="title" id="pv-title"></div>
<div class="meta" id="pv-uploader" style="margin-top:6px"></div>
<div style="margin-top:var(--s-5)">
<div class="seg" id="mode-seg" role="radiogroup" aria-label="Тип загрузки">
<button type="button" role="radio" data-mode="video" aria-checked="true">Видео</button>
<button type="button" role="radio" data-mode="audio" aria-checked="false">Аудио</button>
<button type="button" role="radio" data-mode="all" aria-checked="false">Все форматы</button>
</div>
</div>
<!-- Видео -->
<div class="row" id="opts-video" style="margin-top:var(--s-4)">
<div class="field">
<label for="sel-height">Качество</label>
<select class="input" id="sel-height"><option value="auto">Авто (лучшее)</option></select>
</div>
<div class="field">
<label for="sel-vcodec">Видео</label>
<select class="input" id="sel-vcodec">
<option value="auto">Все кодеки</option>
<option value="h264">H.264 — совместимость</option>
<option value="vp9">VP9</option>
<option value="av1">AV1 — лучшее сжатие</option>
</select>
</div>
<div class="field">
<label for="sel-vaudio" title="Какую звуковую дорожку взять из ролика">Звук в видео</label>
<select class="input" id="sel-vaudio">
<option value="auto">Все кодеки</option>
<option value="aac">AAC</option>
<option value="opus">Opus</option>
</select>
</div>
<p class="meta" style="flex-basis:100%; margin:0">
Это дорожка внутри видео — источник отдаёт только AAC и Opus.
MP3 доступен во вкладке «Аудио».
</p>
</div>
<!-- Аудио -->
<div class="row hidden" id="opts-audio" style="margin-top:var(--s-4)">
<div class="field">
<label for="sel-acodec">Кодек</label>
<select class="input" id="sel-acodec">
<option value="best">Оригинал — мгновенно, без потерь</option>
<option value="aac">AAC — обычно без перекодирования</option>
<option value="opus">Opus — обычно без перекодирования</option>
<option value="mp3">MP3 — совместимость, но медленно</option>
</select>
</div>
<p class="meta" style="flex-basis:100%; margin:0">
MP3 источники не отдают, поэтому его приходится кодировать заново —
на длинном ролике это займёт минуты. Остальные варианты копируют дорожку как есть.
</p>
</div>
<!-- Все форматы -->
<div class="hidden" id="opts-all" style="margin-top:var(--s-4)">
<div class="fmt-list" id="fmt-list" role="radiogroup" aria-label="Доступные форматы"></div>
<p class="meta" style="margin-top:8px">Форматы только с видео или только со звуком будут объединены автоматически.</p>
</div>
<button class="btn btn--primary btn--block" id="download-btn" style="margin-top:var(--s-5)">
⭳ Скачать
</button>
</div>
</div>
</section>
<!-- Задачи -->
<section class="section">
<div class="section__head">
<h2>Загрузки <span class="pill hidden" id="task-count">0</span></h2>
</div>
<div class="stack" id="tasks"></div>
<div class="empty" id="tasks-empty">
<div class="empty__icon" aria-hidden="true"></div>
<strong>Пока пусто</strong>
Вставь ссылку выше, чтобы начать.
</div>
</section>
<!-- История -->
<section class="section">
<div class="section__head">
<h2>История</h2>
<button class="btn btn--ghost" id="clear-history" style="--h:32px; padding:0 12px; font-size:13px">Очистить</button>
</div>
<div class="card" id="history-card">
<div id="history"></div>
<div class="empty" id="history-empty" style="padding:var(--s-6) var(--s-4)">
История хранится только в этом браузере.
</div>
</div>
</section>
<section class="section">
<div class="note">
<span aria-hidden="true">🔒</span>
<span><strong>Что мы храним, а что нет.</strong>
<em>Не храним:</em> сами видеофайлы (удаляются сразу после выдачи), ваш IP, ваши личные настройки
и вашу историю — она лежит только в этом браузере и на сервер не уходит.
<em>Храним:</em> адрес скачанного ролика и время каждого скачивания — это попадает в
публичную <a href="/stats">ленту «что скачивают»</a> и стирается через 90 дней.
Без привязки к тому, кто именно скачивал.
Названия и обложки в ленте ваш браузер берёт напрямую с YouTube — у нас их нет.</span>
</div>
</section>
<footer>
<p>yt-grab — открытая обёртка над
<a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer" target="_blank">yt-dlp</a>.
<a href="https://git.zapret.moe/zapretdiscordyoutube/yt-dlp-gui" rel="noopener noreferrer" target="_blank">Исходный код</a>.
Скачивайте только тот контент, на который у вас есть права.</p>
<p class="footer__donate">
Сервис бесплатный и без рекламы. Если он полезен —
<a href="https://t.me/zapretvpns_bot" rel="noopener noreferrer" target="_blank">поддержите проект</a>:
это оплачивает сервер и трафик. Новости и обсуждение —
<a href="https://t.me/bypassblock" rel="noopener noreferrer" target="_blank">в нашем канале</a>.
</p>
</footer>
</main>
<div class="toasts" id="toasts" aria-live="polite" role="status"></div>
<script>
"use strict";
const $ = (s) => document.querySelector(s);
const LS_SETTINGS = "ytgrab.settings.v1";
const LS_HISTORY = "ytgrab.history.v1";
/* ---------- localStorage: настройки и история живут только здесь ---------- */
const store = {
get(key, fallback) {
try {
const v = localStorage.getItem(key);
if (!v) return fallback;
const parsed = JSON.parse(v);
// Битый JSON ловился и раньше, а вот валидный, но не того типа —
// нет: старый формат истории приводил к hist.unshift is not a
// function при каждой будущей загрузке, и починить это можно было
// только кнопкой «Очистить».
if (Array.isArray(fallback) !== Array.isArray(parsed)) return fallback;
if (parsed === null || typeof parsed !== typeof fallback) return fallback;
return parsed;
} catch { return fallback; }
},
set(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* приватный режим */ }
}
};
let settings = Object.assign(
{ mode: "video", height: "auto", vcodec: "auto", vaudio: "auto",
acodec: "best", theme: null },
store.get(LS_SETTINGS, {})
);
const saveSettings = () => store.set(LS_SETTINGS, settings);
/* ---------- Тема ---------- */
const systemTheme = () =>
window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
const currentTheme = () =>
document.documentElement.getAttribute("data-theme") || systemTheme();
function applyTheme(t) {
document.documentElement.setAttribute("data-theme", t);
settings.theme = t; saveSettings();
}
// Пока пользователь не выбрал тему сам, решает система.
if (settings.theme) applyTheme(settings.theme);
$("#theme-toggle").addEventListener("click", () => {
applyTheme(currentTheme() === "dark" ? "light" : "dark");
});
/* ---------- Утилиты ---------- */
const fmtBytes = (b) => {
if (!b && b !== 0) return "—";
const u = ["B","KB","MB","GB"]; let i = 0, n = b;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(n >= 100 || i === 0 ? 0 : 1)} ${u[i]}`;
};
const fmtDur = (s) => {
if (!s && s !== 0) return "";
s = Math.round(s);
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
return h ? `${h}:${String(m).padStart(2,"0")}:${String(sec).padStart(2,"0")}`
: `${m}:${String(sec).padStart(2,"0")}`;
};
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c =>
({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
/* nginx отдаёт свои ошибки HTML-страницей, поэтому разбор JSON падал
сырым английским исключением прямо в интерфейс. */
const httpMessage = (code) => ({
429: "Слишком много запросов — подождите немного",
502: "Сервис недоступен, попробуйте позже",
503: "Сервис перегружен, попробуйте позже",
504: "Сервер не ответил вовремя",
}[code] || `Сервер ответил ${code}`);
async function readJson(r) {
try { return await r.json(); }
catch { throw new Error("Сервер вернул неожиданный ответ"); }
}
/* Ссылки из истории попадают в href. История лежит в localStorage и могла
быть испорчена, поэтому пропускаем только http(s) — как и на /stats. */
const safeUrl = (u) => {
try {
const p = new URL(u);
return (p.protocol === "http:" || p.protocol === "https:") ? u : null;
} catch { return null; }
};
function toast(msg, kind = "") {
const el = document.createElement("div");
el.className = "toast" + (kind ? ` toast--${kind}` : "");
el.textContent = msg;
$("#toasts").prepend(el);
setTimeout(() => { el.style.opacity = "0"; setTimeout(() => el.remove(), 200); }, 4000);
while ($("#toasts").children.length > 3) $("#toasts").lastElementChild.remove();
}
/* ---------- Fetch info ---------- */
let current = null; // {url, title, thumbnail, formats, ...}
$("#url-form").addEventListener("submit", async (e) => {
e.preventDefault();
const url = $("#url").value.trim();
if (!url) return;
$("#fetch-error").classList.add("hidden");
$("#url").setAttribute("aria-invalid", "false");
$("#preview").classList.add("hidden");
$("#skeleton").classList.remove("hidden");
const btn = $("#fetch-btn");
btn.setAttribute("aria-busy", "true");
btn.innerHTML = '<span class="spinner"></span> Проверяем…';
try {
const r = await fetch("/api/info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url })
});
const data = await readJson(r).catch(() => ({}));
if (!r.ok) throw new Error(data.error || httpMessage(r.status));
current = data;
renderPreview(data);
} catch (err) {
$("#fetch-error-text").textContent = err.message;
$("#fetch-error").classList.remove("hidden");
$("#url").setAttribute("aria-invalid", "true");
} finally {
$("#skeleton").classList.add("hidden");
btn.removeAttribute("aria-busy");
btn.textContent = "Проверить";
}
});
function renderPreview(d) {
$("#pv-title").textContent = d.title;
$("#pv-uploader").textContent = [d.uploader, d.extractor].filter(Boolean).join(" · ");
const thumb = $("#pv-thumb");
if (d.thumbnail) { thumb.src = d.thumbnail; thumb.alt = d.title; }
else { thumb.removeAttribute("src"); thumb.alt = ""; }
const dur = $("#pv-duration");
if (d.duration) { dur.textContent = fmtDur(d.duration); dur.classList.remove("hidden"); }
else dur.classList.add("hidden");
// качество
const sel = $("#sel-height");
sel.innerHTML = '<option value="auto">Авто (лучшее)</option>';
(d.heights || []).forEach(h => {
const o = document.createElement("option");
o.value = h; o.textContent = `${h}p`;
sel.appendChild(o);
});
sel.value = (d.heights || []).includes(settings.height) ? settings.height : "auto";
$("#sel-vcodec").value = settings.vcodec;
$("#sel-vaudio").value = settings.vaudio;
$("#sel-acodec").value = settings.acodec;
renderFormats(d.formats || []);
setMode(settings.mode);
$("#preview").classList.remove("hidden");
$("#preview").classList.add("card--enter");
}
function renderFormats(formats) {
const list = $("#fmt-list");
list.innerHTML = "";
if (!formats.length) {
list.innerHTML = '<div class="empty" style="padding:var(--s-6)">Форматы недоступны</div>';
return;
}
formats.forEach((f, i) => {
const tagCls = f.kind === "both" ? "both" : f.kind === "audio" ? "audio" : "video";
const tagTxt = f.kind === "both" ? "A+V" : f.kind === "audio" ? "Аудио" : "Видео";
const main = f.kind === "audio"
? `${f.ext || ""}${f.abr ? ` · ${Math.round(f.abr)} kbps` : ""}`
: `${f.resolution || (f.height ? f.height + "p" : "?")}${f.fps ? ` · ${Math.round(f.fps)}fps` : ""}`;
const sub = [f.vcodec, f.acodec, f.note].filter(Boolean).join(" / ");
const label = document.createElement("label");
label.className = "fmt";
label.innerHTML = `
<input type="radio" name="fmt" value="${esc(f.format_id)}" ${i === 0 ? "checked" : ""}>
<span>
<span class="tag tag--${tagCls}">${tagTxt}</span>
<span class="fmt__main">${esc(main)}</span>
<span class="fmt__sub"> ${esc(sub)} · id ${esc(f.format_id)}</span>
</span>
<span class="fmt__size">${fmtBytes(f.filesize)}</span>`;
list.appendChild(label);
});
}
/* ---------- Переключение режима ---------- */
function setMode(mode) {
settings.mode = mode; saveSettings();
document.querySelectorAll("#mode-seg button").forEach(b =>
b.setAttribute("aria-checked", String(b.dataset.mode === mode)));
$("#opts-video").classList.toggle("hidden", mode !== "video");
$("#opts-audio").classList.toggle("hidden", mode !== "audio");
$("#opts-all").classList.toggle("hidden", mode !== "all");
}
document.querySelectorAll("#mode-seg button").forEach(b =>
b.addEventListener("click", () => setMode(b.dataset.mode)));
["sel-height","sel-vcodec","sel-vaudio","sel-acodec"].forEach(id => {
$("#" + id).addEventListener("change", (e) => {
const key = { "sel-height":"height", "sel-vcodec":"vcodec",
"sel-vaudio":"vaudio", "sel-acodec":"acodec" }[id];
settings[key] = e.target.value; saveSettings();
});
});
/* ---------- Запуск загрузки ---------- */
$("#download-btn").addEventListener("click", async () => {
if (!current) return;
const btn = $("#download-btn");
if (btn.dataset.busy === "1") return; // защита от повторных кликов
btn.dataset.busy = "1";
btn.setAttribute("aria-busy", "true");
const restore = () => {
delete btn.dataset.busy;
btn.removeAttribute("aria-busy");
};
const body = { url: current.webpage_url, title: current.title, thumbnail: current.thumbnail };
if (settings.mode === "all") {
const picked = document.querySelector('input[name="fmt"]:checked');
if (!picked) { toast("Выберите формат", "error"); restore(); return; }
body.format_id = picked.value;
} else if (settings.mode === "audio") {
body.kind = "audio"; body.acodec = settings.acodec;
} else {
body.kind = "video"; body.height = settings.height;
body.vcodec = settings.vcodec; body.vaudio = settings.vaudio;
}
try {
const r = await fetch("/api/downloads", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const task = await readJson(r).catch(() => ({}));
if (!r.ok) throw new Error(task.error || httpMessage(r.status));
addTask(task);
watchTask(task.id);
toast("Загрузка началась");
} catch (e) {
toast(e.message, "error");
} finally {
restore();
}
});
/* ---------- Карточки задач + SSE ---------- */
const tasks = new Map();
function addTask(t) {
tasks.set(t.id, t);
const el = document.createElement("div");
el.className = "card card--enter";
el.id = "task-" + t.id;
el.innerHTML = `
<div class="task__top">
<div style="min-width:0">
<div class="title" style="font-size:14px">${esc(t.title)}</div>
<div class="meta" style="margin-top:4px">${esc(t.label || "")}</div>
</div>
<button class="icon-btn" data-cancel="${t.id}" aria-label="Отменить">✕</button>
</div>
<div class="progress" data-state="indeterminate" role="progressbar"
aria-label="Загрузка: ${esc(t.title)}" aria-valuemin="0" aria-valuemax="100">
<div class="progress__fill"></div>
</div>
<div class="meta" data-status="${t.id}" aria-live="polite">В очереди…</div>`;
$("#tasks").prepend(el);
el.querySelector("[data-cancel]").addEventListener("click", () =>
fetch(`/api/tasks/${t.id}/cancel`, { method: "POST" }));
updateCount();
}
function watchTask(id) {
const es = new EventSource(`/api/tasks/${id}/progress`);
es.onmessage = (ev) => {
retries = 0; // поток снова живой — счётчик неудач обнуляем
let t; try { t = JSON.parse(ev.data); } catch { return; }
if (t.status === "gone") { es.close(); markLost(id, "Задача не найдена — возможно, сервер перезапускался"); return; }
renderTask(t);
if (["finished","error","cancelled"].includes(t.status)) {
es.close();
if (t.status === "finished") { addHistory(t); toast("Готово: " + t.title, "success"); }
else if (t.status === "error") toast("Ошибка: " + (t.error || ""), "error");
}
};
// Раньше здесь было es.close(): соединение закрывалось навсегда при любом
// разрыве, и карточка вечно висела на «Обработка…» — после перезапуска
// сервера или по истечении срока жизни SSE. Даём EventSource переподключиться,
// но не бесконечно: если задача пропала, честно об этом говорим.
let retries = 0;
es.onerror = () => {
// Сервер штатно рвёт поток каждые SSE_MAX_SECONDS, и браузер
// переподключается сам — это не ошибка. Сдаёмся, только если
// соединение закрыто окончательно и попытки подряд не помогли.
if (es.readyState !== EventSource.CLOSED) return;
// В состоянии CLOSED браузер уже не переподключается сам, поэтому
// второго шанса не будет: сообщаем сразу, а не после порога попыток.
// Раньше счётчик доходил до единицы и карточка замирала навсегда.
if (!el_hasResult(id) && ++retries >= 1) {
es.close();
markLost(id, "Связь с сервером потеряна — запустите загрузку заново");
}
};
}
/* У карточки уже есть результат (кнопка сохранения)? Тогда обрыв потока
ничего не меняет и пугать пользователя не нужно. */
function el_hasResult(id) {
const el = document.getElementById("task-" + id);
return !!el?.querySelector(".btn--success");
}
function markLost(id, text) {
const el = document.getElementById("task-" + id);
if (!el || el.querySelector(".btn--success")) return;
const status = el.querySelector(`[data-status="${id}"]`);
if (status) status.textContent = text;
const bar = el.querySelector(".progress");
if (bar) { bar.dataset.state = "error"; bar.removeAttribute("aria-valuenow"); }
el.querySelector("[data-cancel]")?.remove();
}
function renderTask(t) {
const el = document.getElementById("task-" + t.id);
if (!el) return;
const bar = el.querySelector(".progress");
const fill = el.querySelector(".progress__fill");
const status = el.querySelector(`[data-status="${t.id}"]`);
if (t.status === "downloading" && t.percent != null) {
bar.dataset.state = "downloading";
fill.style.setProperty("--value", t.percent + "%");
bar.setAttribute("aria-valuenow", String(Math.round(t.percent)));
const parts = [`${t.percent}%`];
if (t.speed) parts.push(fmtBytes(t.speed) + "/s");
if (t.eta) parts.push("осталось " + fmtDur(t.eta));
status.textContent = parts.join(" · ");
} else if (t.status === "processing") {
bar.dataset.state = "indeterminate";
bar.removeAttribute("aria-valuenow");
status.textContent = "Обработка (склейка/кодек)…";
} else if (t.status === "finished") {
bar.dataset.state = "finished";
fill.style.setProperty("--value", "100%");
bar.setAttribute("aria-valuenow", "100");
status.innerHTML = `Готово · ${fmtBytes(t.filesize)} `;
el.querySelector("[data-cancel]")?.remove();
if (!el.querySelector(".btn--success")) {
const a = document.createElement("a");
a.className = "btn btn--success btn--block";
a.style.marginTop = "var(--s-3)";
a.href = `/api/tasks/${t.id}/file`;
// В отдельную вкладку: иначе при уже удалённом файле браузер уводил
// со страницы на голый JSON с ошибкой, теряя все карточки и все
//活ные подписки на прогресс других загрузок.
a.target = "_blank";
a.rel = "noopener";
a.textContent = "⭳ Сохранить файл";
el.appendChild(a);
}
} else if (t.status === "error") {
bar.dataset.state = "error";
fill.style.setProperty("--value", "100%");
bar.removeAttribute("aria-valuenow"); // иначе скринридер читает старый процент
status.textContent = "Ошибка: " + (t.error || "неизвестно");
} else if (t.status === "served") {
bar.dataset.state = "finished";
status.textContent = "Файл уже забран и удалён с сервера";
el.querySelector(".btn--success")?.remove();
} else if (t.status === "cancelled") {
bar.dataset.state = "cancelled";
fill.style.setProperty("--value", "100%");
status.textContent = "Отменено";
}
updateCount();
}
function updateCount() {
const n = $("#tasks").children.length;
$("#tasks-empty").classList.toggle("hidden", n > 0);
const pill = $("#task-count");
pill.textContent = String(n);
pill.classList.toggle("hidden", n === 0);
}
/* ---------- История (только localStorage) ---------- */
function addHistory(t) {
const hist = store.get(LS_HISTORY, []);
hist.unshift({ title: t.title, label: t.label, filename: t.filename,
filesize: t.filesize, url: t.url, at: Date.now() });
store.set(LS_HISTORY, hist.slice(0, 50));
renderHistory();
}
function renderHistory() {
const hist = store.get(LS_HISTORY, []);
const box = $("#history");
box.innerHTML = "";
$("#history-empty").classList.toggle("hidden", hist.length > 0);
hist.forEach(h => {
const d = document.createElement("div");
d.className = "hist";
const when = h.at ? new Date(h.at).toLocaleDateString("ru-RU",
{ day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }) : "";
d.innerHTML = `
<span aria-hidden="true">🎬</span>
<span style="min-width:0; flex:1">
<span class="hist__name" title="${esc(h.title)}">${esc(h.title)}</span>
${safeUrl(h.url) ? `<a class="hist__url" href="${esc(h.url)}" target="_blank"
rel="noopener noreferrer" title="${esc(h.url)}">${esc(h.url)}</a>` : ""}
</span>
<span class="hist__meta">${esc(h.label || "")} · ${fmtBytes(h.filesize)}${when ? " · " + when : ""}</span>
${safeUrl(h.url) ? `<button class="icon-btn" data-again="${esc(h.url)}"
aria-label="Скачать снова" title="Скачать снова">↻</button>` : ""}`;
box.appendChild(d);
});
// повторная загрузка: подставить ссылку и сразу проверить
box.querySelectorAll("[data-again]").forEach(b => b.addEventListener("click", () => {
$("#url").value = b.dataset.again;
window.scrollTo({ top: 0, behavior: "smooth" });
$("#url-form").requestSubmit();
}));
}
$("#clear-history").addEventListener("click", () => {
store.set(LS_HISTORY, []); renderHistory(); toast("История очищена");
});
renderHistory();
updateCount();
</script>
</body>
</html>