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" }, ]