git.zapret.moe в России блокируют по имени; ссылки на исходники в шапке и подвале страниц ведут на рабочее имя. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1187 lines
64 KiB
HTML
1187 lines
64 KiB
HTML
<!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>Скачать видео и аудио <em>по ссылке</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) }} ГБ</b><span>максимум на файл</span></div>
|
||
<div class="fact"><b>{{ (limits.max_duration_sec // 3600) }} ч</b><span>максимум длительности</span></div>
|
||
<div class="fact"><b>{{ limits.file_ttl_minutes // 60 }} ч</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 =>
|
||
({ "&":"&","<":"<",">":">",'"':""","'":"'" }[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>
|