Пагинация ленты. Листание шло через 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
92 lines
7.2 KiB
Python
92 lines
7.2 KiB
Python
"""Конфигурация сервиса. Значения переопределяются переменными окружения."""
|
||
import os
|
||
from pathlib import Path
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
DOWNLOAD_DIR = Path(os.environ.get("YTG_DOWNLOAD_DIR", BASE_DIR / "downloads"))
|
||
# Каталог для служебных данных (обезличенные счётчики). Вынесен отдельно,
|
||
# потому что systemd монтирует остальной проект только на чтение.
|
||
DATA_DIR = Path(os.environ.get("YTG_DATA_DIR", BASE_DIR / "data"))
|
||
|
||
# Сеть
|
||
HOST = os.environ.get("YTG_HOST", "127.0.0.1")
|
||
PORT = int(os.environ.get("YTG_PORT", "8090"))
|
||
|
||
# Публичный сервис -> защита от абуза
|
||
MAX_CONCURRENT_DOWNLOADS = int(os.environ.get("YTG_MAX_CONCURRENT", "3"))
|
||
# Глубина очереди ожидания и потолок на число карточек задач в памяти.
|
||
# Оба — защита от переполнения: переполнить память или число потоков
|
||
# не должно получаться в принципе.
|
||
QUEUE_MAX = int(os.environ.get("YTG_QUEUE_MAX", "50"))
|
||
TASKS_MAX = int(os.environ.get("YTG_TASKS_MAX", "500"))
|
||
|
||
# Параллельная загрузка фрагментов (DASH/HLS — то, чем отдаёт YouTube).
|
||
# Без этого фрагменты тянутся строго по одному, и скорость упирается в
|
||
# задержку до сервера, а не в канал. Держим умеренным: значение умножается
|
||
# на число одновременных загрузок, а хост — гипервизор.
|
||
CONCURRENT_FRAGMENTS = int(os.environ.get("YTG_CONCURRENT_FRAGMENTS", "5"))
|
||
|
||
# Прокси для исходящих запросов yt-dlp (socks5://host:port или http://...).
|
||
# Задаётся ТОЛЬКО администратором через окружение и никогда не принимается
|
||
# от пользователя: произвольный прокси — это готовый SSRF.
|
||
# Нужен там, где сайт недоступен напрямую из сети хоста.
|
||
PROXY = os.environ.get("YTG_PROXY", "").strip()
|
||
MAX_FILESIZE_MB = int(os.environ.get("YTG_MAX_FILESIZE_MB", "2048")) # потолок на один файл
|
||
MAX_DURATION_SEC = int(os.environ.get("YTG_MAX_DURATION_SEC", str(4 * 3600))) # 4 часа
|
||
DISK_QUOTA_MB = int(os.environ.get("YTG_DISK_QUOTA_MB", "5120")) # 5 ГБ на всю папку
|
||
# Сервис живёт на Proxmox-хосте: мало оставить место себе, нужно не съесть
|
||
# его у гипервизора. Если на разделе свободно меньше — новые загрузки не
|
||
# принимаются, независимо от квоты выше.
|
||
MIN_FREE_DISK_MB = int(os.environ.get("YTG_MIN_FREE_DISK_MB", "10240")) # 10 ГБ
|
||
|
||
# --- Приватность: сервер не хранит ничего ---
|
||
# Файл удаляется после того, как пользователь его забрал.
|
||
DELETE_AFTER_SERVE = os.environ.get("YTG_DELETE_AFTER_SERVE", "1") == "1"
|
||
# Отсрочка после начала выдачи: запас на докачку по Range и повторный клик.
|
||
SERVED_GRACE_SEC = int(os.environ.get("YTG_SERVED_GRACE_SEC", "1800"))
|
||
# Как часто работает уборщик.
|
||
JANITOR_INTERVAL_SEC = int(os.environ.get("YTG_JANITOR_INTERVAL_SEC", "30"))
|
||
# Жёсткий потолок: ни один файл не живёт на сервере дольше этого,
|
||
# независимо от того, забрали его или нет. Отсчёт — от готовности файла.
|
||
# 2 часа: за это время файл в 2 ГБ (наш потолок) успевает скачаться даже
|
||
# на 2,3 Мбит/с. Общий объём ограничивает не время, а квота DISK_QUOTA_MB.
|
||
FILE_TTL_MINUTES = int(os.environ.get("YTG_FILE_TTL_MINUTES", "120"))
|
||
# Карточки задач держатся в памяти недолго и нигде не персистятся.
|
||
# Срок заведомо больше FILE_TTL_MINUTES: карточка отсчитывается от создания
|
||
# задачи, а файл — от окончания скачивания, поэтому при равных значениях
|
||
# карточка истекала раньше файла ровно на длительность загрузки, и файл
|
||
# оставался на диске недостижимым, занимая квоту.
|
||
TASK_TTL_MINUTES = int(os.environ.get("YTG_TASK_TTL_MINUTES", "240"))
|
||
# Не писать URL/IP пользователей в логи.
|
||
QUIET_ACCESS_LOG = os.environ.get("YTG_QUIET_ACCESS_LOG", "1") == "1"
|
||
|
||
# Rate limiting (простое, in-memory, на IP)
|
||
RATE_WINDOW_SEC = int(os.environ.get("YTG_RATE_WINDOW_SEC", "60"))
|
||
RATE_MAX_INFO = int(os.environ.get("YTG_RATE_MAX_INFO", "20")) # запросов /api/info за окно
|
||
RATE_MAX_DOWNLOAD = int(os.environ.get("YTG_RATE_MAX_DOWNLOAD", "6")) # запусков загрузки за окно
|
||
|
||
# Заголовки, которым доверяем реальный IP (за reverse-proxy)
|
||
TRUST_PROXY = os.environ.get("YTG_TRUST_PROXY", "1") == "1"
|
||
|
||
# Максимальный срок одного SSE-соединения. Каждое занимает поток gunicorn,
|
||
# поэтому рвём его принудительно — EventSource переподключится сам.
|
||
SSE_MAX_SECONDS = int(os.environ.get("YTG_SSE_MAX_SECONDS", "900"))
|
||
|
||
# --- Публичная лента «что скачивают» ---
|
||
# Храним ТОЛЬКО адрес ролика, дату и счётчик. Ни названия, ни обложки:
|
||
# их подтягивает браузер напрямую с источника.
|
||
FEED_ENABLED = os.environ.get("YTG_FEED_ENABLED", "1") == "1"
|
||
FEED_PAGE_SIZE = int(os.environ.get("YTG_FEED_PAGE_SIZE", "50"))
|
||
# Сколько хранить времена отдельных скачиваний. Таблица растёт на строку с
|
||
# каждой загрузкой, и без предела превращается в вечный журнал активности.
|
||
EVENT_RETENTION_DAYS = int(os.environ.get("YTG_EVENT_RETENTION_DAYS", "90"))
|
||
|
||
# Через сколько считать задачу зависшей и снимать её. Отдельная настройка,
|
||
# а не множитель от TASK_TTL: прежняя запись `TASK_TTL_MINUTES * 120`
|
||
# читалась как путаница минут с секундами, хотя означала «два TTL».
|
||
STUCK_TASK_SEC = int(os.environ.get("YTG_STUCK_TASK_SEC", str(8 * 3600)))
|
||
|
||
# Сроки хранения. Обе таблицы росли бессрочно, а ленту вдобавок целиком
|
||
# перечитывала миграция при каждом старте. 0 отключает очистку.
|
||
FEED_RETENTION_DAYS = int(os.environ.get("YTG_FEED_RETENTION_DAYS", "365"))
|
||
DAILY_RETENTION_DAYS = int(os.environ.get("YTG_DAILY_RETENTION_DAYS", "730"))
|