diff --git a/.env.example b/.env.example
index cfb12e7..f86b298 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,10 @@ DEEPGRAM_UTT_SPLIT=0.8
OPENROUTER_API_KEY=
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
+# Целевой уровень качества URL-видео: 144..4320 или best.
+# Это мягкая цель: если формата не выше цели нет, берётся ближайший вариант выше.
+VIDEO_TARGET_RESOLUTION=720
+
# Модели LLM через OpenRouter (приоритетный список, fallback при 429)
LLM_MODELS_SPLIT=google/gemini-3.6-flash,google/gemini-3.5-flash,google/gemini-3.5-flash-lite
LLM_MODELS_SUBSPLIT=google/gemini-3.6-flash,google/gemini-3.5-flash,google/gemini-3.5-flash-lite
diff --git a/Dockerfile b/Dockerfile
index d6fc61f..947866d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,14 +11,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# JS-рантайм для yt-dlp (YouTube требует исполнения JS-челленджей).
# Установщик кладёт бинарь в $DENO_INSTALL/bin/deno → /usr/local/bin/deno.
RUN apt-get update && apt-get install -y --no-install-recommends curl unzip \
- && curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh \
+ && curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s v2.9.3 \
&& apt-get purge -y curl unzip && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /install /usr/local
-# Свежий yt-dlp (старая версия не тянет актуальный YouTube).
-# Устанавливаем ПОСЛЕ COPY из builder, чтобы не перезатёрло свежую версию.
-RUN pip install --no-cache-dir --upgrade yt-dlp
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir --no-deps -e .
diff --git a/README.md b/README.md
index 2a7d16b..5c30499 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,8 @@
HTTP-сервис обработки лекций: на вход — лекция в виде аудиозаписи, видеофайла или
-ссылки на видео (YouTube/HTTP), опционально + слайды PDF/PPTX; на выходе —
+ссылки на видео (YouTube, публичные посты X и обычные HTTP(S)-URL), опционально +
+слайды PDF/PPTX; на выходе —
структурированный конспект в формате Obsidian (Markdown + нарезанные
медиафрагменты + слайды), упакованный в ZIP.
@@ -110,6 +111,12 @@ Expiration 1 день (временные ZIP от `/result-url`). Префик
питон-пакеты `opencv-python-headless` и `numpy` (локальный анализ видеоряда для
стадии `video_slides`).
+Для X поддерживаются публичные `x.com`/`twitter.com`-посты без cookies. Если пост
+содержит несколько видео, URL поста выбирает первое вложение, а суффикс `/video/N` —
+явно указанное. Закрытые, удалённые и требующие авторизации посты не поддерживаются.
+Качество всех URL-видео задаёт `VIDEO_TARGET_RESOLUTION`: числовое значение является
+ориентационно-независимой мягкой целью, `best` снимает ограничение.
+
Проверка:
```bash
@@ -287,6 +294,7 @@ python scripts/submit_task.py submit --audio lecture.mp3 --slides slides.pdf
# видео (слайды извлекаются из видеоряда автоматически)
python scripts/submit_task.py submit --video lecture.mp4
python scripts/submit_task.py submit --video-url "https://youtu.be/abc"
+python scripts/submit_task.py submit --video-url "https://x.com/i/status/2078106556634124335"
# видео без слайдов
python scripts/submit_task.py submit --video lecture.mp4 --no-slides
@@ -340,6 +348,7 @@ python scripts/submit_task.py --base http://my-host:8000/api/v1 status
| `DEEPGRAM_UTT_SPLIT` | Порог паузы utterance в секундах (по умолчанию `0.8`). |
| `OPENROUTER_API_KEY` | Ключ OpenRouter; LLM-вызовы идут через BYOK Google AI Studio. |
| `OPENROUTER_BASE_URL` | Base URL OpenRouter (по умолчанию `https://openrouter.ai/api/v1`). |
+| `VIDEO_TARGET_RESOLUTION` | Целевое разрешение URL-видео: `144..4320` либо `best`; по умолчанию `720`. |
| `LLM_MODELS_*` | Приоритетные списки моделей по этапам структуризации (fallback при 429). |
| `LLM_CONCURRENCY_*` | Параллельность вызовов LLM по этапам. |
| `LLM_EFFORT_*` | Reasoning effort по этапам структуризации (по умолчанию `low`). |
diff --git a/deploy/env.core.example b/deploy/env.core.example
index d5f970d..c025e2b 100644
--- a/deploy/env.core.example
+++ b/deploy/env.core.example
@@ -25,6 +25,9 @@ DEEPGRAM_UTT_SPLIT=0.8
OPENROUTER_API_KEY=
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
+# Целевой уровень качества URL-видео: 144..4320 или best.
+VIDEO_TARGET_RESOLUTION=720
+
LLM_MODELS_SPLIT=google/gemini-3.6-flash,google/gemini-3.5-flash,google/gemini-3.5-flash-lite
LLM_MODELS_SUBSPLIT=google/gemini-3.6-flash,google/gemini-3.5-flash,google/gemini-3.5-flash-lite
LLM_MODELS_RENDER=google/gemini-3.5-flash-lite,google/gemini-3.6-flash,google/gemini-3.5-flash
diff --git a/docs/plans/2026-07-19-x-video-ingestion.md b/docs/plans/2026-07-19-x-video-ingestion.md
new file mode 100644
index 0000000..d03fa0f
--- /dev/null
+++ b/docs/plans/2026-07-19-x-video-ingestion.md
@@ -0,0 +1,456 @@
+# План: загрузка видео из X и ориентационно-независимый выбор разрешения
+
+Дата: 2026-07-19
+
+Ветка: `plan/x-video-ingestion`
+
+База реализации: `origin/dev@dadd025bc827572e4542a4045db52c93cc378a63`
+
+Worktree: `/root/lecturelog-core/.worktrees/x-video-ingestion`
+
+## 1. Цель
+
+Добавить X (`x.com` и `twitter.com`) как явно поддерживаемый источник `video_url`, не
+меняя внешний API задачи и последующие стадии обработки. Одновременно убрать
+зашитое в команду `yt-dlp` ограничение `height<=720`, которое ошибочно понижает
+вертикальное видео до `320x568`, и заменить его на настраиваемую,
+ориентационно-независимую политику качества.
+
+После изменения одна и та же настройка `720` должна выбирать:
+
+- `1280x720` для горизонтального видео;
+- `720x1280` для вертикального видео;
+- лучший доступный вариант на целевом уровне или ниже; если таких форматов нет,
+ `yt-dlp` мягко деградирует к наименьшему варианту выше цели, а не роняет задачу;
+- максимальное доступное разрешение, если администратор явно выбрал `best`;
+- progressive HTTPS используется как tie-breaker при одинаковом разрешении, но не
+ имеет приоритета над видео более высокого качества.
+
+## 2. Подтверждённое текущее состояние
+
+### 2.1. Контракт и пайплайн
+
+- `POST /api/v1/tasks` уже принимает `video_url`; новый request-параметр не нужен.
+- `VideoUrlSource.kind` уже равен `video_url`, поэтому БД, `source_kind`, usage и
+ webhook менять не требуется.
+- После ingest источник становится локальным видео. Извлечение аудио, транскрибация,
+ кадры, нарезка и экспорт не зависят от исходного сайта.
+- `VideoIngestor` передаёт любой HTTP(S)-URL в один метод `_download_youtube` и для
+ всех сайтов применяет YouTube cookies, Deno и EJS.
+- Формат зафиксирован как
+ `bestvideo[height<=720]+bestaudio/best[height<=720]`. Для portrait-видео это
+ ограничение физической высоты, а не уровня разрешения.
+- `--no-playlist` сейчас отсутствует. Для X-поста с несколькими видео один URL может
+ породить несколько entries при единственном ожидаемом файле `video.mp4`.
+
+### 2.2. Живые проверки `yt-dlp 2026.07.04`
+
+Проверка выполнялась без cookies и без JS runtime:
+
+| Сценарий | Результат |
+|---|---|
+| X GraphQL API | работает анонимно |
+| X syndication API | работает анонимно; пригоден как fallback |
+| X legacy API | на проверенных постах возвращает 404; не использовать |
+| Пять параллельных запросов к публичному посту | все успешны без cookies |
+| Обычный X-пост с несколькими видео | возвращает playlist |
+| `--no-playlist` для такого поста | недостаточно: entries всё равно несколько |
+| `--playlist-items 1` | детерминированно выбирает первое вложение |
+| URL с `/video/2` + `--playlist-items 1` | сохраняет явно выбранное второе вложение |
+
+Контрольная ссылка `https://x.com/i/status/2078106556634124335`:
+
+- длительность `2376.934` секунды;
+- варианты до `1920x1080`;
+- готовый progressive MP4 `1280x720` — примерно 115 MiB;
+- готовый progressive MP4 `1920x1080` — примерно 333 MiB;
+- cookies не нужны;
+- production-selector проекта выбирает `1280x720`, но через HLS video + audio с
+ последующим merge; готовый MP4 того же качества уже существует.
+
+Контрольное portrait-видео `1080x1920`:
+
+- текущий `height<=720` выбирает `320x568`;
+- `-S res:720` выбирает `720x1280`;
+- без лимита выбирается `1080x1920`.
+
+## 3. Решения и границы
+
+### 3.1. Что входит
+
+1. Явное распознавание YouTube, X и остальных URL-профилей внутри media
+ infrastructure.
+2. Отдельные аргументы `yt-dlp` для X без YouTube credentials и JS-компонентов.
+3. GraphQL как основной X backend, syndication как один ограниченный fallback.
+4. Детерминированная обработка постов с несколькими видео.
+5. Общая ориентационно-независимая настройка разрешения.
+6. Точные unit/integration-тесты команд, fallback и API-контракта.
+7. Документация конфигурации, публичных ограничений X и rollout.
+
+### 3.2. Что не входит
+
+- X API, платные downloader-сервисы и self-hosted Cobalt.
+- Хранение X cookies или endpoint управления ими. Первая версия поддерживает
+ публично доступные X-видео; protected/private/age-gated контент не обещается.
+- Объединение нескольких вложенных видео в одну лекцию. При URL без `/video/N`
+ берётся первое видео; пользователь может выбрать другое суффиксом `/video/N`.
+- Изменение `POST /tasks`, таблицы `tasks`, `usage`, result layout или webhook.
+- Полная защита generic URL ingest от SSRF. Текущий контракт также обещает обычные
+ HTTP-video URL, а безопасное закрытие redirect/DNS-rebinding требует отдельного
+ решения. В этой задаче нельзя незаметно удалить существующую generic-функцию.
+
+### 3.3. Политика качества
+
+Добавить `MediaConfig` и переменную:
+
+```env
+VIDEO_TARGET_RESOLUTION=720
+```
+
+Значение — строка: целое число от `144` до `4320` либо `best`.
+
+- Дефолт `720` сохраняет текущий объём трафика и диска, но исправляет portrait.
+- `1080` разрешает Full HD в обеих ориентациях.
+- `best` снимает лимит и выбирает максимум источника.
+- В `yt-dlp` числовое значение реализуется через мягкую сортировку
+ `-S res:,proto:https`, а не через фильтр `height<=...`.
+- Это target, не строгий ceiling: если источник не предлагает ни одного формата на
+ целевом уровне или ниже, выбирается наименьший формат выше цели. Такое поведение
+ лучше полного отказа ingest и должно быть отражено в тестах и документации.
+- Для `best` используется quality-first сортировка `-S res,proto:https`: сначала
+ максимальное разрешение, затем предпочтение готового progressive HTTPS среди
+ вариантов того же уровня.
+- Настройка применяется ко всем URL-источникам, чтобы YouTube и X не имели разных
+ трактовок `720p`.
+- Загруженные пользователем файлы не перекодируются и этой настройке не подчиняются.
+
+Дефолт намеренно остаётся `720`: контрольное 39-минутное видео занимает около
+115 MiB в 720p и 333 MiB в 1080p, а затем участвует в извлечении кадров и нарезке.
+Это настройка эксплуатационного профиля, а не скрытый hard cap: prod может перейти
+на `1080` или `best` отдельным env-изменением после замера диска и времени обработки.
+
+## 4. Проектирование реализации
+
+### 4.1. Классификация URL
+
+В `lecturelog/infrastructure/media/url_utils.py` добавить чистую классификацию
+`VideoUrlKind` (`youtube`, `x`, `generic`) по нормализованному hostname.
+
+X-hostnames:
+
+- `x.com`, `www.x.com`, `mobile.x.com`, `m.x.com`;
+- `twitter.com`, `www.twitter.com`, `mobile.twitter.com`, `m.twitter.com`.
+
+YouTube-hostnames сохраняют существующие `youtube.com`-поддомены и `youtu.be`.
+Сравнение должно работать без учёта регистра, игнорировать завершающую точку DNS и
+не принимать suffix-ловушки вроде `x.com.evil.example`.
+
+API продолжает проверять лишь корректность HTTP(S)-URL и не меняет wire-контракт.
+Классификация нужна для выбора downloader profile, а не для запрета generic URL.
+
+### 4.2. Конфигурация
+
+В `lecturelog/config/settings.py`:
+
+- добавить `MediaConfig` с `target_resolution: str`;
+- валидировать `best` или числовой диапазон `144..4320`;
+- включить `media` в eager validation `AppConfig.model_post_init`;
+- передать `cfg.media.target_resolution` в `VideoIngestor` из lifespan.
+
+Обновить `.env.example` и `deploy/env.core.example`. В compose новое поле явно
+пробрасывать не требуется: оба deployment-варианта уже используют `env_file`.
+
+### 4.3. Профили команд `yt-dlp`
+
+Переименовать `_download_youtube` в нейтральный `_download_url` и выделить чистые
+builders аргументов, чтобы unit-тесты проверяли контракт без сети.
+
+Общие аргументы:
+
+```text
+--no-playlist
+--playlist-items 1
+--merge-output-format mp4
+--no-progress
+--print after_move:filepath
+-S res:,proto:https # числовая настройка
+-S res,proto:https # настройка best
+-o /video.%(ext)s
+```
+
+Фиксированное имя `video.mp4` не использовать: оно мешает доказать, что extractor
+создал ровно один final output, и может скрыть второй playlist entry.
+
+Профиль YouTube:
+
+```text
+-f bv*+ba/b
+--js-runtimes deno
+--remote-components ejs:github
+--cookies # только если YouTube CookieStore непуст
+```
+
+Профиль X:
+
+```text
+-f bv*+ba/b
+--extractor-args twitter:api=graphql
+```
+
+Так качество и целевое разрешение остаются первым критерием. `proto:https` отдаёт
+готовому progressive MP4 предпочтение лишь среди равноценных по разрешению
+вариантов; если лучший вариант существует только как HLS/split streams, он не будет
+потерян ради progressive. YouTube cookies, Deno и EJS в X-команду не попадают.
+
+Профиль generic сохраняет текущую возможность `yt-dlp`, но получает новую
+ориентационно-независимую сортировку. YouTube cookies generic-профилю не передаются.
+
+### 4.4. X fallback
+
+Выбирать X backend в отдельной metadata/extraction-only фазе до скачивания:
+
+1. Выполнить GraphQL preflight через `yt-dlp --simulate` с тем же format selector,
+ quality sort, `--no-playlist` и `--playlist-items 1`.
+2. Если GraphQL extraction неуспешен, выполнить ровно один syndication preflight.
+3. Выбранный успешным preflight backend использовать для единственного download
+ subprocess.
+4. Ошибка download/merge/write после успешного preflight не переключает backend и
+ не начинает скачивание заново.
+5. Если оба preflight упали, вернуть одну типизированную ошибку с санитизированной
+ диагностикой обеих попыток.
+
+Fallback запускается только для X. `legacy` не используется. Бесконечных ретраев
+нет; транспортные ретраи внутри самого `yt-dlp` остаются его ответственностью.
+`FileNotFoundError` при запуске бинаря обрабатывается до fallback. Поскольку fallback
+заканчивается до download phase, `ENOSPC`, permission denied и ошибка ffmpeg merge
+по определению не могут запустить вторую полную загрузку.
+
+Preflight добавляет один metadata round-trip к X, зато делает failure domains
+наблюдаемыми и не требует угадывать фазу по общему stderr одного subprocess.
+
+### 4.5. Файл результата и multi-video
+
+- Входной URL без `/video/N`: `--playlist-items 1` выбирает первое видео поста.
+- URL с `/video/N`: extractor возвращает конкретное вложение; это поведение
+ покрывается тестом.
+- Download выполняется в новом isolated attempt directory с template
+ `video.%(ext)s`; каталог принадлежит только этому subprocess.
+- Итоговый путь брать из единственной строки `--print after_move:filepath`, а не из
+ сортировки `glob()`.
+- Напечатанный путь должен существовать, находиться внутри attempt directory и быть
+ ровно один. Ноль или несколько final paths — явная ошибка.
+- После проверки файл атомарно переносится в корень `output_dir` как
+ `video.`; насильно переименовывать WebM/MKV в `.mp4` нельзя.
+- Directory scan остаётся только защитной сверкой с напечатанным final path, а не
+ механизмом выбора `candidates[0]`.
+- `.part`, `.ytdl` и файлы неуспешной попытки не должны попасть в дальнейший pipeline.
+
+### 4.6. Ошибки и публичный контракт
+
+Не расширять `ErrorCode` в этой задаче:
+
+- YouTube bot-check остаётся `cookies_invalid`, но только если ошибка пришла из
+ YouTube profile.
+- X HTTP 429 / rate-limit → `rate_limit`.
+- удалённый пост, пост без видео, protected/private/age-gated без доступа →
+ `bad_input` с безопасным понятным текстом;
+- отсутствие бинаря, диск, permission, merge и неизвестные ошибки → `internal`.
+
+Добавить domain-level `MediaIngestError`, не зависящий от `yt-dlp`, с полями
+`source_kind`, `reason`, `public_message` и `diagnostic`. `reason` как минимум
+различает `not_found`, `auth_required`, `rate_limit`, `tool_missing`, `local_io`,
+`extractor` и `invalid_output`. `classify_error` маппит только `reason`, а не raw
+stderr. `str(error)` возвращает только `public_message`, потому что `PipelineService`
+сохраняет строку исключения и traceback в `Task.error`.
+
+Диагностика логируется отдельно после удаления полного source URL/query string,
+cookie paths и потенциальных bearer/cookie значений и ограничивается по длине.
+Нельзя классифицировать X-фразу `sign in` как требование обновить YouTube cookies.
+
+Wire-формы `GET /tasks/{id}` и webhook не меняются. OpenAPI snapshot после работы
+должен остаться без diff; если генератор всё же изменит его, изменение нужно
+объяснить, а не коммитить автоматически.
+
+### 4.7. Воспроизводимые зависимости
+
+Зафиксировать проверенный runtime-профиль:
+
+```toml
+"yt-dlp[default]==2026.7.4"
+```
+
+Extra `default` включает `yt-dlp-ejs`, необходимый актуальному YouTube extractor.
+Docker builder ставит project dependency один раз; отдельный
+`pip install --upgrade yt-dlp` в runtime удалить, иначе локальные тесты, metadata
+package и production image используют разные версии.
+
+Deno также закрепить на проверенной версии `2.9.3`, передавая версию официальному
+installer, вместо нефиксированного latest. Существование pin подтверждено официальным
+релизом [`v2.9.3`](https://github.com/denoland/deno/releases/tag/v2.9.3),
+опубликованным 2026-07-15. Версии yt-dlp/Deno обновляются отдельным dependency PR с
+smoke matrix; срочное обновление extractor остаётся маленьким явным изменением, а не
+скрытым результатом очередной Docker-сборки.
+
+## 5. Карта изменений по файлам
+
+| Файл | Изменение |
+|---|---|
+| `lecturelog/config/settings.py` | `MediaConfig`, валидация resolution, wiring в `AppConfig` |
+| `lecturelog/api/lifespan.py` | передача media config в ingestor |
+| `lecturelog/infrastructure/media/url_utils.py` | точная классификация YouTube/X/generic hostname |
+| `lecturelog/infrastructure/media/video_ingestor.py` | source-aware profiles, quality sort, X fallback, multi-video invariant |
+| `lecturelog/application/error_classifier.py` | source-aware media error mapping |
+| `lecturelog/domain/exceptions.py` или media-local exceptions | типизированная ошибка downloader без изменения публичного enum |
+| `.env.example` | документировать `VIDEO_TARGET_RESOLUTION` |
+| `deploy/env.core.example` | production-конфиг resolution |
+| `README.md` | X support, public-only scope, multi-video и quality config |
+| `pyproject.toml` | закрепить `yt-dlp[default]==2026.7.4` |
+| `Dockerfile` | единая project install, удалить floating upgrade, закрепить Deno `2.9.3` |
+| `tests/unit/test_url_utils.py` | hostname matrix и suffix traps |
+| `tests/unit/test_config.py` | default, numeric, `best`, invalid values |
+| `tests/unit/test_video_ingestor*.py` | точные argv, cookies isolation, fallback, cleanup, multi-video |
+| `tests/unit/test_error_classifier*.py` | X/YouTube/error-phase classification |
+| `tests/integration/test_api_video_contract.py` | `x.com/i/status/...` проходит неизменный API |
+
+## 6. Последовательность реализации
+
+### Шаг 1. Конфигурация и чистые контракты
+
+1. Добавить `MediaConfig` и его unit-тесты.
+2. Добавить `VideoUrlKind` и hostname matrix.
+3. Ввести типизированную внутреннюю media download error.
+4. Подключить config к lifespan без изменения API.
+
+Критерий: config и URL tests зелёные; текущие YouTube tests ещё сохраняют поведение.
+
+### Шаг 2. Рефакторинг без X fallback
+
+1. Переименовать нейтральные методы.
+2. Выделить builders общих/YouTube/X/generic arguments.
+3. Заменить `height<=720` на format sorting.
+4. Ограничить чтение CookieStore только YouTube profile.
+5. Сделать invariant одного итогового файла.
+
+Критерий: YouTube unit-тесты зелёные; portrait unit-case ожидает `res:720`, а не
+`height<=720`; API и pipeline tests без регрессий.
+
+### Шаг 3. X и fallback
+
+1. Добавить quality-first X selector с progressive tie-breaker.
+2. Добавить GraphQL metadata preflight и syndication preflight fallback.
+3. Скачать ровно один раз выбранным backend в isolated attempt directory.
+4. Валидировать единственный `after_move:filepath` и безопасно перенести final.
+5. Добавить deterministic first media и `/video/N` contracts.
+6. Доработать structured error classification и sanitization.
+
+Критерий: orchestration unit-тесты с замоканным subprocess доказывают порядок
+preflight/download, отсутствие YouTube cookies/JS flags и отсутствие повторного
+download при local failure. Отдельный hermetic format-matrix test проверяет реальное
+поведение закреплённого `yt-dlp`, а не только состав argv.
+
+### Шаг 4. Dependencies и документация
+
+1. Закрепить `yt-dlp[default]==2026.7.4`, удалить runtime floating upgrade и
+ закрепить Deno `2.9.3`.
+2. Обновить env examples и README.
+3. Проверить отсутствие незапланированного OpenAPI diff.
+
+### Шаг 5. Валидация
+
+Запустить из worktree:
+
+```bash
+ruff check .
+ruff format --check .
+pytest -q
+python scripts/export_openapi.py
+git diff --exit-code -- docs/openapi.json
+git diff --check
+```
+
+Добавить hermetic format matrix на synthetic/pinned info JSON с горизонтальными и
+вертикальными formats, progressive/HLS на разных уровнях, отсутствием формата ниже
+target и режимом `best`. Тест должен прогонять реальную логику format selection
+закреплённого `yt-dlp` и доказывать:
+
+- target `720` выбирает `1280x720` и `720x1280`;
+- при отсутствии `<=720` выбирается наименьший формат выше target;
+- более низкий progressive не побеждает более высокий HLS;
+- при одинаковом разрешении предпочтителен progressive HTTPS;
+- `best` выбирает максимальное разрешение.
+
+Также добавить sentinel CookieStore, чей `get()` падает: X и generic ingest не должны
+его вызывать. Fake subprocess должен уметь напечатать ноль, один и два
+`after_move:filepath`, чтобы проверить output invariant и path containment.
+
+Затем собрать runtime image и проверить точные версии: `yt-dlp 2026.07.04`,
+`yt-dlp-ejs 0.8.0`, Deno `2.9.3`, а также наличие ffmpeg/ffprobe. Live X smoke не
+включать в обычный CI: внешний пост может быть удалён, а X может временно rate-limit
+CI IP.
+
+Ручная acceptance matrix:
+
+1. Публичный X single-video URL без cookies, `VIDEO_TARGET_RESOLUTION=720`.
+2. Тот же URL с `1080` и проверкой `ffprobe`.
+3. Portrait X URL: выбран `720x1280`, не `320x568`.
+4. Multi-video URL без индекса: скачано первое и ровно одно вложение.
+5. Multi-video URL `/video/2`: скачано второе вложение.
+6. Искусственный GraphQL preflight failure: syndication preflight выполняется один
+ раз, после чего идёт ровно один download.
+7. Удалённый/закрытый X post: задача завершается `bad_input`, не
+ `cookies_invalid` и не зависает в `video_ingest`.
+8. Обычный YouTube URL с текущими cookies: поведение не регрессировало.
+9. YouTube bot-check: по-прежнему `cookies_invalid`.
+10. Generic HTTP video URL: существующая совместимость сохранена.
+
+Для длинного контрольного X-видео достаточно проверить ingest и `ffprobe`; полный
+LLM pipeline запускать один раз на dev-стенде, чтобы не дублировать платные вызовы.
+
+## 7. Rollout и откат
+
+1. Реализация идёт в отдельной feature-ветке от актуального `dev`.
+2. После локальных тестов — отдельное code review с акцентом на subprocess,
+ credentials isolation, error mapping и multi-video.
+3. Push ветки и draft PR в `dev`.
+4. После зелёного CI собрать `:dev`, подтвердить digest и обновить только сервис
+ `api` штатным compose flow.
+5. На dev-стенде оставить `VIDEO_TARGET_RESOLUTION=720`; выполнить acceptance matrix.
+6. Отдельно измерить scratch peak, размер result и время frames/cut на 1080p.
+7. Поднять prod до `1080` или `best` только явным решением после замера.
+
+Откат не требует миграций:
+
+- вернуть предыдущий image digest/tag;
+- либо временно оставить новый image с `VIDEO_TARGET_RESOLUTION=720` — это снижает
+ ресурсный профиль, но не отключает X;
+- незавершённые задачи после рестарта будут обработаны существующим механизмом
+ `interrupted`.
+
+## 8. Риски и защитные меры
+
+| Риск | Мера |
+|---|---|
+| X меняет GraphQL | metadata-only syndication fallback, явный yt-dlp bump, smoke перед deploy |
+| X требует login | v1 обещает только public media; X cookies не смешиваются с YouTube |
+| Portrait снова деградирует | отдельный argv test и live `ffprobe` acceptance |
+| 1080p увеличивает disk/CPU | дефолт 720, env opt-in, замер перед повышением |
+| Multi-video создаёт несколько файлов | `--playlist-items 1` + invariant одного candidate |
+| Fallback скрывает disk/permission error | fallback завершается до download; structured reason mapping |
+| GraphQL extraction нестабилен | metadata-only syndication fallback до единственного download |
+| Output подменён/множественный | isolated dir + единственный санитизированный after-move path |
+| Dependency устаревает | точные версии + явный dependency bump PR + image smoke |
+| Generic URL сохраняет SSRF-риск | не расширять обещание; завести отдельный security design для redirect/DNS controls |
+
+## 9. Definition of Done
+
+- X single-video URL проходит существующий `POST /tasks` без нового API-поля.
+- Для публичного X URL cookies не читаются и не передаются.
+- GraphQL работает основным путём, syndication вызывается не более одного раза.
+- Multi-video детерминирован: первое вложение либо явный `/video/N`.
+- `VIDEO_TARGET_RESOLUTION=720` выбирает `1280x720` и `720x1280`, когда эти уровни
+ доступны, и документированно выбирает наименьший larger fallback иначе.
+- `VIDEO_TARGET_RESOLUTION=best` выбирает максимальное доступное разрешение X.
+- YouTube cookies и `cookies_invalid` не регрессировали.
+- Полный test/lint/OpenAPI gate зелёный.
+- README и оба env example описывают новую поддержку и её ограничения.
+- Изменения не требуют миграции БД и имеют проверенный image rollback.
diff --git a/lecturelog/api/lifespan.py b/lecturelog/api/lifespan.py
index 2ceb680..0fa4cfc 100644
--- a/lecturelog/api/lifespan.py
+++ b/lecturelog/api/lifespan.py
@@ -118,7 +118,10 @@ def frames_factory(video_path: Path, srt_path: Path) -> VideoFrameProvider:
structurizer=structurizer,
audio_cutter=FfmpegAudioCutter(),
video_cutter=FfmpegVideoCutter(),
- ingestor=VideoIngestor(cookie_store=cookie_store),
+ ingestor=VideoIngestor(
+ cookie_store=cookie_store,
+ target_resolution=cfg.media.target_resolution,
+ ),
exporter=ObsidianExporter(),
progress_plan_factory=ProgressPlan.for_audio,
webhook_notifier=notifier,
diff --git a/lecturelog/application/error_classifier.py b/lecturelog/application/error_classifier.py
index 296cf96..f55b529 100644
--- a/lecturelog/application/error_classifier.py
+++ b/lecturelog/application/error_classifier.py
@@ -3,6 +3,7 @@
import httpx
from lecturelog.domain.enums import ErrorCode
+from lecturelog.domain.exceptions import MediaIngestError, MediaIngestReason
# Подстроки-сигналы лимита. LlmClient (infrastructure/llm/llm_client.py) при
# исчерпании ретраев оборачивает исходную ошибку провайдера в RuntimeError с
@@ -24,6 +25,18 @@ def classify_error(exc: BaseException) -> ErrorCode:
# 1) HTTP-статус от Groq (httpx.HTTPStatusError несёт response.status_code).
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (429, 503):
return ErrorCode.RATE_LIMIT
+ if isinstance(exc, MediaIngestError):
+ if exc.reason is MediaIngestReason.RATE_LIMIT:
+ return ErrorCode.RATE_LIMIT
+ if exc.reason is MediaIngestReason.AUTH_REQUIRED and exc.source_kind == "youtube":
+ return ErrorCode.COOKIES_INVALID
+ if exc.reason in {
+ MediaIngestReason.NOT_FOUND,
+ MediaIngestReason.AUTH_REQUIRED,
+ MediaIngestReason.EXTRACTOR,
+ }:
+ return ErrorCode.BAD_INPUT
+ return ErrorCode.INTERNAL
# 2) Типовые сигналы битого/нераспознанного входа.
if isinstance(exc, (FileNotFoundError, ValueError)):
return ErrorCode.BAD_INPUT
diff --git a/lecturelog/application/pipeline_service.py b/lecturelog/application/pipeline_service.py
index 4f08e8a..6e94ded 100644
--- a/lecturelog/application/pipeline_service.py
+++ b/lecturelog/application/pipeline_service.py
@@ -12,6 +12,7 @@
from lecturelog.application.progress_plan import ProgressPlan
from lecturelog.application.usage_accumulator import UsageAccumulator
from lecturelog.domain.enums import PipelineStage, TaskStatus
+from lecturelog.domain.exceptions import MediaIngestError
from lecturelog.domain.media_source import (
AudioSource,
MediaSource,
@@ -363,6 +364,14 @@ async def structurize_progress(pct: int):
return result_path
except Exception as exc:
logger.warning("Пайплайн упал для task=%s: %s", task.task_id, exc)
+ if isinstance(exc, MediaIngestError) and exc.diagnostic:
+ logger.warning(
+ "Диагностика media ingest task=%s source=%s reason=%s: %s",
+ task.task_id,
+ exc.source_kind,
+ exc.reason,
+ exc.diagnostic,
+ )
# Best-effort: пересчитать total и сохранить частичный расход,
# чтобы он доехал на FAILED/INTERRUPTED.
acc.compute_total()
diff --git a/lecturelog/config/settings.py b/lecturelog/config/settings.py
index 9cd2fa9..9a889ae 100644
--- a/lecturelog/config/settings.py
+++ b/lecturelog/config/settings.py
@@ -4,7 +4,7 @@
from typing import Literal
from urllib.parse import urlsplit
-from pydantic import Field, SecretStr, computed_field, model_validator
+from pydantic import Field, SecretStr, computed_field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
_BASE = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@@ -151,6 +151,27 @@ class WorkerConfig(BaseSettings):
max_concurrent_tasks: int = Field(2, alias="MAX_CONCURRENT_TASKS")
+class MediaConfig(BaseSettings):
+ model_config = _BASE
+ target_resolution: str = Field("720", alias="VIDEO_TARGET_RESOLUTION")
+
+ @field_validator("target_resolution")
+ @classmethod
+ def validate_target_resolution(cls, value: str) -> str:
+ normalized = value.strip().lower()
+ if normalized == "best":
+ return normalized
+ try:
+ resolution = int(normalized)
+ except ValueError as exc:
+ raise ValueError(
+ "VIDEO_TARGET_RESOLUTION должен быть best или числом 144..4320"
+ ) from exc
+ if not 144 <= resolution <= 4320:
+ raise ValueError("VIDEO_TARGET_RESOLUTION должен быть в диапазоне 144..4320")
+ return str(resolution)
+
+
class WebhookConfig(BaseSettings):
# Оба поля опциональны: режим вебхука включается только при заданном callback_url.
# Без URL движок работает автономно (поллинг-эндпоинты), поведение не меняется.
@@ -173,6 +194,7 @@ def model_post_init(self, __context: object) -> None:
self.database,
self.s3,
self.worker,
+ self.media,
self.webhook,
self.frames,
)
@@ -202,6 +224,11 @@ def s3(self) -> S3Config:
def worker(self) -> WorkerConfig:
return WorkerConfig()
+ @computed_field # type: ignore[prop-decorator]
+ @cached_property
+ def media(self) -> MediaConfig:
+ return MediaConfig()
+
@computed_field # type: ignore[prop-decorator]
@cached_property
def webhook(self) -> WebhookConfig:
diff --git a/lecturelog/domain/exceptions.py b/lecturelog/domain/exceptions.py
index 72dc20f..c1431fd 100644
--- a/lecturelog/domain/exceptions.py
+++ b/lecturelog/domain/exceptions.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+from enum import StrEnum
+
class DomainError(Exception):
"""Базовое доменное исключение."""
@@ -32,3 +34,31 @@ def __init__(self, allowed: list[str]):
class InvalidSource(DomainError):
def __init__(self, message: str = "Передайте ровно один источник: audio, video или video_url"):
super().__init__(message)
+
+
+class MediaIngestReason(StrEnum):
+ NOT_FOUND = "not_found"
+ AUTH_REQUIRED = "auth_required"
+ RATE_LIMIT = "rate_limit"
+ TOOL_MISSING = "tool_missing"
+ LOCAL_IO = "local_io"
+ EXTRACTOR = "extractor"
+ INVALID_OUTPUT = "invalid_output"
+
+
+class MediaIngestError(DomainError):
+ """Безопасная публичная ошибка ingest с отдельной санитизированной диагностикой."""
+
+ def __init__(
+ self,
+ *,
+ source_kind: str,
+ reason: MediaIngestReason,
+ public_message: str,
+ diagnostic: str = "",
+ ):
+ super().__init__(public_message)
+ self.source_kind = source_kind
+ self.reason = reason
+ self.public_message = public_message
+ self.diagnostic = diagnostic
diff --git a/lecturelog/infrastructure/media/url_utils.py b/lecturelog/infrastructure/media/url_utils.py
index b5b5084..e001dad 100644
--- a/lecturelog/infrastructure/media/url_utils.py
+++ b/lecturelog/infrastructure/media/url_utils.py
@@ -1,12 +1,41 @@
from __future__ import annotations
+from enum import StrEnum
from pathlib import Path
from urllib.parse import urlparse
+class VideoUrlKind(StrEnum):
+ YOUTUBE = "youtube"
+ X = "x"
+ GENERIC = "generic"
+
+
+_X_HOSTS = {
+ "x.com",
+ "www.x.com",
+ "mobile.x.com",
+ "m.x.com",
+ "twitter.com",
+ "www.twitter.com",
+ "mobile.twitter.com",
+ "m.twitter.com",
+}
+
+
def is_url(source: str | Path) -> bool:
"""URL = http/https-схема + непустой netloc. Путь/Path → False."""
if not isinstance(source, str):
return False
parsed = urlparse(source)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
+
+
+def classify_video_url(url: str) -> VideoUrlKind:
+ """Выбрать downloader-профиль по точному нормализованному hostname."""
+ hostname = (urlparse(url).hostname or "").lower().rstrip(".")
+ if hostname in _X_HOSTS:
+ return VideoUrlKind.X
+ if hostname == "youtu.be" or hostname == "youtube.com" or hostname.endswith(".youtube.com"):
+ return VideoUrlKind.YOUTUBE
+ return VideoUrlKind.GENERIC
diff --git a/lecturelog/infrastructure/media/video_ingestor.py b/lecturelog/infrastructure/media/video_ingestor.py
index 59272f5..a1c2211 100644
--- a/lecturelog/infrastructure/media/video_ingestor.py
+++ b/lecturelog/infrastructure/media/video_ingestor.py
@@ -1,31 +1,41 @@
from __future__ import annotations
import asyncio
+import re
import shutil
import sys
import tempfile
from pathlib import Path
+from lecturelog.domain.exceptions import MediaIngestError, MediaIngestReason
from lecturelog.domain.media_source import MediaSource, VideoFileSource, VideoUrlSource
from lecturelog.domain.ports import CookieStore, MediaIngestor
+from lecturelog.infrastructure.media.url_utils import VideoUrlKind, classify_video_url
VIDEO_EXTENSIONS = {".mp4", ".mov", ".webm", ".mkv", ".m4v", ".avi"}
+_MAX_DIAGNOSTIC_LENGTH = 2000
+_URL_RE = re.compile(r"https?://[^\s\"']+", re.IGNORECASE)
+_SECRET_RE = re.compile(r"(?i)(authorization|cookie|set-cookie|bearer)(\s*[:=]\s*|\s+)[^\s,;]+")
+_COOKIE_PATH_RE = re.compile(r"(?i)(--cookies(?:=|\s+))\S+")
class VideoIngestor(MediaIngestor):
- """Реализация порта MediaIngestor: yt-dlp для URL, копирование для файла."""
+ """yt-dlp для URL и нормализация локального видео в output_dir/video.*."""
- def __init__(self, cookie_store: CookieStore | None = None):
+ def __init__(
+ self,
+ cookie_store: CookieStore | None = None,
+ target_resolution: str = "720",
+ ):
self._cookie_store = cookie_store
+ self._target_resolution = target_resolution
async def ingest(self, source: MediaSource, output_dir: Path) -> Path:
- """Привести видеоисточник к локальному файлу output_dir/video.*"""
+ """Привести видеоисточник к локальному файлу output_dir/video.*."""
output_dir.mkdir(parents=True, exist_ok=True)
if isinstance(source, VideoUrlSource):
- target = output_dir / "video.mp4"
- await self._download_youtube(source.url, target)
- return target
+ return await self._download_url(source.url, output_dir)
if isinstance(source, VideoFileSource):
src_path = source.path
@@ -76,63 +86,296 @@ def _yt_dlp_bin() -> str:
return str(candidate)
return "yt-dlp"
- async def _download_youtube(self, url: str, output_path: Path) -> None:
- cookies_path: Path | None = None
- cookies_dir: str | None = None
- if self._cookie_store is not None:
- content = await self._cookie_store.get()
- if content:
- # БЕЗОПАСНОСТЬ: пишем cookies в приватный temp-каталог ВНЕ output.
- # output (= export/output/) — расшаренный том, который читает web;
- # секрет туда попадать не должен даже на время загрузки.
- cookies_dir = tempfile.mkdtemp(prefix="yt-cookies-")
- cookies_path = Path(cookies_dir) / "cookies.txt"
- cookies_path.write_bytes(content)
- cookies_path.chmod(0o600)
+ def _format_sort(self) -> str:
+ if self._target_resolution == "best":
+ return "res,proto:https"
+ return f"res:{self._target_resolution},proto:https"
+ def _base_args(self, kind: VideoUrlKind) -> list[str]:
args = [
self._yt_dlp_bin(),
"-f",
- "bestvideo[height<=720]+bestaudio/best[height<=720]",
- "--merge-output-format",
- "mp4",
- "--js-runtimes",
- "deno",
- "--remote-components",
- "ejs:github",
+ "bv*+ba/b",
+ "--no-playlist",
+ "--playlist-items",
+ "1",
+ "--no-progress",
+ "-S",
+ self._format_sort(),
]
+ if kind is VideoUrlKind.YOUTUBE:
+ args += [
+ "--js-runtimes",
+ "deno",
+ "--remote-components",
+ "ejs:github",
+ ]
+ return args
+
+ def _x_backend_args(self, backend: str) -> list[str]:
+ return ["--extractor-args", f"twitter:api={backend}"]
+
+ def _preflight_args(self, url: str, backend: str) -> list[str]:
+ return [
+ *self._base_args(VideoUrlKind.X),
+ *self._x_backend_args(backend),
+ "--simulate",
+ url,
+ ]
+
+ def _download_args(
+ self,
+ *,
+ url: str,
+ kind: VideoUrlKind,
+ attempt_dir: Path,
+ cookies_path: Path | None,
+ x_backend: str | None,
+ ) -> list[str]:
+ args = self._base_args(kind)
+ if x_backend is not None:
+ args += self._x_backend_args(x_backend)
if cookies_path is not None:
args += ["--cookies", str(cookies_path)]
- args += ["-o", str(output_path), url]
+ args += [
+ "--merge-output-format",
+ "mp4",
+ "--print",
+ "after_move:filepath",
+ "-o",
+ str(attempt_dir / "video.%(ext)s"),
+ url,
+ ]
+ return args
+ async def _run_yt_dlp(self, args: list[str], source_kind: str) -> tuple[int, bytes, bytes]:
try:
- try:
- proc = await asyncio.create_subprocess_exec(
- *args,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- except FileNotFoundError as exc:
- raise RuntimeError(
- "yt-dlp не найден. Установи зависимость: `pip install yt-dlp`."
- ) from exc
-
- _, stderr = await proc.communicate()
- if proc.returncode != 0:
- # ВНИМАНИЕ: в текст ошибки кладём stderr yt-dlp, но НЕ содержимое cookies.
- raise RuntimeError(
- f"yt-dlp не смог скачать видео: {stderr.decode('utf-8', errors='ignore')}"
+ proc = await asyncio.create_subprocess_exec(
+ *args,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ except FileNotFoundError as exc:
+ raise MediaIngestError(
+ source_kind=source_kind,
+ reason=MediaIngestReason.TOOL_MISSING,
+ public_message="Сервис скачивания видео временно недоступен.",
+ diagnostic="yt-dlp executable not found",
+ ) from exc
+ stdout, stderr = await proc.communicate()
+ return proc.returncode, stdout, stderr
+
+ async def _select_x_backend(self, url: str) -> str:
+ failures: list[MediaIngestError] = []
+ for backend in ("graphql", "syndication"):
+ returncode, _, stderr = await self._run_yt_dlp(
+ self._preflight_args(url, backend),
+ VideoUrlKind.X.value,
+ )
+ if returncode == 0:
+ return backend
+ failures.append(
+ self._error_from_failure(
+ kind=VideoUrlKind.X,
+ stderr=stderr,
+ url=url,
+ phase=f"{backend} preflight",
)
+ )
+
+ reason = self._combined_reason(failures)
+ diagnostic = " | ".join(error.diagnostic for error in failures)
+ raise MediaIngestError(
+ source_kind=VideoUrlKind.X.value,
+ reason=reason,
+ public_message=self._public_message(VideoUrlKind.X, reason),
+ diagnostic=diagnostic[:_MAX_DIAGNOSTIC_LENGTH],
+ )
+
+ async def _download_url(self, url: str, output_dir: Path) -> Path:
+ kind = classify_video_url(url)
+ x_backend = await self._select_x_backend(url) if kind is VideoUrlKind.X else None
+ cookies_path: Path | None = None
+ cookies_dir: str | None = None
+ attempt_dir = Path(tempfile.mkdtemp(prefix=".yt-dlp-", dir=output_dir))
+
+ try:
+ if kind is VideoUrlKind.YOUTUBE and self._cookie_store is not None:
+ content = await self._cookie_store.get()
+ if content:
+ # Cookies живут вне расшаренного output и доступны только владельцу.
+ cookies_dir = tempfile.mkdtemp(prefix="yt-cookies-")
+ cookies_path = Path(cookies_dir) / "cookies.txt"
+ cookies_path.write_bytes(content)
+ cookies_path.chmod(0o600)
- # yt-dlp иногда возвращает 0, но кладёт файл с другим расширением.
- # cookies-файла в output больше нет (он в temp-каталоге), фильтровать не нужно.
- if not output_path.exists():
- candidates = sorted(output_path.parent.glob(f"{output_path.stem}.*"))
- if not candidates:
- raise RuntimeError(
- f"yt-dlp завершился без ошибки, но файл не создан: {output_path}"
- )
- candidates[0].rename(output_path)
+ args = self._download_args(
+ url=url,
+ kind=kind,
+ attempt_dir=attempt_dir,
+ cookies_path=cookies_path,
+ x_backend=x_backend,
+ )
+ returncode, stdout, stderr = await self._run_yt_dlp(args, kind.value)
+ if returncode != 0:
+ raise self._error_from_failure(
+ kind=kind,
+ stderr=stderr,
+ url=url,
+ phase="download",
+ )
+ return self._normalize_download(stdout, attempt_dir, output_dir, kind)
finally:
+ shutil.rmtree(attempt_dir, ignore_errors=True)
if cookies_dir is not None:
shutil.rmtree(cookies_dir, ignore_errors=True)
+
+ def _normalize_download(
+ self,
+ stdout: bytes,
+ attempt_dir: Path,
+ output_dir: Path,
+ kind: VideoUrlKind,
+ ) -> Path:
+ printed = [
+ Path(line.strip())
+ for line in stdout.decode("utf-8", errors="replace").splitlines()
+ if line.strip()
+ ]
+ if len(printed) != 1:
+ raise self._invalid_output(
+ kind,
+ f"expected one after_move filepath, got {len(printed)}",
+ )
+
+ candidate = printed[0]
+ if not candidate.is_absolute():
+ candidate = attempt_dir / candidate
+ candidate = candidate.resolve()
+ resolved_attempt = attempt_dir.resolve()
+ if candidate == resolved_attempt or resolved_attempt not in candidate.parents:
+ raise self._invalid_output(kind, "after_move filepath escapes attempt directory")
+ if not candidate.is_file():
+ raise self._invalid_output(kind, "after_move filepath does not exist")
+
+ final_files = [
+ path.resolve()
+ for path in attempt_dir.rglob("*")
+ if path.is_file() and path.suffix.lower() not in {".part", ".ytdl"}
+ ]
+ if final_files != [candidate]:
+ raise self._invalid_output(
+ kind,
+ f"attempt directory contains {len(final_files)} final files",
+ )
+
+ suffix = candidate.suffix.lower()
+ if not suffix:
+ raise self._invalid_output(kind, "downloaded file has no extension")
+ target = output_dir / f"video{suffix}"
+ if target.exists():
+ raise self._invalid_output(kind, "target video already exists")
+ candidate.replace(target)
+ return target
+
+ def _invalid_output(self, kind: VideoUrlKind, diagnostic: str) -> MediaIngestError:
+ return MediaIngestError(
+ source_kind=kind.value,
+ reason=MediaIngestReason.INVALID_OUTPUT,
+ public_message="Сервис скачивания вернул некорректный результат.",
+ diagnostic=diagnostic,
+ )
+
+ def _error_from_failure(
+ self,
+ *,
+ kind: VideoUrlKind,
+ stderr: bytes,
+ url: str,
+ phase: str,
+ ) -> MediaIngestError:
+ raw = stderr.decode("utf-8", errors="replace")
+ upper = raw.upper()
+ if any(token in upper for token in ("429", "TOO MANY REQUESTS", "RATE LIMIT")):
+ reason = MediaIngestReason.RATE_LIMIT
+ elif any(
+ token in upper
+ for token in (
+ "SIGN IN",
+ "LOGIN REQUIRED",
+ "PRIVATE VIDEO",
+ "PROTECTED",
+ "AGE-RESTRICTED",
+ "AGE RESTRICTED",
+ )
+ ):
+ reason = MediaIngestReason.AUTH_REQUIRED
+ elif any(
+ token in upper
+ for token in (
+ "NOT FOUND",
+ "NO VIDEO",
+ "NO MEDIA",
+ "DOES NOT EXIST",
+ "UNAVAILABLE",
+ "REMOVED",
+ )
+ ):
+ reason = MediaIngestReason.NOT_FOUND
+ elif any(
+ token in upper
+ for token in (
+ "NO SPACE LEFT",
+ "PERMISSION DENIED",
+ "READ-ONLY FILE SYSTEM",
+ "FFMPEG",
+ )
+ ):
+ reason = MediaIngestReason.LOCAL_IO
+ else:
+ reason = MediaIngestReason.EXTRACTOR
+ diagnostic = self._sanitize_diagnostic(f"{phase}: {raw}", url)
+ return MediaIngestError(
+ source_kind=kind.value,
+ reason=reason,
+ public_message=self._public_message(kind, reason),
+ diagnostic=diagnostic,
+ )
+
+ @staticmethod
+ def _combined_reason(errors: list[MediaIngestError]) -> MediaIngestReason:
+ reasons = {error.reason for error in errors}
+ for reason in (
+ MediaIngestReason.RATE_LIMIT,
+ MediaIngestReason.AUTH_REQUIRED,
+ MediaIngestReason.NOT_FOUND,
+ MediaIngestReason.EXTRACTOR,
+ ):
+ if reason in reasons:
+ return reason
+ return MediaIngestReason.EXTRACTOR
+
+ @staticmethod
+ def _public_message(kind: VideoUrlKind, reason: MediaIngestReason) -> str:
+ source = (
+ "X" if kind is VideoUrlKind.X else "YouTube" if kind is VideoUrlKind.YOUTUBE else "URL"
+ )
+ if reason is MediaIngestReason.RATE_LIMIT:
+ return f"{source} временно ограничил частоту запросов. Попробуйте позже."
+ if reason is MediaIngestReason.AUTH_REQUIRED:
+ if kind is VideoUrlKind.YOUTUBE:
+ return "YouTube требует обновить cookies."
+ return f"Видео {source} недоступно без авторизации."
+ if reason is MediaIngestReason.NOT_FOUND:
+ return f"Видео {source} не найдено или было удалено."
+ if reason is MediaIngestReason.LOCAL_IO:
+ return "Не удалось сохранить или собрать скачанное видео."
+ return f"Не удалось получить видео из {source}."
+
+ @staticmethod
+ def _sanitize_diagnostic(text: str, source_url: str) -> str:
+ sanitized = text.replace(source_url, "")
+ sanitized = _COOKIE_PATH_RE.sub(r"\1", sanitized)
+ sanitized = _SECRET_RE.sub(r"\1: ", sanitized)
+ sanitized = _URL_RE.sub("", sanitized)
+ return " ".join(sanitized.split())[:_MAX_DIAGNOSTIC_LENGTH]
diff --git a/pyproject.toml b/pyproject.toml
index a790079..6ead743 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,7 +17,7 @@ dependencies = [
"google-genai>=0.3",
"openai>=1.55",
"pymupdf>=1.24",
- "yt-dlp>=2024.12",
+ "yt-dlp[default]==2026.7.4",
"numpy>=2.0",
"opencv-python-headless>=4.10",
"aioboto3>=13",
diff --git a/tests/integration/test_api_video_contract.py b/tests/integration/test_api_video_contract.py
index eeaa88f..29cb816 100644
--- a/tests/integration/test_api_video_contract.py
+++ b/tests/integration/test_api_video_contract.py
@@ -72,6 +72,15 @@ def test_video_url_returns_task_id(client):
assert job.source.url == "https://youtu.be/abc"
+def test_x_video_url_uses_unchanged_task_contract(client):
+ url = "https://x.com/i/status/2078106556634124335"
+ r = client.post("/api/v1/tasks", data={"video_url": url})
+ assert r.status_code == 200
+ job = client._worker.jobs[-1]
+ assert job.source.kind == "video_url"
+ assert job.source.url == url
+
+
def test_two_sources_still_400(client):
r = client.post(
"/api/v1/tasks",
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 913fe9a..b1cc1c6 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -102,6 +102,34 @@ def test_worker_default_concurrency(monkeypatch):
assert cfg.worker.max_concurrent_tasks == 2
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ (None, "720"),
+ ("best", "best"),
+ ("BEST", "best"),
+ ("1080", "1080"),
+ (" 720 ", "720"),
+ ],
+)
+def test_media_target_resolution(monkeypatch, raw, expected):
+ for k, v in _env().items():
+ monkeypatch.setenv(k, v)
+ if raw is None:
+ monkeypatch.delenv("VIDEO_TARGET_RESOLUTION", raising=False)
+ else:
+ monkeypatch.setenv("VIDEO_TARGET_RESOLUTION", raw)
+ assert AppConfig().media.target_resolution == expected
+
+
+@pytest.mark.parametrize("raw", ["143", "4321", "720p", "", "-1"])
+def test_media_target_resolution_rejects_invalid_value(monkeypatch, raw):
+ for k, v in _env(VIDEO_TARGET_RESOLUTION=raw).items():
+ monkeypatch.setenv(k, v)
+ with pytest.raises(Exception, match="VIDEO_TARGET_RESOLUTION"): # noqa: B017
+ AppConfig()
+
+
def test_missing_required_key_raises(monkeypatch):
# Изолируем именно обязательность OPENROUTER_API_KEY: остальное окружение полное.
for k, v in _env().items():
diff --git a/tests/unit/test_error_classifier_cookies.py b/tests/unit/test_error_classifier_cookies.py
index 4680824..ea335c4 100644
--- a/tests/unit/test_error_classifier_cookies.py
+++ b/tests/unit/test_error_classifier_cookies.py
@@ -1,5 +1,6 @@
from lecturelog.application.error_classifier import classify_error
from lecturelog.domain.enums import ErrorCode
+from lecturelog.domain.exceptions import MediaIngestError, MediaIngestReason
def test_classifies_youtube_bot_check_as_cookies_invalid():
@@ -17,3 +18,29 @@ def test_classifies_confirm_not_a_bot_variant():
def test_unrelated_error_stays_internal():
assert classify_error(RuntimeError("disk full")) == ErrorCode.INTERNAL
+
+
+def _media_error(source_kind, reason):
+ return MediaIngestError(
+ source_kind=source_kind,
+ reason=reason,
+ public_message="safe",
+ diagnostic="raw",
+ )
+
+
+def test_structured_youtube_auth_is_cookies_invalid():
+ assert (
+ classify_error(_media_error("youtube", MediaIngestReason.AUTH_REQUIRED))
+ is ErrorCode.COOKIES_INVALID
+ )
+
+
+def test_structured_x_auth_is_bad_input_not_cookies_invalid():
+ assert classify_error(_media_error("x", MediaIngestReason.AUTH_REQUIRED)) is ErrorCode.BAD_INPUT
+
+
+def test_structured_media_reason_mapping():
+ assert classify_error(_media_error("x", MediaIngestReason.RATE_LIMIT)) is ErrorCode.RATE_LIMIT
+ assert classify_error(_media_error("x", MediaIngestReason.NOT_FOUND)) is ErrorCode.BAD_INPUT
+ assert classify_error(_media_error("x", MediaIngestReason.LOCAL_IO)) is ErrorCode.INTERNAL
diff --git a/tests/unit/test_url_utils.py b/tests/unit/test_url_utils.py
index 67c9178..b2d5e84 100644
--- a/tests/unit/test_url_utils.py
+++ b/tests/unit/test_url_utils.py
@@ -1,6 +1,12 @@
from pathlib import Path
-from lecturelog.infrastructure.media.url_utils import is_url
+import pytest
+
+from lecturelog.infrastructure.media.url_utils import (
+ VideoUrlKind,
+ classify_video_url,
+ is_url,
+)
def test_http_url_is_url():
@@ -23,3 +29,22 @@ def test_scheme_without_netloc_is_not_url():
def test_path_object_is_not_url():
assert is_url(Path("/tmp/v.mp4")) is False
+
+
+@pytest.mark.parametrize(
+ ("url", "expected"),
+ [
+ ("https://x.com/i/status/1", VideoUrlKind.X),
+ ("https://WWW.X.COM./u/status/1", VideoUrlKind.X),
+ ("https://mobile.twitter.com/u/status/1", VideoUrlKind.X),
+ ("https://m.twitter.com/u/status/1", VideoUrlKind.X),
+ ("https://youtube.com/watch?v=x", VideoUrlKind.YOUTUBE),
+ ("https://music.youtube.com/watch?v=x", VideoUrlKind.YOUTUBE),
+ ("https://youtu.be/x", VideoUrlKind.YOUTUBE),
+ ("https://x.com.evil.example/video", VideoUrlKind.GENERIC),
+ ("https://notyoutube.com/video", VideoUrlKind.GENERIC),
+ ("https://cdn.example/video.mp4", VideoUrlKind.GENERIC),
+ ],
+)
+def test_classify_video_url_uses_exact_normalized_hostname(url, expected):
+ assert classify_video_url(url) is expected
diff --git a/tests/unit/test_video_ingestor_cookies.py b/tests/unit/test_video_ingestor_cookies.py
index be81dde..a0d3355 100644
--- a/tests/unit/test_video_ingestor_cookies.py
+++ b/tests/unit/test_video_ingestor_cookies.py
@@ -37,10 +37,10 @@ class P:
returncode = 0
async def communicate(self):
- # Эмулируем создание выходного файла yt-dlp.
- out = Path(argv[argv.index("-o") + 1])
+ template = argv[argv.index("-o") + 1]
+ out = Path(template.replace("%(ext)s", "mp4"))
out.write_bytes(b"video")
- return (b"", b"")
+ return (f"{out}\n".encode(), b"")
return P()
@@ -62,6 +62,10 @@ async def test_download_uses_cookies_and_deno(captured, tmp_path):
assert captured["cookies_content"] == b"COOKIEDATA"
assert "--js-runtimes" in argv and "deno" in argv
assert "--remote-components" in argv and "ejs:github" in argv
+ assert "bv*+ba/b" in argv
+ assert "res:720,proto:https" in argv
+ assert "--no-playlist" in argv
+ assert argv[argv.index("--playlist-items") + 1] == "1"
# БЕЗОПАСНОСТЬ: cookies-файл лежит ВНЕ output-директории (не в расшаренном томе).
cookies_path = Path(argv[argv.index("--cookies") + 1])
assert out_dir.resolve() not in cookies_path.resolve().parents
diff --git a/tests/unit/test_video_ingestor_urls.py b/tests/unit/test_video_ingestor_urls.py
new file mode 100644
index 0000000..eae4756
--- /dev/null
+++ b/tests/unit/test_video_ingestor_urls.py
@@ -0,0 +1,216 @@
+from pathlib import Path
+
+import pytest
+
+from lecturelog.domain.exceptions import MediaIngestError, MediaIngestReason
+from lecturelog.domain.media_source import VideoUrlSource
+from lecturelog.domain.ports import CookieStatus, CookieStore
+from lecturelog.infrastructure.media.url_utils import VideoUrlKind
+from lecturelog.infrastructure.media.video_ingestor import VideoIngestor
+
+
+class SentinelCookieStore(CookieStore):
+ async def save(self, content): ...
+
+ async def get(self):
+ raise AssertionError("cookie store must not be read")
+
+ async def status(self):
+ return CookieStatus(exists=False, updated_at=None, size=0)
+
+ async def delete(self): ...
+
+
+def _backend(argv: tuple[str, ...]) -> str | None:
+ if "--extractor-args" not in argv:
+ return None
+ return argv[argv.index("--extractor-args") + 1].split("=")[-1]
+
+
+def _fake_exec_factory(
+ calls,
+ *,
+ preflight_codes=None,
+ download_code=0,
+ download_stderr=b"",
+ output_mode="one",
+):
+ preflight_codes = preflight_codes or {}
+
+ async def fake_exec(*argv, **kwargs):
+ calls.append(argv)
+ is_preflight = "--simulate" in argv
+
+ class Process:
+ returncode = preflight_codes.get(_backend(argv), 0) if is_preflight else download_code
+
+ async def communicate(self):
+ if is_preflight:
+ return b"", b"preflight failed" if self.returncode else b""
+ if self.returncode:
+ return b"", download_stderr
+ template = argv[argv.index("-o") + 1]
+ output = Path(template.replace("%(ext)s", "mp4"))
+ if output_mode == "zero":
+ return b"", b""
+ if output_mode == "escape":
+ escaped = output.parent.parent / "escaped.mp4"
+ escaped.write_bytes(b"video")
+ return f"{escaped}\n".encode(), b""
+ output.write_bytes(b"video")
+ if output_mode == "two":
+ second = output.with_name("second.mp4")
+ second.write_bytes(b"video2")
+ return f"{output}\n{second}\n".encode(), b""
+ return f"{output}\n".encode(), b""
+
+ return Process()
+
+ return fake_exec
+
+
+@pytest.mark.asyncio
+async def test_x_uses_graphql_preflight_then_one_cookie_free_download(monkeypatch, tmp_path):
+ calls = []
+ monkeypatch.setattr(
+ "lecturelog.infrastructure.media.video_ingestor.asyncio.create_subprocess_exec",
+ _fake_exec_factory(calls),
+ )
+ ingestor = VideoIngestor(cookie_store=SentinelCookieStore(), target_resolution="720")
+
+ result = await ingestor.ingest(
+ VideoUrlSource(url="https://x.com/i/status/2078106556634124335"),
+ tmp_path / "out",
+ )
+
+ assert result.name == "video.mp4"
+ assert len(calls) == 2
+ preflight, download = calls
+ assert "--simulate" in preflight
+ assert _backend(preflight) == "graphql"
+ assert "--simulate" not in download
+ assert _backend(download) == "graphql"
+ assert "--cookies" not in download
+ assert "--js-runtimes" not in download
+ assert "--remote-components" not in download
+ assert download[download.index("--playlist-items") + 1] == "1"
+ assert download[download.index("-S") + 1] == "res:720,proto:https"
+
+
+@pytest.mark.asyncio
+async def test_x_falls_back_during_preflight_and_still_downloads_once(monkeypatch, tmp_path):
+ calls = []
+ monkeypatch.setattr(
+ "lecturelog.infrastructure.media.video_ingestor.asyncio.create_subprocess_exec",
+ _fake_exec_factory(calls, preflight_codes={"graphql": 1, "syndication": 0}),
+ )
+
+ await VideoIngestor().ingest(
+ VideoUrlSource(url="https://twitter.com/user/status/1"),
+ tmp_path / "out",
+ )
+
+ assert [_backend(call) for call in calls] == ["graphql", "syndication", "syndication"]
+ assert sum("--simulate" not in call for call in calls) == 1
+
+
+@pytest.mark.asyncio
+async def test_x_download_failure_does_not_retry_other_backend(monkeypatch, tmp_path):
+ calls = []
+ monkeypatch.setattr(
+ "lecturelog.infrastructure.media.video_ingestor.asyncio.create_subprocess_exec",
+ _fake_exec_factory(
+ calls,
+ download_code=1,
+ download_stderr=b"ERROR: No space left on device",
+ ),
+ )
+
+ with pytest.raises(MediaIngestError) as caught:
+ await VideoIngestor().ingest(
+ VideoUrlSource(url="https://x.com/i/status/1"),
+ tmp_path / "out",
+ )
+
+ assert caught.value.reason is MediaIngestReason.LOCAL_IO
+ assert [_backend(call) for call in calls] == ["graphql", "graphql"]
+ assert sum("--simulate" not in call for call in calls) == 1
+
+
+@pytest.mark.asyncio
+async def test_x_both_preflights_fail_without_download(monkeypatch, tmp_path):
+ calls = []
+ monkeypatch.setattr(
+ "lecturelog.infrastructure.media.video_ingestor.asyncio.create_subprocess_exec",
+ _fake_exec_factory(calls, preflight_codes={"graphql": 1, "syndication": 1}),
+ )
+
+ with pytest.raises(MediaIngestError) as caught:
+ await VideoIngestor().ingest(
+ VideoUrlSource(url="https://x.com/i/status/1"),
+ tmp_path / "out",
+ )
+
+ assert caught.value.reason is MediaIngestReason.EXTRACTOR
+ assert len(calls) == 2
+ assert all("--simulate" in call for call in calls)
+
+
+@pytest.mark.asyncio
+async def test_generic_does_not_read_youtube_cookies_and_best_is_unlimited(monkeypatch, tmp_path):
+ calls = []
+ monkeypatch.setattr(
+ "lecturelog.infrastructure.media.video_ingestor.asyncio.create_subprocess_exec",
+ _fake_exec_factory(calls),
+ )
+
+ await VideoIngestor(
+ cookie_store=SentinelCookieStore(),
+ target_resolution="best",
+ ).ingest(
+ VideoUrlSource(url="https://cdn.example/video"),
+ tmp_path / "out",
+ )
+
+ assert len(calls) == 1
+ assert calls[0][calls[0].index("-S") + 1] == "res,proto:https"
+ assert "--cookies" not in calls[0]
+ assert "--js-runtimes" not in calls[0]
+ assert "--extractor-args" not in calls[0]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("output_mode", ["zero", "two", "escape"])
+async def test_download_rejects_invalid_after_move_output(monkeypatch, tmp_path, output_mode):
+ calls = []
+ monkeypatch.setattr(
+ "lecturelog.infrastructure.media.video_ingestor.asyncio.create_subprocess_exec",
+ _fake_exec_factory(calls, output_mode=output_mode),
+ )
+
+ with pytest.raises(MediaIngestError) as caught:
+ await VideoIngestor().ingest(
+ VideoUrlSource(url="https://cdn.example/video"),
+ tmp_path / "out",
+ )
+
+ assert caught.value.reason is MediaIngestReason.INVALID_OUTPUT
+ assert not list((tmp_path / "out").glob(".yt-dlp-*"))
+
+
+def test_diagnostic_is_sanitized_and_public_string_is_safe():
+ url = "https://x.com/i/status/1?token=secret"
+ error = VideoIngestor()._error_from_failure(
+ kind=VideoUrlKind.X,
+ stderr=(
+ b"ERROR https://x.com/i/status/1?token=secret "
+ b"--cookies /tmp/private/cookies.txt Authorization: Bearer-secret"
+ ),
+ url=url,
+ phase="download",
+ )
+
+ assert url not in error.diagnostic
+ assert "/tmp/private/cookies.txt" not in error.diagnostic
+ assert "Bearer-secret" not in error.diagnostic
+ assert error.diagnostic != str(error)
diff --git a/tests/unit/test_yt_dlp_format_matrix.py b/tests/unit/test_yt_dlp_format_matrix.py
new file mode 100644
index 0000000..aaad048
--- /dev/null
+++ b/tests/unit/test_yt_dlp_format_matrix.py
@@ -0,0 +1,99 @@
+import json
+import subprocess
+
+import pytest
+
+from lecturelog.infrastructure.media.video_ingestor import VideoIngestor
+
+
+def _format(
+ format_id,
+ width,
+ height,
+ *,
+ protocol="https",
+ tbr=1000,
+):
+ return {
+ "format_id": format_id,
+ "url": f"https://media.example/{format_id}.mp4",
+ "ext": "mp4",
+ "width": width,
+ "height": height,
+ "tbr": tbr,
+ "protocol": protocol,
+ "vcodec": "avc1.64001f",
+ "acodec": "mp4a.40.2",
+ }
+
+
+def _select(tmp_path, formats, sort):
+ info = {
+ "id": "synthetic",
+ "title": "Synthetic format matrix",
+ "extractor": "generic",
+ "extractor_key": "Generic",
+ "webpage_url": "https://media.example/watch/synthetic",
+ "formats": formats,
+ }
+ info_path = tmp_path / "info.json"
+ info_path.write_text(json.dumps(info), encoding="utf-8")
+ result = subprocess.run(
+ [
+ VideoIngestor._yt_dlp_bin(),
+ "--load-info-json",
+ str(info_path),
+ "--simulate",
+ "-f",
+ "bv*+ba/b",
+ "-S",
+ sort,
+ "--print",
+ "%(format_id)s",
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ return result.stdout.strip()
+
+
+def test_target_720_prefers_progressive_https_at_same_landscape_resolution(tmp_path):
+ formats = [
+ _format("progressive-360", 640, 360, tbr=500),
+ _format("hls-720", 1280, 720, protocol="m3u8_native", tbr=1800),
+ _format("progressive-720", 1280, 720, tbr=1500),
+ _format("hls-1080", 1920, 1080, protocol="m3u8_native", tbr=3000),
+ ]
+ assert _select(tmp_path, formats, "res:720,proto:https") == "progressive-720"
+
+
+def test_target_720_is_orientation_independent_for_portrait_video(tmp_path):
+ formats = [
+ _format("portrait-568", 320, 568, tbr=500),
+ _format("portrait-720", 720, 1280, tbr=1500),
+ _format("portrait-1080", 1080, 1920, tbr=3000),
+ ]
+ assert _select(tmp_path, formats, "res:720,proto:https") == "portrait-720"
+
+
+def test_target_uses_smallest_format_above_when_nothing_is_below(tmp_path):
+ formats = [
+ _format("above-1080", 1920, 1080, tbr=3000),
+ _format("above-1440", 2560, 1440, tbr=5000),
+ ]
+ assert _select(tmp_path, formats, "res:720,proto:https") == "above-1080"
+
+
+def test_best_keeps_quality_ahead_of_progressive_protocol(tmp_path):
+ formats = [
+ _format("progressive-720", 1280, 720, tbr=1500),
+ _format("hls-1080", 1920, 1080, protocol="m3u8_native", tbr=3000),
+ ]
+ assert _select(tmp_path, formats, "res,proto:https") == "hls-1080"
+
+
+@pytest.mark.parametrize("target", ["720", "best"])
+def test_ingestor_sort_contract_matches_matrix(target):
+ expected = "res,proto:https" if target == "best" else "res:720,proto:https"
+ assert VideoIngestor(target_resolution=target)._format_sort() == expected
diff --git a/uv.lock b/uv.lock
index e1967fc..d709fd9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,6 +1,10 @@
version = 1
revision = 3
requires-python = ">=3.12"
+resolution-markers = [
+ "python_full_version >= '3.13'",
+ "python_full_version < '3.13'",
+]
[[package]]
name = "aioboto3"
@@ -309,6 +313,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" },
]
+[[package]]
+name = "brotli"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
+ { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
+ { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
+ { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
+ { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
+ { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
+ { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
+ { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
+ { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
+]
+
+[[package]]
+name = "brotlicffi"
+version = "1.2.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
+ { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
+ { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
+]
+
[[package]]
name = "certifi"
version = "2026.6.17"
@@ -945,7 +1008,7 @@ dependencies = [
{ name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "uvicorn", extra = ["standard"] },
- { name = "yt-dlp" },
+ { name = "yt-dlp", extra = ["default"] },
]
[package.optional-dependencies]
@@ -978,7 +1041,7 @@ requires-dist = [
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.11" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
- { name = "yt-dlp", specifier = ">=2024.12" },
+ { name = "yt-dlp", extras = ["default"], specifier = "==2026.7.4" },
]
provides-extras = ["dev"]
@@ -1156,6 +1219,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
+[[package]]
+name = "mutagen"
+version = "1.48.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/70/1675da133ea92227da41bf5b24e1c66be597ff736a1533ade41da986852f/mutagen-1.48.1.tar.gz", hash = "sha256:8f95637ab9f6f305cec6bd1294e197debe207998e3e068596563c74f86b0a173", size = 1276978, upload-time = "2026-06-25T09:47:32.443Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/d8/a29e4e3991765e7ce4ed1f7e4074fe1ba9da03e0048639734de60f9cadb9/mutagen-1.48.1-py3-none-any.whl", hash = "sha256:4f077fe87d3fc7fba259aa63d8c026b18382ca6a42ef37c61e16f1b1b5b82fe7", size = 195706, upload-time = "2026-06-25T09:47:30.296Z" },
+]
+
[[package]]
name = "numpy"
version = "2.5.1"
@@ -1387,6 +1459,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
+[[package]]
+name = "pycryptodomex"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" },
+ { url = "https://files.pythonhosted.org/packages/70/af/774c2e2b4f6570fbf6a4972161adbb183aeeaa1863bde31e8706f123bf92/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8962204c47464d5c1c4038abeadd4514a133b28748bcd9fa5b6d62e3cec6fa", size = 2187643, upload-time = "2025-05-17T17:22:26.37Z" },
+ { url = "https://files.pythonhosted.org/packages/de/a3/71065b24cb889d537954cedc3ae5466af00a2cabcff8e29b73be047e9a19/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a33986a0066860f7fcf7c7bd2bc804fa90e434183645595ae7b33d01f3c91ed8", size = 2273762, upload-time = "2025-05-17T17:22:28.313Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/0b/ff6f43b7fbef4d302c8b981fe58467b8871902cdc3eb28896b52421422cc/pycryptodomex-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7947ab8d589e3178da3d7cdeabe14f841b391e17046954f2fbcd941705762b5", size = 2313012, upload-time = "2025-05-17T17:22:30.57Z" },
+ { url = "https://files.pythonhosted.org/packages/02/de/9d4772c0506ab6da10b41159493657105d3f8bb5c53615d19452afc6b315/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c25e30a20e1b426e1f0fa00131c516f16e474204eee1139d1603e132acffc314", size = 2186856, upload-time = "2025-05-17T17:22:32.819Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ad/8b30efcd6341707a234e5eba5493700a17852ca1ac7a75daa7945fcf6427/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:da4fa650cef02db88c2b98acc5434461e027dce0ae8c22dd5a69013eaf510006", size = 2347523, upload-time = "2025-05-17T17:22:35.386Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/02/16868e9f655b7670dbb0ac4f2844145cbc42251f916fc35c414ad2359849/pycryptodomex-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58b851b9effd0d072d4ca2e4542bf2a4abcf13c82a29fd2c93ce27ee2a2e9462", size = 2272825, upload-time = "2025-05-17T17:22:37.632Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/18/4ca89ac737230b52ac8ffaca42f9c6f1fd07c81a6cd821e91af79db60632/pycryptodomex-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:a9d446e844f08299236780f2efa9898c818fe7e02f17263866b8550c7d5fb328", size = 1772078, upload-time = "2025-05-17T17:22:40Z" },
+ { url = "https://files.pythonhosted.org/packages/73/34/13e01c322db027682e00986873eca803f11c56ade9ba5bbf3225841ea2d4/pycryptodomex-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bc65bdd9fc8de7a35a74cab1c898cab391a4add33a8fe740bda00f5976ca4708", size = 1803656, upload-time = "2025-05-17T17:22:42.139Z" },
+ { url = "https://files.pythonhosted.org/packages/54/68/9504c8796b1805d58f4425002bcca20f12880e6fa4dc2fc9a668705c7a08/pycryptodomex-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c885da45e70139464f082018ac527fdaad26f1657a99ee13eecdce0f0ca24ab4", size = 1707172, upload-time = "2025-05-17T17:22:44.704Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" },
+ { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" },
+ { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" },
+ { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.13.4"
@@ -2122,9 +2224,31 @@ wheels = [
[[package]]
name = "yt-dlp"
-version = "2026.6.9"
+version = "2026.7.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/c5/9972af4b472b0d55badf841ebafd2f98944cb0ae0f46e11d01f363ea5b91/yt_dlp-2026.7.4.tar.gz", hash = "sha256:b094813404f87a9dd2186f00815231df32e5fd8a5403be0f807b3bb2d21a4432", size = 3049326, upload-time = "2026-07-04T22:42:14.837Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/8a/cd4c9b02c10c563adfe78118310129641900e1cd6de888cfae2452072696/yt_dlp-2026.7.4-py3-none-any.whl", hash = "sha256:f11f2b11d5a8ac4059f9bdf29fa4407dc7c6bb00c5097e95ca22a7a9db518266", size = 3184705, upload-time = "2026-07-04T22:42:12.989Z" },
+]
+
+[package.optional-dependencies]
+default = [
+ { name = "brotli", marker = "implementation_name == 'cpython' and sys_platform != 'ios'" },
+ { name = "brotlicffi", marker = "implementation_name != 'cpython'" },
+ { name = "certifi" },
+ { name = "mutagen" },
+ { name = "pycryptodomex" },
+ { name = "requests" },
+ { name = "urllib3" },
+ { name = "websockets" },
+ { name = "yt-dlp-ejs" },
+]
+
+[[package]]
+name = "yt-dlp-ejs"
+version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/88/a4/1b0979d28f87774bb67fbbc66bce44f9dd1aa0e547a99e22985fac945c33/yt_dlp-2026.6.9.tar.gz", hash = "sha256:d50fcb95f48d61bedde33e408c1881d4c279e51c31354a599ce09e96ba0f4b86", size = 3030590, upload-time = "2026-06-09T23:27:14.831Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/e6/cceb9530e8f4e5940f6f7822d90e9d94f1b85343329a16baaf47bbbb3de1/yt_dlp_ejs-0.8.0.tar.gz", hash = "sha256:d5fa1639f63b5c4af8d932495f60689d5370f1a095782c944f7f62a303eb104e", size = 96571, upload-time = "2026-03-17T22:49:19.299Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f3/ee/188a3dadf9dfdac713243521f919feca1cd091d4358c9ea7e8ebb710a7cc/yt_dlp-2026.6.9-py3-none-any.whl", hash = "sha256:442ba4c75724b9496144c8434b617962ee08d0ee7c26ec663848fe9b78d5a3e4", size = 3169035, upload-time = "2026-06-09T23:27:12.58Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/bd/520769863744b669440a924271a6159ddd82ad5ae26b4ac4d4b69e9f8d44/yt_dlp_ejs-0.8.0-py3-none-any.whl", hash = "sha256:79300e5fca7f937a1eeede11f0456862c1b41107ce1d726871e0207424f4bdb4", size = 53443, upload-time = "2026-03-17T22:49:17.736Z" },
]