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

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

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

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

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

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

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

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

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

588 lines
28 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="robots" content="noindex">
<!-- Шрифт размещён локально: внешний запрос к Google блокировал отрисовку
и попутно отдавал им IP каждого посетителя. -->
<link rel="preload" as="font" type="font/woff2" crossorigin
href="{{ url_for('static', filename='fonts/inter-400-cyrillic.woff2') }}">
<link rel="preload" as="font" type="font/woff2" crossorigin
href="{{ url_for('static', filename='fonts/inter-600-cyrillic.woff2') }}">
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="topbar">
<div class="wrap topbar__in">
<div class="brand">
<span class="brand__mark" aria-hidden="true"></span>
<span>yt-grab <span class="brand__sub">· статистика</span></span>
</div>
<nav class="topnav">
<a href="/">На главную</a>
<a class="topnav__ch" href="https://t.me/bypassblock" target="_blank" rel="noopener noreferrer" title="Наш канал в Telegram"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11.5 20 5l-2.5 14-5-4-2.5 3-.5-4.5L18 7"/></svg><span>Канал</span></a>
<a class="topnav__gh" href="https://github.com/zapret-discordyoutube/yt-dlp-gui" target="_blank" rel="noopener noreferrer" title="Зеркало на GitHub"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg><span>GitHub</span></a>
<a class="topnav__repo" href="https://git.zapret.moe/zapretdiscordyoutube/yt-dlp-gui" target="_blank" rel="noopener noreferrer"
title="Исходный код проекта"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="5" r="2.5"/><circle cx="6" cy="19" r="2.5"/><circle cx="18" cy="9" r="2.5"/><path d="M6 7.5v9M8.5 5H14a4 4 0 0 1 4 4v0"/></svg><span>Исходники</span></a>
<a class="topnav__donate" href="https://t.me/zapretvpns_bot" target="_blank" rel="noopener noreferrer"
title="Поддержать проект"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 20s-7-4.3-7-9.2A4 4 0 0 1 12 8a4 4 0 0 1 7 2.8C19 15.7 12 20 12 20z"/></svg><span>Поддержать</span></a>
<button class="icon-btn" id="theme-toggle" aria-label="Сменить тему" title="Сменить тему"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>
</nav>
</div>
</header>
<main class="wrap">
<section class="hero" style="padding-bottom:var(--s-4)">
<h1 style="font-size:28px">Как пользуются сервисом</h1>
<p>Счётчики нагрузки и лента скачанного. Без IP и без привязки к тому, кто скачивал.</p>
</section>
<!-- Переключатель периода -->
<div style="display:flex; justify-content:center; margin-bottom:var(--s-6)">
<div class="seg" id="period-seg" role="radiogroup" aria-label="Период">
<button type="button" role="radio" data-period="today" aria-checked="true">Сегодня</button>
<button type="button" role="radio" data-period="week" aria-checked="false">Неделя</button>
<button type="button" role="radio" data-period="month" aria-checked="false">Месяц</button>
<button type="button" role="radio" data-period="all" aria-checked="false">Всё время</button>
</div>
</div>
<div class="banner hidden" id="load-error" role="alert" style="margin-bottom:var(--s-4)">
<span></span><span id="load-error-text"></span>
</div>
<!-- Плитки -->
<div class="tiles" id="tiles">
<div class="tile">
<div class="tile__label">Отдано данных</div>
<div class="tile__value" id="v-bytes"></div>
<div class="tile__sub" id="v-files"></div>
</div>
<div class="tile">
<div class="tile__label">Скачиваний</div>
<div class="tile__value" id="v-done"></div>
<div class="tile__sub" id="v-started"></div>
</div>
<div class="tile">
<div class="tile__label">Запросов к API</div>
<div class="tile__value" id="v-api"></div>
<div class="tile__sub">разборов ссылок</div>
</div>
<div class="tile">
<div class="tile__label">Доля успешных</div>
<div class="tile__value" id="v-rate"></div>
<div class="tile__sub" id="v-failed"></div>
</div>
</div>
<!-- График -->
<section class="section">
<div class="section__head">
<h2>За последние 30 дней</h2>
<div class="seg" id="metric-seg" role="radiogroup" aria-label="Показатель" style="padding:2px">
<button type="button" role="radio" data-metric="bytes_served" aria-checked="true"
style="padding:6px 10px; font-size:12px">Байты</button>
<button type="button" role="radio" data-metric="downloads_done" aria-checked="false"
style="padding:6px 10px; font-size:12px">Скачивания</button>
<button type="button" role="radio" data-metric="api_info" aria-checked="false"
style="padding:6px 10px; font-size:12px">Запросы</button>
</div>
</div>
<div class="card">
<div class="chart" id="chart" role="img" aria-label="Активность за 30 дней"></div>
<div class="chart__axis">
<span id="axis-from"></span>
<span id="axis-to"></span>
</div>
</div>
</section>
<!-- Лента: что скачивают -->
<section class="section">
<div class="section__head">
<h2>Что скачивают <span class="pill" id="feed-total"></span></h2>
<div class="seg" id="feed-seg" role="radiogroup" aria-label="Сортировка" style="padding:2px">
<button type="button" role="radio" data-order="recent" aria-checked="true"
style="padding:6px 10px; font-size:12px">Недавние</button>
<button type="button" role="radio" data-order="popular" aria-checked="false"
style="padding:6px 10px; font-size:12px">Популярные</button>
</div>
</div>
<div class="banner hidden" id="feed-error" role="alert" style="margin-bottom:var(--s-3)">
<span></span><span id="feed-error-text"></span>
</div>
<div class="stack" id="feed"></div>
<div class="empty" id="feed-empty">
<div class="empty__icon" aria-hidden="true">🎬</div>
<strong>Пока пусто</strong>
Здесь появятся ролики, которые скачивали через сервис.
</div>
<div style="display:flex; justify-content:center; margin-top:var(--s-4)">
<button class="btn btn--ghost hidden" id="feed-more">Показать ещё</button>
</div>
</section>
<section class="section">
<div class="note">
<span aria-hidden="true">🔒</span>
<span>О ленте выше: сервер хранит <strong>адрес ролика и время каждого скачивания</strong>
— и больше ничего. Ни названий, ни обложек, ни размеров, ни того, кто скачивал: ни IP,
ни сессии, ни учётных записей. Названия и картинки ваш браузер забирает напрямую с
YouTube, сервер их не видит и не хранит. Сами видеофайлы не сохраняются — удаляются
вскоре после выдачи. Времена скачиваний хранятся 90 дней, затем стираются.
<span id="since-note"></span></span>
</div>
</section>
<footer>
<p><a href="/">yt-grab</a> — открытая обёртка над
<a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer" target="_blank">yt-dlp</a>.
<a href="https://git.zapret.moe/zapretdiscordyoutube/yt-dlp-gui" rel="noopener noreferrer" target="_blank">Исходный код</a>.</p>
<p class="footer__donate">
Сервис бесплатный и без рекламы —
<a href="https://t.me/zapretvpns_bot" rel="noopener noreferrer" target="_blank">поддержать проект</a>,
<a href="https://t.me/bypassblock" rel="noopener noreferrer" target="_blank">канал</a>.
</p>
</footer>
</main>
<script>
"use strict";
const $ = (s) => document.querySelector(s);
/* тема — та же настройка, что и на главной */
try {
const s = JSON.parse(localStorage.getItem("ytgrab.settings.v1") || "{}");
if (s.theme) document.documentElement.setAttribute("data-theme", s.theme);
} catch { /* приватный режим */ }
$("#theme-toggle").addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme")
|| (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
const next = cur === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try {
const s = JSON.parse(localStorage.getItem("ytgrab.settings.v1") || "{}");
s.theme = next;
localStorage.setItem("ytgrab.settings.v1", JSON.stringify(s));
} catch { /* приватный режим */ }
});
const fmtBytes = (b) => {
if (!b) return "0 B";
const u = ["B","KB","MB","GB","TB"]; 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 fmtNum = (n) => (n || 0).toLocaleString("ru-RU");
const plural = (n, one, few, many) => {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few;
return many;
};
/* ---------- Данные ----------
Страница приходит с сервера уже с цифрами (INITIAL), поэтому прочерки
не мигают и первый запрос к API не нужен вовсе. Дальше главный источник
— кэш в localStorage: к серверу идём, только когда кэш протух. */
const INITIAL = {{ initial|tojson }};
const LS_CACHE = "ytgrab.cache.v1";
const CACHE_TTL_MS = 60000;
const cache = {
read() {
try { return JSON.parse(localStorage.getItem(LS_CACHE) || "null"); }
catch { return null; }
},
write(patch) {
try {
const cur = cache.read() || {};
localStorage.setItem(LS_CACHE, JSON.stringify({ ...cur, ...patch, at: Date.now() }));
} catch { /* приватный режим — просто живём без кэша */ }
},
fresh() {
const c = cache.read();
return c && c.at && (Date.now() - c.at) < CACHE_TTL_MS;
}
};
let period = "today", metric = "bytes_served";
let DATA = INITIAL.stats || cache.read()?.stats || null;
/* Суммы за период считаем в браузере из того же ряда по дням, который уже
получен. Сервер их больше не агрегирует — это три лишних запроса к БД. */
const COUNTERS = ["api_info","downloads_started","downloads_done",
"downloads_failed","files_served","bytes_served"];
function periodTotals(p) {
if (!DATA) return {};
if (p === "all") return DATA.all || {};
const days = p === "today" ? 1 : p === "week" ? 7 : 30;
const series = (DATA.series || []).slice(-days);
const out = {};
COUNTERS.forEach(c => { out[c] = series.reduce((s, d) => s + (d[c] || 0), 0); });
return out;
}
async function load(force) {
// к серверу идём, только если кэш протух или нас попросили явно
if (!force && cache.fresh()) return;
try {
const r = await fetch("/api/stats");
if (!r.ok) throw new Error(httpMessage(r.status));
DATA = await readJson(r);
cache.write({ stats: DATA });
} catch (e) {
// Показываем ошибку, только если показывать вообще нечего.
// Если данные уже на экране — молча живём со старыми.
if (!DATA) {
$("#load-error-text").textContent = e.message;
$("#load-error").classList.remove("hidden");
}
return;
}
$("#load-error").classList.add("hidden");
renderAll();
}
function renderAll() {
if (!DATA) return;
render();
renderChart();
if (DATA.since) {
const d = new Date(DATA.since + "T00:00:00");
// локаль уже добавляет «г.», своя точка дала бы «2026 г..»
$("#since-note").textContent = "Счёт идёт с " +
d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
}
}
function render() {
const d = periodTotals(period);
$("#v-bytes").textContent = fmtBytes(d.bytes_served);
$("#v-files").textContent = fmtNum(d.files_served) + " " +
plural(d.files_served || 0, "файл отдан", "файла отдано", "файлов отдано");
$("#v-done").textContent = fmtNum(d.downloads_done);
$("#v-started").textContent = "запущено " + fmtNum(d.downloads_started);
$("#v-api").textContent = fmtNum(d.api_info);
const done = d.downloads_done || 0, failed = d.downloads_failed || 0;
const total = done + failed;
$("#v-rate").textContent = total ? Math.round(done / total * 100) + "%" : "—";
$("#v-failed").textContent = failed
? fmtNum(failed) + " " + plural(failed, "ошибка", "ошибки", "ошибок")
: "без ошибок";
}
function renderChart() {
const series = DATA.series || [];
const box = $("#chart");
box.innerHTML = "";
if (!series.length) { box.innerHTML = '<div class="empty">Пока нет данных</div>'; return; }
const max = Math.max(...series.map(s => s[metric] || 0), 1);
series.forEach(s => {
const v = s[metric] || 0;
const bar = document.createElement("div");
bar.className = "bar" + (v === 0 ? " bar--empty" : "");
// минимальная видимая высота у ненулевых значений, чтобы их было заметно
bar.style.height = v === 0 ? "2px" : Math.max(4, v / max * 100) + "%";
const shown = metric === "bytes_served" ? fmtBytes(v) : fmtNum(v);
const day = new Date(s.day + "T00:00:00")
.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
bar.title = `${day}: ${shown}`;
box.appendChild(bar);
});
const fmtDay = (iso) => new Date(iso + "T00:00:00")
.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
$("#axis-from").textContent = fmtDay(series[0].day);
$("#axis-to").textContent = fmtDay(series[series.length - 1].day);
}
document.querySelectorAll("#period-seg button").forEach(b =>
b.addEventListener("click", () => {
period = b.dataset.period;
document.querySelectorAll("#period-seg button").forEach(x =>
x.setAttribute("aria-checked", String(x === b)));
render();
}));
document.querySelectorAll("#metric-seg button").forEach(b =>
b.addEventListener("click", () => {
metric = b.dataset.metric;
document.querySelectorAll("#metric-seg button").forEach(x =>
x.setAttribute("aria-checked", String(x === b)));
renderChart();
}));
/* ---------- Лента «что скачивают» ---------- */
/* 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("Сервер вернул неожиданный ответ"); }
}
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c =>
({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
/* Адреса приходят из БД и попадают в href — пропускаем только http(s),
чтобы исключить javascript:-ссылки. */
const safeUrl = (u) => {
try {
const p = new URL(u);
return (p.protocol === "http:" || p.protocol === "https:") ? u : null;
} catch { return null; }
};
/* ID ролика -> обложка с CDN YouTube. Никаких запросов к нашему серверу. */
function youtubeId(url) {
try {
const u = new URL(url);
const h = u.hostname.replace(/^www\./, "");
if (h === "youtu.be") return u.pathname.slice(1).split("/")[0] || null;
if (h === "youtube.com" || h.endsWith(".youtube.com")) {
if (u.pathname === "/watch") return u.searchParams.get("v");
const m = u.pathname.match(/^\/(shorts|embed|live)\/([^/]+)/);
if (m) return m[2];
}
} catch { /* не ссылка */ }
return null;
}
const titleCache = new Map();
async function fetchTitle(url) {
if (titleCache.has(url)) return titleCache.get(url);
try {
const r = await fetch("https://www.youtube.com/oembed?format=json&url=" +
encodeURIComponent(url));
if (!r.ok) throw new Error("no oembed");
const d = await r.json();
titleCache.set(url, d.title);
return d.title;
} catch {
titleCache.set(url, null); // не долбим источник повторно
return null;
}
}
const FEED_PAGE = (INITIAL.feed && INITIAL.feed.page_size) || 50;
// Листаем по курсору, а не по смещению: last_seen меняется при каждом
// скачивании, поэтому при OFFSET записи прыгали между страницами.
let feedOrder = "recent", feedCursor = null, feedPages = 0;
const relTime = (iso) => {
const d = new Date(iso);
if (isNaN(d)) return ""; // пустая или битая метка
const diff = (Date.now() - d) / 1000;
if (diff < 60) return "только что";
if (diff < 3600) {
const m = Math.floor(diff / 60);
return `${m} ${plural(m, "минуту", "минуты", "минут")} назад`;
}
if (diff < 86400) {
const h = Math.floor(diff / 3600);
return `${h} ${plural(h, "час", "часа", "часов")} назад`;
}
const dd = Math.floor(diff / 86400);
if (diff < 2592000) return `${dd} ${plural(dd, "день", "дня", "дней")} назад`;
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short", year: "numeric" });
};
/* Точное время: в ленте видно, когда именно скачивали, а не только
«сколько назад». Сегодняшнее показываем часами, остальное — с датой. */
const exactTime = (iso) => {
const d = new Date(iso);
if (isNaN(d)) return "";
const today = new Date().toDateString() === d.toDateString();
return d.toLocaleString("ru-RU", today
? { hour: "2-digit", minute: "2-digit" }
: { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
};
/* Полная метка для подсказки при наведении. */
const fullTime = (iso) => {
const d = new Date(iso);
return isNaN(d) ? "" : d.toLocaleString("ru-RU", {
day: "2-digit", month: "long", year: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit" });
};
let feedGen = 0;
async function loadFeed(reset) {
// Простая блокировка «уже гружу» отбрасывала смену сортировки: кнопка
// выглядела нажатой, а лента оставалась прежней и дальше дописывала
// популярные к недавним. Считаем поколения — побеждает последний
// запрос, устаревший ответ отбрасывается.
const gen = ++feedGen;
const more = $("#feed-more");
more.disabled = true; // защита от двойного клика
try {
await _loadFeed(reset, gen);
} finally {
if (gen === feedGen) more.disabled = false;
}
}
async function _loadFeed(reset, gen) {
if (reset) { feedCursor = null; feedPages = 0; $("#feed").innerHTML = ""; }
const order = feedOrder, cursor = feedCursor;
let d;
try {
const qs = `order=${order}` + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
const r = await fetch(`/api/feed?${qs}`);
if (!r.ok) throw new Error(httpMessage(r.status));
d = await readJson(r);
if (gen !== feedGen) return; // ответ на устаревший запрос
if (order === "recent" && !cursor) cache.write({ feed: d });
} catch (e) {
if (gen !== feedGen) return;
// Есть что показать из кэша — показываем его, а не ошибку.
const c = cache.read();
if (reset && feedOrder === "recent" && c && c.feed) {
applyFeed(c.feed);
return;
}
// «Пока пусто» здесь было бы ложью — данные есть, их просто не отдали
$("#feed-empty").classList.add("hidden");
$("#feed-error-text").textContent = e.message;
$("#feed-error").classList.remove("hidden");
return;
}
applyFeed(d);
}
function applyFeed(d) {
$("#feed-error").classList.add("hidden");
const items = d.items || [];
$("#feed-empty").classList.toggle("hidden", !!($("#feed").children.length || items.length));
if (d.totals && d.totals.unique) {
$("#feed-total").textContent = fmtNum(d.totals.unique) + " " +
plural(d.totals.unique, "ролик", "ролика", "роликов");
$("#feed-total").classList.remove("hidden");
} else {
$("#feed-total").classList.add("hidden");
}
items.forEach(it => {
const url = safeUrl(it.url);
if (!url) return;
const vid = youtubeId(url);
const host = (() => { try { return new URL(url).hostname.replace(/^www\./, ""); }
catch { return ""; } })();
const el = document.createElement("div");
el.className = "feed-item";
el.innerHTML = `
<div class="feed-thumb">${vid
? `<img src="https://i.ytimg.com/vi/${esc(vid)}/mqdefault.jpg" alt=""
loading="lazy" referrerpolicy="no-referrer">`
: `<span aria-hidden="true">🔗</span>`}</div>
<div class="feed-body">
<a class="feed-title" href="${esc(url)}" target="_blank"
rel="noopener noreferrer nofollow">${esc(url)}</a>
<div class="meta" title="Последний раз: ${esc(fullTime(it.last_seen))}&#10;Впервые: ${esc(fullTime(it.first_seen))}">${esc(host)}${
exactTime(it.last_seen) ? ` · <span class="meta__time">${esc(exactTime(it.last_seen))}</span>` : ""
}${relTime(it.last_seen) ? ` · ${esc(relTime(it.last_seen))}` : ""}</div>
</div>
<button class="feed-count" type="button" data-times="${esc(url)}"
aria-expanded="false" title="Показать время каждого скачивания">
<strong>${fmtNum(it.count)}</strong><span>раз</span>
</button>
<div class="feed-times hidden" hidden></div>`;
$("#feed").appendChild(el);
// по клику на счётчик показываем время каждого скачивания
el.querySelector("[data-times]").addEventListener("click", async (e) => {
const btn = e.currentTarget;
const box = el.querySelector(".feed-times");
const open = btn.getAttribute("aria-expanded") === "true";
if (open) {
btn.setAttribute("aria-expanded", "false");
box.hidden = true; box.classList.add("hidden");
return;
}
btn.setAttribute("aria-expanded", "true");
box.hidden = false; box.classList.remove("hidden");
if (box.dataset.loaded) return;
box.textContent = "Загружаем…";
try {
const r = await fetch("/api/feed/events?url=" + encodeURIComponent(url));
if (!r.ok) throw new Error(`Сервер ответил ${r.status}`);
const d = await r.json();
const times = d.times || [];
if (!times.length) {
box.textContent = "Времена не записаны: их начали сохранять недавно.";
} else {
box.innerHTML = times.map(t =>
`<span class="feed-time">${esc(fullTime(t))}</span>`).join("");
if (times.length < it.count) {
box.innerHTML += `<span class="feed-time feed-time--note">` +
`…и ещё ${fmtNum(it.count - times.length)} до начала записи ` +
`(хранится ${d.retention_days} дней)</span>`;
}
}
box.dataset.loaded = "1";
} catch (err) {
box.textContent = "Не удалось загрузить: " + err.message;
}
});
// название подтягиваем с YouTube — наш сервер его не хранит
if (vid) {
const a = el.querySelector(".feed-title");
fetchTitle(url).then(t => { if (t) a.textContent = t; });
}
});
feedCursor = d.next_cursor || null;
feedPages += 1;
$("#feed-more").classList.toggle("hidden", !feedCursor);
}
document.querySelectorAll("#feed-seg button").forEach(b =>
b.addEventListener("click", () => {
feedOrder = b.dataset.order;
document.querySelectorAll("#feed-seg button").forEach(x =>
x.setAttribute("aria-checked", String(x === b)));
loadFeed(true);
}));
$("#feed-more").addEventListener("click", () => loadFeed(false));
/* ---------- Старт ----------
Рисуем синхронно из данных, пришедших вместе со страницей: никакого
мигания прочерков и ни одного запроса к API на обычной загрузке. */
renderAll();
cache.write({ stats: DATA, feed: INITIAL.feed });
if (INITIAL.feed) applyFeed(INITIAL.feed);
/* Обновляем только когда вкладка на виду и кэш протух. */
function refreshIfStale() {
if (document.visibilityState !== "visible" || cache.fresh()) return;
load();
// Ленту перечитываем только если пользователь не листал дальше первой
// страницы: иначе фоновое обновление схлопывало подгруженные карточки
// и сбрасывало прокрутку прямо во время чтения.
if (feedPages <= 1) loadFeed(true);
}
setInterval(refreshIfStale, 30000);
document.addEventListener("visibilitychange", refreshIfStale);
</script>
</body>
</html>