yt-dlp-gui/templates/index.html
loop-uh 0bb9a1d9eb Ссылка «Исходный код» — на git2.zapret.moe
git.zapret.moe в России блокируют по имени; ссылки на исходники в шапке и
подвале страниц ведут на рабочее имя.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:58:19 -04:00

1187 lines
64 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="Скачивание видео и аудио по ссылке. Настройки и история хранятся только в вашем браузере.">
<meta name="theme-color" content="#0c0d0b">
<!-- Шрифт размещён локально: внешний запрос к 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-700-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="topbar__in">
<a class="brand" href="/">
<span class="brand__mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11M7 10l5 5 5-5M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg></span>
<span>yt-grab <span class="brand__sub">zapret.moe</span></span>
</a>
<nav class="topnav">
<a class="topnav__keep" href="/stats"><span>Статистика</span></a>
<span class="topnav__sep" aria-hidden="true"></span>
<a 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 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 href="https://git2.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?start=ytdlp_topnav" 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>
<!-- Сцена: заголовок и строка ввода, во всю ширину -->
<section class="stage">
<div class="app stage__in">
<span class="eyebrow">yt-dlp · без рекламы · без регистрации</span>
<h1>Скачать видео и&nbsp;аудио <em>по&nbsp;ссылке</em></h1>
<p class="stage__lead">Любое качество и кодек — MP4, MP3, AAC, Opus. Отрезок, субтитры,
обложка и прямые эфиры. Файл удаляется с сервера сразу после выдачи.</p>
<form class="urlbar" id="url-form">
<span class="urlbar__icon" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg></span>
<label class="sr" for="url">Ссылка на видео</label>
<input class="urlbar__input" id="url" name="url" type="url" inputmode="url"
placeholder="Вставьте ссылку на видео, трек или пост" autocomplete="off" required>
<button class="paste-btn hidden" id="paste-btn" type="button" title="Вставить из буфера обмена"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="3" width="8" height="4" rx="1"/><path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2"/></svg>Вставить</button>
<button class="btn btn--primary btn--lg" id="fetch-btn" type="submit">Проверить</button>
</form>
<div class="banner hidden" id="fetch-error" role="alert"><span aria-hidden="true">⚠</span><span id="fetch-error-text"></span></div>
<div class="sites" aria-label="Поддерживаемые сайты">
<span class="sites__item">YouTube</span>
<span class="sites__item">X / Twitter</span>
<span class="sites__item">TikTok</span>
<span class="sites__item">Instagram</span>
<span class="sites__item">SoundCloud</span>
<span class="sites__item">VK Видео</span>
<span class="sites__item">Twitch</span>
<span class="sites__item">Vimeo</span>
<span class="sites__more">+ сотни других</span>
</div>
</div>
</section>
<div class="app layout">
<div class="layout__main">
<!-- Пока ссылки нет: как это работает -->
<section id="intro">
<div class="intro">
<div class="step">
<span class="step__n">01</span>
<h3>Вставьте ссылку</h3>
<p>Ролик, трек, пост с фото или идущий эфир.</p>
</div>
<div class="step">
<span class="step__n">02</span>
<h3>Выберите формат</h3>
<p>Качество, кодек, язык звука, отрезок «с — по».</p>
</div>
<div class="step">
<span class="step__n">03</span>
<h3>Сохраните файл</h3>
<p>После выдачи файл сразу удаляется с сервера.</p>
</div>
</div>
<div class="facts">
<div class="fact">{% set gb = limits.max_filesize_mb / 1024 %}<b>{{ gb|int if gb == gb|int else gb|round(1) }}&nbsp;ГБ</b><span>максимум на файл</span></div>
<div class="fact"><b>{{ (limits.max_duration_sec // 3600) }}&nbsp;ч</b><span>максимум длительности</span></div>
<div class="fact"><b>{{ limits.file_ttl_minutes // 60 }}&nbsp;ч</b><span>файл живёт не дольше</span></div>
<div class="fact"><b>0</b><span>аккаунтов и регистраций</span></div>
</div>
</section>
<!-- Skeleton при загрузке -->
<section id="skeleton" class="card work hidden" aria-hidden="true">
<div class="work__head">
<div class="skel" style="aspect-ratio:16/9; border-radius:var(--r-md)"></div>
<div>
<div class="skel" style="height:14px; width:30%; margin-bottom:14px"></div>
<div class="skel" style="height:20px; width:85%; margin-bottom:10px"></div>
<div class="skel" style="height:14px; width:45%"></div>
</div>
</div>
<div class="work__body">
<div class="skel" style="height:44px"></div>
<div class="skel" style="height:42px; width:70%"></div>
</div>
</section>
<!-- Плейлист / канал: список роликов, клик — открыть ролик -->
<section id="playlist" class="card work hidden">
<div class="work__body" style="gap:var(--s-3)">
<div>
<span class="source">Плейлист</span>
<div class="title" id="pl-title" style="margin-top:8px"></div>
<div class="meta" id="pl-meta" style="margin-top:4px"></div>
</div>
<div class="pl-list" id="pl-list" role="list"></div>
</div>
<div class="work__foot">
<div class="field" style="min-width:220px">
<label for="pl-format">Весь плейлист архивом</label>
<select class="input" id="pl-format">
<option value="video:auto">Видео — лучшее качество</option>
<option value="video:1080">Видео — 1080p</option>
<option value="video:720">Видео — 720p</option>
<option value="video:480">Видео — 480p</option>
<option value="audio:mp3">Звук — MP3</option>
<option value="audio:best">Звук — оригинал</option>
</select>
</div>
<button class="btn btn--primary btn--lg" id="pl-download">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>
<span id="pl-download-label">Скачать архивом</span>
</button>
</div>
<p class="hint" id="pl-hint" style="padding:0 var(--s-5) var(--s-4)"></p>
</section>
<!-- Превью + выбор формата -->
<section id="preview" class="card work hidden">
<div class="work__head">
<div class="thumb">
<img id="pv-thumb" alt="" loading="lazy">
<span class="thumb__badge hidden" id="pv-duration"></span>
</div>
<div class="work__info">
<span class="source hidden" id="pv-source"></span>
<div class="title" id="pv-title"></div>
<div class="meta" id="pv-uploader"></div>
<div class="live-note hidden" id="pv-live">
<span class="live-badge">● Эфир</span>
Идёт трансляция. Запись пойдёт с текущего момента; чтобы остановить и
сохранить записанное — нажмите ✕ на карточке загрузки.
</div>
<div class="live-note hidden" id="pv-gallery">
<span class="live-badge live-badge--img">Фото</span>
Пост с изображениями (<span id="pv-gallery-count"></span>). Скачаются одним архивом.
</div>
<div class="live-note hidden" id="pv-multi">
<span class="live-badge live-badge--img">Видео</span>
В посте <span id="pv-multi-count"></span>. Скачаются все, одним архивом.
</div>
</div>
</div>
<div class="work__body">
<div class="seg seg--wide" id="mode-seg" role="radiogroup" aria-label="Тип загрузки">
<button type="button" role="radio" data-mode="video" aria-checked="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="15" height="14" rx="2"/><path d="m17 10 5-3v10l-5-3"/></svg>Видео</button>
<button type="button" role="radio" data-mode="audio" aria-checked="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>Аудио</button>
<button type="button" role="radio" data-mode="all" aria-checked="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>Все форматы</button>
</div>
<!-- Видео -->
<div class="group" id="opts-video">
<div class="grid-3">
<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>
</div>
<p class="hint" id="vaudio-hint">Источник отдаёт звук в AAC или Opus. MP3 — во вкладке «Аудио».</p>
<label class="chk"><input type="checkbox" id="chk-tv"><span>Для старого телевизора</span></label>
<p class="hint" id="tv-hint">Видео и звук перекодируются: MKV, H.264 до 720p, AAC. Играет там, где
обычный файл с YouTube не открывается. Занимает дольше: на часовом ролике — несколько минут.</p>
</div>
<!-- Аудио -->
<div class="group hidden" id="opts-audio">
<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="hint">MP3 источники не отдают, его приходится кодировать заново — на длинном
ролике это займёт минуты. Обложка и теги встраиваются автоматически.</p>
</div>
<!-- Все форматы -->
<div class="group hidden" id="opts-all">
<div class="fmt-list" id="fmt-list" role="radiogroup" aria-label="Доступные форматы"></div>
<p class="hint">Форматы только с видео или только со звуком будут объединены автоматически.</p>
</div>
<!-- Язык звука (дубляж) и отрезок -->
<div class="grid-2" id="extra-row">
<div class="field hidden" id="alang-row">
<label for="sel-alang">Язык звука</label>
<select class="input" id="sel-alang"></select>
</div>
<div class="field hidden" id="clip-row">
<label for="clip-from">Отрезок <span style="text-transform:none; letter-spacing:0">(необязательно)</span></label>
<div class="clip">
<input class="input input--mono" id="clip-from" type="text" inputmode="numeric"
placeholder="с 0:00" autocomplete="off" spellcheck="false" aria-label="Начало отрезка">
<span class="clip__sep">—</span>
<input class="input input--mono" id="clip-to" type="text" inputmode="numeric"
placeholder="по конец" autocomplete="off" spellcheck="false" aria-label="Конец отрезка">
</div>
</div>
</div>
<p class="hint hidden" id="clip-hint" style="margin-top:calc(-1 * var(--s-3))">Время отрезка: секунды (90), M:SS (1:30) или H:MM:SS. Пусто — весь ролик.</p>
<!-- Постобработка -->
<div class="group" id="pp-opts">
<div class="group__label"><span>Дополнительно</span><span class="pp-count" id="pp-count"></span></div>
<div class="pp-opts">
<label class="chk"><input type="checkbox" id="pp-embed_subs"><span>Встроить субтитры</span></label>
<label class="chk"><input type="checkbox" id="pp-embed_thumbnail"><span>Встроить обложку</span></label>
<label class="chk"><input type="checkbox" id="pp-embed_metadata"><span>Встроить метаданные</span></label>
<label class="chk"><input type="checkbox" id="pp-embed_chapters"><span>Встроить главы</span></label>
<label class="chk"><input type="checkbox" id="pp-sponsorblock"><span>Вырезать рекламные вставки</span></label>
<label class="chk"><input type="checkbox" id="pp-write_thumbnail"><span>Обложка отдельным файлом</span></label>
<label class="chk"><input type="checkbox" id="pp-write_description"><span>Описание отдельным файлом</span></label>
<label class="chk"><input type="checkbox" id="pp-no_merge"><span>Не объединять аудио и видео</span></label>
</div>
</div>
</div>
<div class="work__foot">
<div class="size-est" id="size-box">
<span class="size-est__label">Примерный размер</span>
<span class="size-est__value" id="size-est">—</span>
</div>
<button class="btn btn--primary btn--lg" id="download-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>
<span id="download-label">Скачать</span>
</button>
</div>
</section>
</div>
<aside class="layout__side">
<!-- Задачи -->
<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 empty--box" id="tasks-empty">
<div class="empty__icon" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg></div>
<strong>Пока пусто</strong>
Здесь появятся ваши загрузки.
</div>
</section>
<!-- История -->
<section class="section">
<div class="section__head">
<h2>История</h2>
<button class="btn btn--ghost btn--sm" id="clear-history">Очистить</button>
</div>
<div class="card hist-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>
</aside>
</div>
</main>
<section class="privacy" aria-labelledby="privacy-title">
<div class="privacy__in">
<div class="privacy__title" id="privacy-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
Что мы храним, а что нет
</div>
<div class="privacy__row">
<span class="privacy__tag privacy__tag--no">Не храним</span>
<p>Сами видеофайлы (удаляются сразу после выдачи), ваш IP, ваши личные настройки
и вашу историю — она лежит только в этом браузере и на сервер не уходит.</p>
</div>
<div class="privacy__row">
<span class="privacy__tag privacy__tag--yes">Храним</span>
<p>Адрес скачанного ролика, время каждого скачивания и размер самого ролика
(в лучшем качестве, а не выбранного вами варианта) — это попадает в
публичную <a href="/stats">ленту «что скачивают»</a> и стирается через год.
Без привязки к тому, кто именно скачивал. Названия и обложки в ленте ваш браузер
берёт напрямую с YouTube — у нас их нет. Ещё 30 дней храним замеры скорости
загрузок: только сайт (например, youtube.com) и числа — без ссылки и без IP.</p>
</div>
</div>
</section>
<footer class="footer">
<div class="footer__in">
<p>yt-grab — открытая обёртка над
<a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer" target="_blank">yt-dlp</a>.
<a href="https://git2.zapret.moe/zapretdiscordyoutube/yt-dlp-gui" rel="noopener noreferrer" target="_blank">Исходный код</a>.
Скачивайте только тот контент, на который у вас есть права.</p>
<nav class="footer__sites" data-zapret-sites="Другие сайты Zapret:"></nav>
<script src="/_sites/sites.js" defer></script>
<p class="footer__donate">
Сервис бесплатный и без рекламы. Если он полезен —
<a href="https://t.me/zapretvpns_bot?start=ytdlp_footer" rel="noopener noreferrer" target="_blank">поддержите проект</a>:
это оплачивает сервер и трафик. Новости и обсуждение —
<a href="https://t.me/bypassblock" rel="noopener noreferrer" target="_blank">в нашем канале</a>.
</p>
</div>
</footer>
<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, pp: {}, alang: "", tv: false },
store.get(LS_SETTINGS, {})
);
if (!settings.pp || typeof settings.pp !== "object") settings.pp = {};
const saveSettings = () => store.set(LS_SETTINGS, settings);
// Режим на экране. Обычно совпадает с сохранённым settings.mode, но у
// немого ролика «Аудио» недоступно — показываем «Видео», не затирая выбор.
let activeMode = settings.mode;
/* Флаги постобработки (чекбоксы). Имена совпадают с id вида pp-<key>. */
const PP_KEYS = ["embed_subs","embed_thumbnail","write_thumbnail",
"write_description","embed_metadata","embed_chapters","sponsorblock","no_merge"];
/* ---------- Тема ---------- */
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, ...}
function setInvalid(on) {
$("#fetch-error").classList.toggle("hidden", !on);
$("#url").setAttribute("aria-invalid", String(on));
$("#url-form").dataset.invalid = String(on);
}
/* Вставка из буфера: кнопка — только там, где браузер даёт readText
(в Firefox его для страниц нет). Вставка ссылки в пустое поле сразу
запускает проверку — лишний клик по «Проверить» не нужен. */
const looksLikeUrl = (s) => /^https?:\/\/\S+$/i.test(s.trim());
if (navigator.clipboard && typeof navigator.clipboard.readText === "function") {
$("#paste-btn").classList.remove("hidden");
$("#paste-btn").addEventListener("click", async () => {
try {
const text = (await navigator.clipboard.readText()).trim();
if (!text) { toast("Буфер обмена пуст"); return; }
$("#url").value = text;
if (looksLikeUrl(text)) $("#url-form").requestSubmit();
else $("#url").focus();
} catch { toast("Нет доступа к буферу — вставьте ссылку вручную", "error"); }
});
}
$("#url").addEventListener("paste", (e) => {
const text = (e.clipboardData && e.clipboardData.getData("text")) || "";
if ($("#url").value.trim() || !looksLikeUrl(text)) return;
e.preventDefault();
$("#url").value = text.trim();
$("#url-form").requestSubmit();
});
$("#url-form").addEventListener("submit", async (e) => {
e.preventDefault();
const url = $("#url").value.trim();
if (!url) return;
setInvalid(false);
$("#preview").classList.add("hidden");
$("#playlist").classList.add("hidden");
$("#intro").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));
if (data.is_playlist) { current = null; renderPlaylist(data); }
else { current = data; renderPreview(data); }
} catch (err) {
$("#fetch-error-text").textContent = err.message;
setInvalid(true);
// Прежнего превью нет — возвращаем вступление, чтобы не зиять пустотой.
if ($("#preview").classList.contains("hidden")) $("#intro").classList.remove("hidden");
} finally {
$("#skeleton").classList.add("hidden");
btn.removeAttribute("aria-busy");
btn.textContent = "Проверить";
}
});
/* Плейлист: список роликов. Клик по ролику — подставить ссылку и проверить,
дальше всё как с обычным роликом. */
function renderPlaylist(d) {
currentPlaylist = d;
$("#preview").classList.add("hidden");
const total = (d.entries || []).length;
const take = Math.min(total, {{ playlist_max }});
$("#pl-download-label").textContent = `Скачать архивом (${take})`;
$("#pl-hint").textContent = (total > take ? `Архивом — первые ${take} роликов. ` : "") +
"Архив до {{ limits.max_filesize_mb // 1024 }} ГБ; недоступные ролики (18+, удалённые) пропускаются.";
$("#pl-title").textContent = d.title || "Плейлист";
const n = d.count || (d.entries || []).length;
$("#pl-meta").textContent = [d.uploader, n ? `${n} роликов` : "",
(d.entries || []).length < n ? `показаны первые ${(d.entries || []).length}` : "",
"выберите ролик"].filter(Boolean).join(" · ");
const list = $("#pl-list");
list.innerHTML = "";
(d.entries || []).forEach((e, i) => {
const u = safeUrl(e.url);
if (!u) return;
const b = document.createElement("button");
b.type = "button";
b.className = "pl-item";
b.setAttribute("role", "listitem");
const th = safeUrl(e.thumbnail);
b.innerHTML = `
<span class="pl-item__n">${i + 1}</span>
<span class="pl-item__thumb">${th ? `<img src="${esc(th)}" alt="" loading="lazy">` : ""}</span>
<span class="pl-item__title">${esc(e.title)}</span>
<span class="pl-item__dur">${e.duration ? fmtDur(e.duration) : ""}</span>`;
b.addEventListener("click", () => {
$("#url").value = u;
window.scrollTo({ top: 0, behavior: "smooth" });
$("#url-form").requestSubmit();
});
list.appendChild(b);
});
$("#playlist").classList.remove("hidden");
$("#playlist").classList.add("card--enter");
}
let currentPlaylist = null;
$("#pl-download").addEventListener("click", async () => {
const d = currentPlaylist;
const btn = $("#pl-download");
if (!d || btn.dataset.busy === "1") return;
btn.dataset.busy = "1"; btn.setAttribute("aria-busy", "true");
const [kind, q] = $("#pl-format").value.split(":");
const body = { url: d.webpage_url, playlist: true, kind, title: d.title,
thumbnail: (d.entries[0] || {}).thumbnail || null };
if (kind === "audio") body.acodec = q; else { body.height = q; body.vcodec = "auto"; body.vaudio = "auto"; }
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); rememberTask(task); watchTask(task.id);
toast("Плейлист поставлен в загрузку");
} catch (e) { toast(e.message, "error"); }
finally { delete btn.dataset.busy; btn.removeAttribute("aria-busy"); }
});
function renderPreview(d) {
$("#playlist").classList.add("hidden");
$("#pv-title").textContent = d.title;
$("#pv-uploader").textContent = d.uploader || "";
const src = $("#pv-source");
src.textContent = d.extractor || "";
src.classList.toggle("hidden", !d.extractor);
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.is_live) { dur.textContent = "LIVE"; dur.classList.remove("hidden"); }
else if (d.duration) { dur.textContent = fmtDur(d.duration); dur.classList.remove("hidden"); }
else dur.classList.add("hidden");
$("#pv-live").classList.toggle("hidden", !d.is_live);
// качество
const sel = $("#sel-height");
// «Авто» — с тем, что сервер на нём выберет (d.picks — тем же селектором
// и сортировкой, что у загрузки).
const ap = (d.picks || {}).auto;
sel.innerHTML = "";
const ao = document.createElement("option");
ao.value = "auto";
ao.textContent = ap && ap.res ? `Авто (лучшее · ${pickLabel(ap)})` : "Авто (лучшее)";
sel.appendChild(ao);
(d.heights || []).forEach(h => {
const o = document.createElement("option");
o.value = h; o.textContent = `${h}p`;
sel.appendChild(o);
});
// Качество не переносится на следующий ролик: каждый начинается с «Авто».
// Раньше прошлый выбор (1080p) молча подставлялся, а если такой высоты у
// ролика не было, на экране было «Авто», а на сервер уходила старая высота.
sel.value = "auto";
if (settings.height !== "auto") { settings.height = "auto"; saveSettings(); }
$("#sel-vcodec").value = settings.vcodec;
$("#sel-vaudio").value = settings.vaudio;
$("#sel-acodec").value = settings.acodec;
$("#chk-tv").checked = !!settings.tv;
renderFormats(d.formats || []);
// Языки аудиодорожек (дубляж): заполняем селектор, если их несколько.
const langs = d.audio_langs || [];
const alsel = $("#sel-alang");
if (langs.length) {
alsel.innerHTML = "";
langs.forEach(l => {
const o = document.createElement("option");
o.value = l.code;
o.textContent = l.label + (l.original ? " · оригинал" : "");
alsel.appendChild(o);
});
alsel.value = langs.some(l => l.code === settings.alang)
? settings.alang : (langs.find(l => l.original) || langs[0]).code;
settings.alang = alsel.value;
}
// Пост-галерея (фото без видео): прячем выбор формата, качаем архивом.
const gallery = !!d.is_gallery;
$("#pv-gallery").classList.toggle("hidden", !gallery);
if (gallery) $("#pv-gallery-count").textContent = (d.image_count || 0) + " фото";
["mode-seg", "opts-video", "opts-audio", "opts-all", "pp-opts"].forEach(id =>
$("#" + id).classList.toggle("hidden", gallery));
// У фото нет ни отрезка, ни звуковых дорожек.
if (gallery) ["clip-row", "alang-row"].forEach(id => $("#" + id).classList.add("hidden"));
// Пост с несколькими видео: качаются все записи архивом. Отдельных
// форматов у поста нет — вкладка «Все форматы» ему не подходит.
const multi = d.multi_count || 0;
$("#pv-multi").classList.toggle("hidden", !multi);
const multiImg = d.multi_images || 0; // смешанная карусель: ещё и фото
if (multi) $("#pv-multi-count").textContent =
multi + " видео" + (multiImg ? " и " + multiImg + " фото" : "");
const allBtn = $('#mode-seg button[data-mode="all"]');
allBtn.disabled = !!multi;
allBtn.title = multi ? "У поста с несколькими видео нет общего списка форматов" : "";
// Немой ролик (Instagram/Pinterest): звука нет ни в одном формате.
const noAudio = d.has_audio === false;
const audioBtn = $('#mode-seg button[data-mode="audio"]');
audioBtn.disabled = noAudio;
audioBtn.title = noAudio ? (multi ? "В роликах этого поста нет звука" : "В этом ролике нет звука") : "";
$("#sel-vaudio").disabled = noAudio;
$("#vaudio-hint").textContent = noAudio
? (multi ? "В роликах этого поста нет звука — скачаются только видео."
: "В этом ролике нет звука — скачается только видео.")
: "Источник отдаёт звук в AAC или Opus. MP3 — во вкладке «Аудио».";
// Сохранённый выбор «Аудио» не затираем — для следующих роликов он в силе.
let mode = settings.mode;
if ((noAudio && mode === "audio") || (multi && mode === "all")) mode = "video";
// Вынужденную смену не сохраняем — выбор в силе для следующих роликов.
if (!gallery) setMode(mode, mode === settings.mode);
else { updateExtraRow(); updateSizeEst(); }
$("#download-label").textContent =
gallery ? "Скачать фото" : (d.is_live ? "Записать эфир" : (multi ? `Скачать все (${multi + multiImg})` : "Скачать"));
$("#intro").classList.add("hidden");
$("#preview").classList.remove("hidden");
$("#preview").classList.add("card--enter");
// Превью ниже первого экрана (узкий экран) — подводим к нему сами.
const top = $("#preview").getBoundingClientRect().top;
if (top > window.innerHeight * 0.6) $("#preview").scrollIntoView({ behavior: "smooth", block: "start" });
}
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);
});
}
/* Примерный размер выбранного варианта — оценка из данных probe (filesize
или битрейт × длительность). Не точная, но даёт понять порядок. */
/* Подпись выбора сервера: «2160p60 · VP9 · HDR». */
const VCODEC_NAMES = { avc1: "H.264", h264: "H.264", av01: "AV1", vp9: "VP9", vp09: "VP9" };
function pickLabel(p) {
return [p.res ? `${p.res}p${p.fps && p.fps > 30 ? p.fps : ""}` : null,
p.vcodec ? (VCODEC_NAMES[p.vcodec.toLowerCase()] || p.vcodec) : null,
p.hdr ? "HDR" : null].filter(Boolean).join(" · ");
}
const LIMIT_BYTES = {{ limits.max_filesize_mb }} * 1024 * 1024;
function updateSizeEst() {
let what = "";
const el = $("#size-est");
const box = $("#size-box");
const fs = current && current.formats;
if (!fs || !fs.length || current.is_gallery || current.is_live) { box.classList.add("hidden"); return; }
const dur = current.duration || 0;
const bytesOf = f => f.filesize || (f.tbr ? Math.round(f.tbr * 125 * dur) : 0);
const bestAudio = () => fs.filter(f => f.kind === "audio")
.sort((x, y) => (y.abr || y.tbr || 0) - (x.abr || x.tbr || 0))[0];
let total = 0;
if (activeMode === "audio") {
const a = bestAudio(); total = a ? bytesOf(a) : 0;
} else if (activeMode === "all") {
const picked = document.querySelector('input[name="fmt"]:checked');
const f = picked && fs.find(x => x.format_id === picked.value);
total = f ? bytesOf(f) : 0;
} else {
// Кодеки «Любой» — ровно то, что выберет сервер (current.picks). С отрезком
// лимит размера при выборе не действует — берём выбор без него только
// для «Авто», где сервер это и отмечает (capped).
const p = settings.vcodec === "auto" && settings.vaudio === "auto"
&& (current.picks || {})[settings.height];
const clipped = clipFraction() < 1;
if (p && p.size && !(clipped && p.capped)) {
total = p.size;
what = pickLabel(p) + (p.capped ? ` · лучшее, что влезает в ${fmtBytes(LIMIT_BYTES)}` : "");
} else {
// «Высота» — короткая сторона: у вертикального 1080x1920 это 1080.
const side = f => (f.width && f.height) ? Math.min(f.width, f.height) : (f.height || 0);
const h = settings.height === "auto" ? Infinity : parseInt(settings.height);
const v = fs.filter(f => (f.kind === "video" || f.kind === "both") && side(f) <= h)
.sort((a, b) => (side(b) - side(a)) || ((b.fps || 0) - (a.fps || 0))
|| ((b.tbr || 0) - (a.tbr || 0)))[0];
if (v) {
total = bytesOf(v);
if (v.kind === "video") { const a = bestAudio(); if (a) total += bytesOf(a); }
}
}
}
if (total > 0) {
// С отрезком файл меньше пропорционально его доле в ролике.
const clip = clipFraction();
el.textContent = "≈ " + fmtBytes(Math.round(total * clip))
+ (what ? " · " + what : "") + (clip < 1 ? " · отрезок" : "");
box.classList.remove("hidden");
} else {
box.classList.add("hidden");
}
}
/* Доля ролика, которую займёт отрезок (1 — весь ролик). Разбор тот же,
что на сервере: секунды, M:SS или H:MM:SS. */
function parseTc(s) {
s = String(s || "").trim();
if (!/^\d{1,3}(:[0-5]?\d){0,2}(\.\d{1,3})?$/.test(s)) return null;
return s.split(":").reduce((acc, p) => acc * 60 + parseFloat(p), 0);
}
function clipFraction() {
const dur = (current && current.duration) || 0;
if (!dur || activeMode === "all" || $("#clip-row").classList.contains("hidden")) return 1;
const a = parseTc($("#clip-from").value) ?? 0;
const b = parseTc($("#clip-to").value) ?? dur;
if (b <= a) return 1;
return Math.max(0, Math.min(1, (Math.min(b, dur) - a) / dur));
}
["clip-from", "clip-to"].forEach(id => $("#" + id).addEventListener("input", updateSizeEst));
/* Строка «язык звука / отрезок»: прячем целиком, если в ней нечего
показать, — иначе пустая сетка оставляла лишний зазор. */
function updateExtraRow() {
const a = $("#alang-row").classList.contains("hidden");
const c = $("#clip-row").classList.contains("hidden");
$("#extra-row").classList.toggle("hidden", a && c);
$("#clip-hint").classList.toggle("hidden", c);
$("#extra-row").style.gridTemplateColumns = (!a && !c) ? "" : "minmax(0,1fr)";
}
/* Строку выбора языка звука показываем только в режимах видео/аудио, когда у
ролика реально несколько аудиодорожек. */
function updateAlangRow() {
const langs = (current && current.audio_langs) || [];
const show = langs.length && !(current && current.is_gallery)
&& (activeMode === "video" || activeMode === "audio");
$("#alang-row").classList.toggle("hidden", !show);
updateExtraRow();
}
/* ---------- Переключение режима ---------- */
function setMode(mode, persist = true) {
activeMode = mode;
if (persist) { 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");
// Отрезок — для видео/аудио (не для «Все форматы», галерей, эфиров и постов из нескольких видео).
$("#clip-row").classList.toggle("hidden",
!!(current && (current.is_gallery || current.is_live || current.multi_count))
|| !(mode === "video" || mode === "audio"));
updateAlangRow();
updateSizeEst();
}
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(); updateSizeEst();
});
});
$("#chk-tv").addEventListener("change", (e) => {
settings.tv = e.target.checked; saveSettings();
});
/* Выбор конкретного формата во «Все форматы» тоже меняет оценку. */
$("#fmt-list").addEventListener("change", updateSizeEst);
/* Язык звука. */
$("#sel-alang").addEventListener("change", (e) => {
settings.alang = e.target.value; saveSettings();
});
/* Чекбоксы постобработки: восстановление и сохранение выбора. */
function updatePpCount() {
const n = PP_KEYS.filter(k => settings.pp[k]).length;
$("#pp-count").textContent = n ? `выбрано: ${n}` : "";
}
PP_KEYS.forEach(k => {
const el = $("#pp-" + k);
if (!el) return;
el.checked = !!settings.pp[k];
el.addEventListener("change", () => {
settings.pp[k] = el.checked; saveSettings(); updatePpCount();
});
});
updatePpCount();
/* ---------- Запуск загрузки ---------- */
$("#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 (current.is_live) body.is_live = true;
if (current.via_egress) body.via_egress = true;
if (current.multi_count) body.multi = true;
if (current.is_gallery) {
// Галерея: качаем фото архивом, выбор формата и постобработка неприменимы.
body.kind = "image";
} else {
if (activeMode === "all") {
const picked = document.querySelector('input[name="fmt"]:checked');
if (!picked) { toast("Выберите формат", "error"); restore(); return; }
body.format_id = picked.value;
} else if (activeMode === "audio") {
body.kind = "audio"; body.acodec = settings.acodec;
} else {
body.kind = "video"; body.height = settings.height;
body.vcodec = settings.vcodec; body.vaudio = settings.vaudio;
if (settings.tv) body.tv = true;
}
// Язык звука — только для видео/аудио и только если дорожек несколько.
if (activeMode !== "all" && (current.audio_langs || []).length && settings.alang) {
body.alang = settings.alang;
}
// Отрезок — только для видео/аудио.
if (activeMode !== "all") {
const cf = $("#clip-from").value.trim(), ct = $("#clip-to").value.trim();
if (cf) body.clip_from = cf;
if (ct) body.clip_to = ct;
}
PP_KEYS.forEach(k => { if (settings.pp[k]) body[k] = true; });
}
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);
rememberTask(task);
watchTask(task.id);
toast("Загрузка началась");
} catch (e) {
toast(e.message, "error");
} finally {
restore();
}
});
/* ---------- Карточки задач + SSE ---------- */
const tasks = new Map();
/* Активные загрузки переживают перезагрузку страницы: их id лежат в этом
браузере, и после перезагрузки карточки восстанавливаются и снова следят
за прогрессом. Раньше перезагрузка «теряла» загрузку — и в историю она
не попадала, хотя файл на сервере был готов. */
const LS_ACTIVE = "ytgrab.active.v1";
function rememberTask(t) {
const list = store.get(LS_ACTIVE, []).filter(x => x && x.id !== t.id);
list.unshift({ id: t.id, title: t.title, label: t.label, url: t.url,
thumbnail: t.thumbnail, is_live: !!t.is_live });
store.set(LS_ACTIVE, list.slice(0, 10));
}
function forgetTask(id) {
store.set(LS_ACTIVE, store.get(LS_ACTIVE, []).filter(x => x && x.id !== id));
}
function restoreTasks() {
const list = store.get(LS_ACTIVE, []).filter(x => x && /^[0-9a-f]{32}$/.test(x.id || ""));
list.slice().reverse().forEach(t => {
if (document.getElementById("task-" + t.id)) return;
addTask(t);
watchTask(t.id);
});
}
function addTask(t) {
tasks.set(t.id, t);
const el = document.createElement("div");
el.className = "card task card--enter";
el.id = "task-" + t.id;
const thumb = safeUrl(t.thumbnail);
el.innerHTML = `
<div class="task__top">
<div class="task__thumb">${thumb ? `<img src="${esc(thumb)}" alt="" loading="lazy">` : ""}</div>
<div class="task__body">
<div class="title">${esc(t.title)}</div>
<div class="task__label" title="${esc(t.label || "")}">${esc(t.label || "")}</div>
</div>
<button class="icon-btn" data-cancel="${t.id}" aria-label="Отменить" title="Отменить">✕</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="task__status" data-status="${t.id}" aria-live="polite">Запуск…</div>`;
$("#tasks").prepend(el);
el.querySelector("[data-cancel]").addEventListener("click", (e) => {
// Отклик сразу, не дожидаясь сервера: кнопку гасим, пишем, что происходит.
const btn = e.currentTarget;
if (btn.disabled) return;
btn.disabled = true;
const st = el.querySelector(`[data-status="${t.id}"]`);
if (st) st.textContent = t.is_live ? "Останавливаем запись и сохраняем…" : "Отменяем…";
fetch(`/api/tasks/${t.id}/cancel`, { method: "POST" })
.catch(() => { btn.disabled = false; toast("Не удалось отменить — нет связи", "error"); });
});
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(); forgetTask(id); markLost(id, "Задача не найдена — возможно, сервер перезапускался"); return; }
renderTask(t);
if (["finished","error","cancelled","served"].includes(t.status)) {
es.close();
forgetTask(id);
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;
// Подпись может дополниться по ходу (плейлист: «скачано N из M»).
const lbl = el.querySelector(".task__label");
if (lbl && t.label && lbl.textContent !== t.label) { lbl.textContent = t.label; lbl.title = t.label; }
const bar = el.querySelector(".progress");
const fill = el.querySelector(".progress__fill");
const status = el.querySelector(`[data-status="${t.id}"]`);
if (t.status === "queued") {
bar.dataset.state = "indeterminate";
status.textContent = "В очереди — все слоты заняты, скоро начнём…";
} else if ((t.status === "preparing" || t.status === "downloading") && !t.percent && t.phase) {
// До первых байт: что именно происходит, а не безликая «подготовка».
bar.dataset.state = "indeterminate";
status.textContent = t.phase === "bypass"
? "Сервер YouTube заблокирован провайдером — качаем через резервный канал…"
: "Подключаемся к серверу YouTube…";
} else if (t.status === "preparing") {
bar.dataset.state = "indeterminate";
status.textContent = "Подготовка — получаем ссылки на файлы…";
} else if (t.status === "downloading" && t.percent == null) {
// Эфир или источник без размера: процента нет, показываем скорость.
bar.dataset.state = "indeterminate";
status.textContent = "Скачивание" + (t.speed ? " · " + fmtBytes(t.speed) + "/s" : "…");
} else 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.href = `/api/tasks/${t.id}/file`;
// В отдельную вкладку: иначе при уже удалённом файле браузер уводил
// со страницы на голый JSON с ошибкой, теряя все карточки и все
// активные подписки на прогресс других загрузок.
a.target = "_blank";
a.rel = "noopener";
a.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>Сохранить файл';
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, []);
if (t.id && hist.some(h => h.id === t.id)) return; // уже записана
hist.unshift({ id: t.id, 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" }) : "";
const u = safeUrl(h.url);
let host = "";
try { host = u ? new URL(u).hostname.replace(/^www\./, "") : ""; } catch { /* мусор */ }
d.innerHTML = `
<div class="hist__main">
<span class="hist__name" title="${esc(h.title)}">${esc(h.title)}</span>
<div class="hist__meta">
${u ? `<a class="hist__url" href="${esc(u)}" target="_blank"
rel="noopener noreferrer" title="${esc(u)}">${esc(host)}</a>` : ""}
<span>${fmtBytes(h.filesize)}</span>
${when ? `<span>${esc(when)}</span>` : ""}
${h.label ? `<span title="${esc(h.label)}">${esc(h.label)}</span>` : ""}
</div>
</div>
${u ? `<button class="icon-btn" data-again="${esc(u)}"
aria-label="Скачать снова" 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"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.5 6.2L3 16M3 21v-5h5"/></svg></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", () => {
const hist = store.get(LS_HISTORY, []);
if (!hist.length) { toast("История уже пуста"); return; }
if (!confirm(`Очистить историю (${hist.length})? Это действие нельзя отменить.`)) return;
store.set(LS_HISTORY, []); renderHistory(); toast("История очищена");
});
renderHistory();
restoreTasks();
updateCount();
</script>
</body>
</html>