yt-dlp-gui/app.py
loop-uh a25fc3d666 Режим «Для старого телевизора»: перекодирование в MKV H.264 High@4.0 ≤720p + AAC 48 кГц
Проверено на Philips 24PFL4228T: потоки YouTube как есть не играют (ни видео,
ни звук 44,1 кГц), перекодированные — играют; 70-минутный MP4 не открылся
(индекс ~3 МБ), 70-минутный MKV открылся. Галочка в настройках видео, потолок
качества 720p, битрейт ограничен лимитом размера файла.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 19:00:30 +03:00

544 lines
26 KiB
Python
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.

"""yt-dlp GUI — веб-обёртка. Сервер не хранит историю и настройки пользователя."""
from __future__ import annotations
import json
import logging
import os
import shutil
import threading
import time
from collections import defaultdict, deque
from flask import (Flask, Response, jsonify, render_template, request,
send_file, stream_with_context)
from werkzeug.middleware.proxy_fix import ProxyFix
import config
import downloader as dl
import stats
app = Flask(__name__)
app.config["JSON_AS_ASCII"] = False
# Статика (стили и восемь файлов шрифта) отдаётся этим же процессом, а
# потоки здесь дефицитны из-за SSE. Без кэширования браузер ревалидировал
# все девять файлов на каждой навигации.
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 60 * 60 * 24 * 30
if config.TRUST_PROXY:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
if config.QUIET_ACCESS_LOG:
# не пишем URL/IP пользователей в лог
logging.getLogger("werkzeug").setLevel(logging.WARNING)
manager = dl.DownloadManager()
stats.init()
_mig = stats.run_migrations(dl.canonical_url)
if not _mig.get("skipped"):
logging.info("разовые миграции выполнены: огрублено меток %d, "
"схлопнуто дубликатов %d", _mig["rounded"], _mig["merged"])
def _task_finished(task) -> None:
"""Считаем исход задачи. Пишем только числа, без URL и заголовков."""
if task.status == "finished":
stats.bump("downloads_done")
# В ленту идёт приведённая ссылка: один ролик — одна запись,
# и в публичную выдачу не попадают метки перехода и токены.
stats.record_download(dl.canonical_url(task.url), size=task.video_size)
elif task.status == "error":
stats.bump("downloads_failed")
# Производительность — по всем исходам, включая отмену: только домен и
# числа, без ссылки.
if task.status in ("finished", "error", "cancelled"):
stats.record_perf(dl._host_of(task.url).removeprefix("www."), task.status,
task.metrics, error=task.error, size=task.filesize)
manager.on_complete = _task_finished
def _prune_events_loop() -> None:
"""Раз в сутки убирать устаревшие события: таблица растёт на строку с
каждой загрузкой и без этого станет вечным журналом активности."""
while True:
try:
removed = stats.prune_events()
removed += stats.prune_perf()
gone_feed, gone_daily = stats.prune_feed_and_daily()
if removed or gone_feed or gone_daily:
logging.info("очистка: событий %d, записей ленты %d, суток %d",
removed, gone_feed, gone_daily)
except Exception:
logging.exception("не удалось почистить события")
# Спим ПОСЛЕ работы: при сне в начале сервис, перезапускаемый чаще
# раза в сутки, не чистил события никогда, и обещанные «90 дней»
# были неправдой.
time.sleep(24 * 3600)
threading.Thread(target=_prune_events_loop, daemon=True).start()
# --- простой in-memory rate limit по IP ---
_hits: dict[str, deque] = defaultdict(deque)
_hits_lock = threading.Lock()
def _client_ip() -> str:
return request.remote_addr or "unknown"
_hits_last_gc = 0.0
def _gc_hits(now: float) -> None:
"""Выкинуть ключи протухших окон.
Вызывается под _hits_lock. Без этого словарь рос бы вечно: каждый
новый IP оставлял запись навсегда, и достаточно было перебрать много
адресов, чтобы выесть память процесса.
"""
global _hits_last_gc
if now - _hits_last_gc < 60:
return
_hits_last_gc = now
cutoff = now - config.RATE_WINDOW_SEC
for key in [k for k, q in _hits.items() if not q or q[-1] < cutoff]:
del _hits[key]
def rate_ok(bucket: str, limit: int) -> bool:
key = f"{bucket}:{_client_ip()}"
now = time.time()
with _hits_lock:
_gc_hits(now)
q = _hits[key]
while q and q[0] < now - config.RATE_WINDOW_SEC:
q.popleft()
if len(q) >= limit:
return False
q.append(now)
return True
def err(msg: str, code: int = 400):
return jsonify({"error": msg}), code
# Единая карта сообщений: раньше словари дублировались в двух ручках и
# успели разойтись — на одну и ту же ссылку /api/info отвечал «Этот адрес
# недоступен», а /api/downloads глотал код и говорил «Плохая ссылка».
_MESSAGES = {
"empty_or_too_long": "Пустая или слишком длинная ссылка",
"bad_scheme": "Нужна ссылка http:// или https://",
"private_host": "Этот адрес недоступен",
"bad_format_id": "Недопустимый формат",
"unknown_acodec": "Неизвестный аудиокодек",
"unknown_vcodec": "Неизвестный видеокодек",
"unknown_vaudio": "Неизвестный формат звука",
"unknown_height": "Неизвестное разрешение",
"unknown_kind": "Неизвестный тип",
"unknown_lang": "Неизвестный язык дорожки",
}
def message_for(e: Exception, default: str) -> str:
return _MESSAGES.get(str(e), default)
@app.after_request
def security_headers(resp):
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("Referrer-Policy", "no-referrer")
resp.headers.setdefault("X-Frame-Options", "DENY")
return resp
@app.get("/")
def index():
return render_template("index.html", playlist_max=config.PLAYLIST_DL_MAX, limits={
"max_filesize_mb": config.MAX_FILESIZE_MB,
"max_duration_sec": config.MAX_DURATION_SEC,
"file_ttl_minutes": config.FILE_TTL_MINUTES,
})
@app.get("/readyz")
def readyz():
"""Настоящая проверка готовности, в отличие от /healthz.
/healthz отвечает «жив» даже когда диск полон, уборщик мёртв, БД не
пишется, а ffmpeg отсутствует. Здесь проверяется то, без чего сервис
фактически не работает, и при деградации отдаётся 503 — чтобы поломку
было видно мониторингу, а не только пользователям.
"""
h = manager.health()
checks = {}
checks["ffmpeg"] = shutil.which("ffmpeg") is not None
try:
stats.selftest()
checks["stats_db"] = True
except Exception:
checks["stats_db"] = False
checks["disk_free"] = (h["free_disk_mb"] is not None
and h["free_disk_mb"] >= config.MIN_FREE_DISK_MB)
checks["disk_quota"] = h["downloads_mb"] < config.DISK_QUOTA_MB
# уборщик должен отрабатывать регулярно; тройной интервал — запас
checks["janitor"] = h["janitor_age_sec"] < config.JANITOR_INTERVAL_SEC * 3 + 30
checks["queue"] = h["queue_pending"] < config.QUEUE_MAX
ok = all(checks.values())
body = {"ok": ok, "checks": checks}
# YouTube напрямую не отвечает (блокировка по SNI) — это предупреждение,
# а не отказ: остальные сайты работают, YouTube идёт через egress, и 503
# здесь означал бы «сервис лежит». Наружу — только название.
yt = dl.yt_direct_state()
body["warnings"] = ["youtube_direct"] if yt["ok"] is False else []
# Числа (свободное место на разделе гипервизора, глубина очереди,
# версия yt-dlp) — только для локального мониторинга. Наружу уходит
# лишь признак готовности: сайт публичный.
if request.remote_addr in ("127.0.0.1", "::1"):
body["metrics"] = {**h, "youtube_direct": yt}
body["version"] = dl.yt_dlp.version.__version__
return jsonify(body), (200 if ok else 503)
@app.get("/healthz")
def healthz():
return jsonify({"ok": True, "version": dl.yt_dlp.version.__version__})
@app.post("/api/info")
def api_info():
if not rate_ok("info", config.RATE_MAX_INFO):
return err("Слишком много запросов, подождите немного", 429)
data = request.get_json(silent=True) or {}
try:
url = dl.validate_url(data.get("url", ""))
except ValueError as e:
return err(message_for(e, "Плохая ссылка"))
try:
info = dl.probe(url)
stats.bump("api_info")
return jsonify(info)
except ValueError as e:
if str(e) == "too_long":
return err(f"Слишком длинное видео (лимит {config.MAX_DURATION_SEC // 3600} ч)")
if str(e) == "upcoming":
return err("Эфир ещё не начался — попробуйте, когда трансляция пойдёт")
if str(e) == "age_restricted":
return err("Видео с возрастным ограничением: YouTube отдаёт его только "
"после входа в аккаунт, а сервис работает без аккаунтов")
if str(e) == "no_formats":
return err("Источник не отдал ни одного формата для скачивания")
return err("Не удалось разобрать ссылку")
except dl.yt_dlp.utils.DownloadError as e:
return err(dl._clean_err(str(e)))
except Exception:
return err("Не удалось получить информацию о видео", 502)
@app.post("/api/downloads")
def api_download():
if not rate_ok("download", config.RATE_MAX_DOWNLOAD):
return err("Слишком много загрузок, подождите немного", 429)
if manager.quota_exceeded():
return err("На сервере временно нет места, попробуйте позже", 503)
data = request.get_json(silent=True) or {}
try:
url = dl.validate_url(data.get("url", ""))
except ValueError as e:
return err(message_for(e, "Плохая ссылка"))
images_mode = str(data.get("kind")) == "image"
# «Для старого ТВ»: только одиночное видео; к аудио, плейлистам, эфирам и
# фото не применяется (решает флаг tv_mode ниже, после разбора запроса).
tv_mode = bool(data.get("tv")) and str(data.get("kind", "video")) == "video" \
and not images_mode and not data.get("is_live")
# Отрезок «с X по Y» — до выбора формата: к отрезку лимит размера при
# выборе не применяется (от длинного 4K нужен кусок, а не 1080p).
clip = None
if not images_mode:
try:
clip = dl.parse_clip(
str(data.get("clip_from", "")), str(data.get("clip_to", "")))
except ValueError:
return err("Неверный отрезок: конец должен быть больше начала")
# Разбор из «Проверить» (тем же путём): ориентация и длительность — для
# «1080p» у вертикальных роликов и для лимита размера.
# Путь выбирает сервер: YouTube, который напрямую сейчас не отвечает,
# идёт через egress, даже если страницу открыли до поломки.
use_egress = bool(data.get("via_egress")) or dl.yt_egress_fallback(url)
probed = dl.cached_info(url, use_egress) or {}
try:
if images_mode:
# Пост-галерея: формат не выбираем, картинки берёт сам воркер.
fmt, extra, label = "", {}, "фото"
elif data.get("format_id"):
fmt, extra, label = dl.build_from_format_id(
str(data["format_id"]), str(data.get("container", "auto")))
else:
height = str(data.get("height", "auto"))
if tv_mode and height in ("auto", "2160", "1440", "1080"):
height = dl.TV_MAX_HEIGHT
fmt, extra, label = dl.build_format(
kind=str(data.get("kind", "video")),
height=height,
acodec=str(data.get("acodec", "best")),
vcodec=str(data.get("vcodec", "auto")),
vaudio=str(data.get("vaudio", "auto")),
alang=str(data.get("alang", "")),
portrait=dl.is_portrait(probed),
duration=probed.get("duration"),
size_cap=not clip and not data.get("is_live"),
)
except ValueError as e:
return err(message_for(e, "Неверный выбор формата"))
# Постобработка (чекбоксы): субтитры/обложка/метаданные/главы/SponsorBlock
# и отдельные файлы. Флаги — по белому списку внутри parse_features.
# К фото-режиму не применяем: там нет форматов и постпроцессоров.
bundle = False
clip_task = None
if not images_mode:
features = dl.parse_features(data)
kind_for_feat = "video" if data.get("format_id") else str(data.get("kind", "video"))
# Для аудио обложку и метаданные встраиваем всегда: музыке нужна
# обложка, а тянуть её вручную галочкой неудобно.
if kind_for_feat == "audio":
features["embed_thumbnail"] = True
features["embed_metadata"] = True
fmt, extra, feat_label, bundle = dl.apply_features(fmt, extra, features, kind_for_feat)
if feat_label:
label = f"{label} · {feat_label}"
if clip:
label = f"{label} · ✂ {dl.clip_label(*clip)}"
# Как резать, решает исполнитель: везде — частичная загрузка
# только нужного куска (YouTube — через racefd.SectionSession),
# а где дорожки так не умеют (HLS) — целиком и ffmpeg-обрезка.
clip_task = clip
title = str(data.get("title") or "Видео")[:200]
thumb = data.get("thumbnail")
if thumb and not str(thumb).startswith(("http://", "https://")):
thumb = None
# Плейлист целиком архивом. Список роликов сервер получает сам заново —
# от клиента берётся только ссылка на плейлист.
playlist_n = 0
# Пост с несколькими видео — все записи архивом (тем же путём, что
# плейлист). Число видео — из разбора сервера, не от клиента.
if data.get("multi") and not images_mode and not data.get("playlist"):
post = dl.multi_post(url)
if not post:
try:
dl.probe(url)
except Exception: # noqa: BLE001
pass
post = dl.multi_post(url)
if post:
# Номера записей в карусели — сквозные для фото и видео, поэтому
# предел — по всем записям, иначе видео в конце карусели не вошли бы.
playlist_n = min(post["entries"] or post["videos"], config.PLAYLIST_DL_MAX)
clip_task, bundle = None, False
what = f"{post['videos']} видео"
if post["images"]:
extra = {**extra, dl.POST_IMAGES_KEY: True}
what += f" + {post['images']} фото"
label = f"{what} · {label.split(' · ✂')[0]}"
if data.get("playlist"):
if images_mode or data.get("format_id") or not dl.is_youtube_list(url):
return err("Архивом можно скачать только плейлист или канал YouTube")
try:
listing = dl.probe_list(url)
except Exception: # noqa: BLE001
return err("Не удалось получить список роликов плейлиста")
playlist_n = min(len(listing["entries"]), config.PLAYLIST_DL_MAX)
if not playlist_n:
return err("В плейлисте нет доступных роликов")
clip_task, bundle = None, False # отрезок к плейлисту не применяется
title = str(listing.get("title") or "Плейлист")[:200]
thumb = (listing["entries"][0].get("thumbnail") or thumb)
label = f"Плейлист · {playlist_n} шт. · {label.split(' · ✂')[0]}"
if tv_mode and not (playlist_n or bundle):
extra = {**extra, dl.TV_KEY: True, "merge_output_format": "mkv"}
label = f"{label} · для ТВ"
try:
task = manager.create(url=url, fmt=fmt, extra=extra, label=label,
title=title, thumbnail=thumb, bundle=bundle,
is_live=bool(data.get("is_live")),
images_mode=images_mode,
use_egress=use_egress,
clip=clip_task, playlist=playlist_n)
except dl.Overloaded:
# честный отказ сразу, а не молчаливое ожидание в очереди
return err("Сервис сейчас перегружен, попробуйте через пару минут", 503)
stats.bump("downloads_started")
return jsonify(task.public()), 201
# Списка задач здесь сознательно нет.
# Раньше GET /api/tasks отдавал карточки ВСЕХ пользователей вместе с их
# ссылками и названиями, а по найденному там id можно было забрать чужой
# готовый файл: единственной защитой была неугадываемость id, и список её
# обнулял. Доступ к задаче — только по её идентификатору.
@app.get("/api/tasks/<tid>")
def api_task(tid):
t = manager.get(tid)
return jsonify(t.public()) if t else err("Задача не найдена", 404)
@app.post("/api/tasks/<tid>/cancel")
def api_cancel(tid):
# Различаем «задачи нет» и «отменять нечего»: раньше несуществующая
# задача давала 409, хотя соседние ручки на неё отвечают 404.
if not manager.get(tid):
return err("Задача не найдена", 404)
return (jsonify({"cancelled": True}) if manager.cancel(tid)
else err("Задача уже завершена", 409))
@app.get("/api/tasks/<tid>/progress")
def api_progress(tid):
if not manager.get(tid):
return err("Задача не найдена", 404)
def gen():
last = None
# Поток держится всё время загрузки, поэтому ограничиваем его срок:
# иначе несколько зависших соединений исчерпают пул gunicorn.
# Браузер переподключит EventSource автоматически.
deadline = time.time() + config.SSE_MAX_SECONDS
last_write = time.time()
while time.time() < deadline:
t = manager.get(tid)
if not t:
yield f"data: {json.dumps({'status': 'gone'})}\n\n"
return
payload = json.dumps(t.public(), ensure_ascii=False)
now = time.time()
if payload != last:
yield f"data: {payload}\n\n"
last, last_write = payload, now
elif now - last_write > 15:
# Пока статус не меняется (очередь, долгая склейка), в сокет
# не шло ни байта, и обрыв клиента оставался незамеченным:
# поток жил до самого дедлайна. Периодический комментарий
# заставляет запись упасть на закрытом соединении.
yield ": ping\n\n"
last_write = now
if t.status in ("finished", "error", "cancelled", "served"):
return
time.sleep(0.5)
# мягкий разрыв: клиент переподключится и продолжит следить
yield ": timeout\n\n"
return Response(stream_with_context(gen()), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive"})
@app.get("/api/tasks/<tid>/file")
def api_file(tid):
t = manager.get(tid)
if not t:
return err("Задача не найдена", 404)
if t.status == "served":
return err("Файл уже удалён с сервера — запустите загрузку заново", 410)
if t.status != "finished" or not t.filename:
return err("Файл ещё не готов", 409)
path = dl.safe_download_path(t.filename)
if not path:
return err("Файл удалён с сервера — запустите загрузку заново", 410)
# Отмечаем момент выдачи; уборщик удалит файл через grace-период.
# Не используем resp.call_on_close: send_file включает direct_passthrough,
# и WSGI закрывает файловую обёртку, а не Response — колбэк не сработает.
try:
resp = send_file(path, as_attachment=True,
download_name=t.display_name or t.filename)
except (FileNotFoundError, OSError):
# Уборщик успел удалить файл между проверкой и открытием.
return err("Файл удалён с сервера — запустите загрузку заново", 410)
if manager.mark_served(t):
# считаем только первую выдачу, чтобы докачка не удваивала цифры
stats.bump("files_served")
stats.bump("bytes_served", t.filesize or 0)
return resp
def _cacheable(resp, seconds: int):
"""Короткое кэширование в браузере.
Данные меняются медленно, а частая перезагрузка страницы иначе
превращается в поток одинаковых запросов и упирается в rate limit.
"""
resp.headers["Cache-Control"] = f"public, max-age={seconds}"
return resp
@app.get("/api/stats")
def api_stats():
return _cacheable(jsonify(stats.summary()), 15)
@app.get("/api/feed/events")
def api_feed_events():
"""Времена отдельных скачиваний одного ролика."""
if not rate_ok("info", config.RATE_MAX_INFO):
return err("Слишком много запросов, подождите немного", 429)
url = request.args.get("url", "")
if not url:
return err("Не указана ссылка")
return _cacheable(jsonify({
"url": url,
"times": stats.events_for(dl.canonical_url(url)),
"retention_days": config.EVENT_RETENTION_DAYS,
}), 15)
@app.get("/api/feed")
def api_feed():
order = "popular" if request.args.get("order") == "popular" else "recent"
cursor = request.args.get("cursor") or None
items = stats.feed(order=order, limit=config.FEED_PAGE_SIZE, cursor=cursor)
return _cacheable(jsonify({
"items": items,
"totals": stats.feed_totals(),
"page_size": config.FEED_PAGE_SIZE,
# Листаем по ключу сортировки: при OFFSET записи сдвигались между
# запросами страниц, потому что last_seen меняется при каждом
# скачивании — одни попадали на две страницы, другие ни на одну.
"next_cursor": (stats.encode_cursor(items[-1], order)
if len(items) == config.FEED_PAGE_SIZE else None),
}), 15)
@app.get("/stats")
def stats_page():
# Данные вшиваются прямо в страницу: иначе она сначала рисуется с
# прочерками и только потом, после асинхронного запроса, показывает
# цифры — заметное мигание при каждой загрузке.
_initial_feed = stats.feed(order="recent", limit=config.FEED_PAGE_SIZE)
return render_template("stats.html", initial={
"stats": stats.summary(),
"feed": {
"items": _initial_feed,
"totals": stats.feed_totals(),
"page_size": config.FEED_PAGE_SIZE,
"next_cursor": (stats.encode_cursor(_initial_feed[-1], "recent")
if len(_initial_feed) == config.FEED_PAGE_SIZE else None),
},
})
if __name__ == "__main__":
app.run(host=config.HOST, port=config.PORT, threaded=True)