Fix download status handling and the blank page after download
- Never let a non-authoritative MeTube 'updated' event downgrade a terminal job (it refilled the progress bar after completion). - Self-heal stale active jobs against MeTube history on status polls, so a missed event no longer leaves a job stuck in 'queued'. - Build YT.Player on an imperatively created child div: React keeps owning the container, so switching to the local copy after a download no longer throws removeChild and blanks the page. - Autoplay on open and seek controls; subtitle experiment reverted.
This commit is contained in:
parent
b1faeb3729
commit
43adec5224
9 changed files with 758 additions and 5 deletions
90
analytics/2026-09-25-download-status-fixes.md
Normal file
90
analytics/2026-09-25-download-status-fixes.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Фиксы статусов загрузки: самоисцеление зависших job, откат completed неавторитетными событиями, субтитры off после ready
|
||||
|
||||
> **Статус (2026-09-25): Баг 3 (субтитры) ОТКАЧЕН.** Вместе с решением по `analytics/2026-09-25-subtitles-off-by-default.md` правки Бага 3 отменены и откатываются из `Player.tsx`; пользователь отключит субтитры сам в настройках YouTube-аккаунта. **Баги 1 и 2 остаются в силе** и выполняются в полном объёме. Детали — в журнале.
|
||||
|
||||
## Задача
|
||||
|
||||
Три независимых бага (2026-09-25):
|
||||
|
||||
**Баг 1 — job висит в queued.** MeTube вернул done-запись со статусом `error` (HTTP 429 от YouTube — бот-проверка; инцидент: job id 14, видео COSMIC), но наш job остался `queued`: событие ошибки пропущено, а сверка с историей MeTube (`reconcile_on_startup`) работает только при старте. Нужно самоисцеление: при `GET /videos/{id}/download-status`, если последний job в `ACTIVE_STATUSES` и устарел (нет событий дольше порога, ~5 минут) — один раз сверить его с MeTube history и применить авторитетный статус.
|
||||
|
||||
**Баг 2 — двойная шкала прогресса.** В `_apply_metube_info` неавторитетное событие `updated` со статусом downloading/postprocessing откатывает completed-джоб назад (`elif our_status != "unknown": job.status = our_status`): после completed приходит поздний `updated` → статус снова downloading → шкала заполняется второй раз.
|
||||
|
||||
**Баг 3 — субтитры включаются по умолчанию.** `cc_load_policy=0` = предпочтение пользователя (у пользователя включено в аккаунте) — force-off у YouTube не существует; нужен сброс через официальное IFrame API после `onReady`. Локальное видео: возможны поздние in-band дорожки после `loadedmetadata` — нужен обработчик `addtrack` на `textTracks`.
|
||||
|
||||
## Контекст
|
||||
|
||||
### Текущее состояние кода (проверено)
|
||||
|
||||
- `backend/app/services/download_jobs.py`:
|
||||
- `_apply_metube_info` (строки 195–225): обновляет `metube_job_id`, `started_at`, `progress_percent`; затем ветки: `failed` (без проверки authoritative!), `completed` (только если authoritative), иначе `elif our_status != "unknown": job.status = our_status` — **именно эта ветка откатывает completed** (Баг 2). Ветка `failed` тоже не гвардится по authoritative: поздний `updated` с `error` на completed-джобе превратит его в failed.
|
||||
- `reconcile_on_startup` (строки 228–268): сверяет только джобы в `ACTIVE_STATUSES + ["unknown"]`; `by_url` собирается из bucket'ов history (queue/pending — неавторитетные, done — авторитетный); нет совпадения → `unknown`; fetch упал → всем `unknown`. Вызывается только из lifespan (`backend/app/main.py:62–65`) — при работе сервиса не срабатывает.
|
||||
- Константы в `backend/app/models/download_job.py`: `ACTIVE_STATUSES = ("queued", "downloading", "postprocessing")` (строка 11), `TERMINAL_STATUSES = ("completed", "failed", "deleted")` (строка 12).
|
||||
- `backend/app/api/videos.py` — `download_status` (строки 97–103): просто `get_latest_job` + `_serialize_job`, никакой сверки с MeTube. `_serialize_job` (34–43) отдаёт `media_url` только при `completed`. `get_db` (`app/db.py:14–19`) не коммитит — обработчики коммитят сами.
|
||||
- `backend/app/services/metube_client.py`: `fetch_history` (78–81) возвращает `{"queue": [], "pending": [], "done": []}`; `METUBE_STATUS_MAP` (37–45): `finished→completed`, `error→failed`.
|
||||
- Frontend `DownloadButton.tsx:29–35`: пока статус в `ACTIVE_STATUSES`, поллит `GET /download-status` каждые 2 с — это готовый триггер для самоисцеления Бага 1; для Бага 2 фронт менять не нужно (он лишь читает статус: при terminal рендерит бейдж, а не шкалу).
|
||||
- `frontend/src/components/Player.tsx` (worktree): уже содержит незакоммиченные правки двух предыдущих задач — `cc_load_policy: 0` в `playerVars` (строка 188) и в `src` фолбэк-iframe (295), `onLoadedMetadata` с `mode='disabled'` (266–270), создание YT.Player на императивном дочернем div. Интерфейс `YoutubePlayerApi` (13–18) не содержит `loadModule`/`unloadModule`. Работа с `textTracks` только в `onLoadedMetadata` — обработчика `addtrack` нет (grep: `addtrack|loadModule|unloadModule` не встречаются).
|
||||
|
||||
### Детали инцидентов
|
||||
|
||||
- Баг 1: Socket.IO-событие `completed` с done-записью `{"status": "error", ...}` пропущено (backend был недоступен/событие потеряно) — job остался `queued` навсегда, поллинг бесконечен.
|
||||
- Баг 2: Socket.IO доставляет события с задержкой; после авторитетного `completed` приходит поздний `updated` (downloading/postprocessing) → откат статуса → в UI шкала заполняется заново.
|
||||
- Баг 3: по диагнозу оркестратора у пользователя субтитры включены в настройках YouTube-аккаунта — `cc_load_policy=0` их не отключает (force-off не существует, задокументировано в `analytics/2026-09-25-subtitles-off-by-default.md`, «Риски»). MeTube качает субтитры (`subtitle_mode prefer_manual`) — наш `enqueue_video` (`metube_client.py:57–69`) subtitle-настройки не передаёт, конфиг живёт на mediaVM вне нашего контроля (AGENTS.md:11); локальные дорожки возможны только in-band в медиаконтейнере (backend `.vtt` не отдаёт, `<track>` в JSX нет).
|
||||
|
||||
## Затронутые подсистемы и файлы
|
||||
|
||||
- `backend/app/services/download_jobs.py` — хелперы `job_is_stale(db, job)` / `reconcile_stale_job(db, job)`, гвард терминальных статусов в `_apply_metube_info`.
|
||||
- `backend/app/api/videos.py` — `download_status`: триггер самоисцеления.
|
||||
- `frontend/src/components/Player.tsx` — Баг 3: `unloadModule/loadModule('captions')` после `onReady`; обработчик `addtrack` в локальной ветке; расширение интерфейса `YoutubePlayerApi`.
|
||||
- `tests/test_download_jobs.py`, `tests/test_videos.py` — новые тесты.
|
||||
- Без изменений: `DownloadButton.tsx` (фронт Бага 2 не требует), миграции, модели, README, `App.css`.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. **Баг 1 (самоисцеление):** при `GET /videos/{id}/download-status` устаревший job в `ACTIVE_STATUSES` сверяется с MeTube history: done[`finished`] → `completed` (+ `media_url`), done[`error`] → `failed` (+ `error_message`), в queue/pending → остаётся активным (не `unknown`), нет в истории → `unknown`. Fetch history упал → статус не меняется, исключение не всплывает в ответ API.
|
||||
2. **Баг 1 (не чаще раза за окно):** свежий job (события шли недавно) и терминальный job не триггерят `fetch_history`; повторный поллинг (2 с) не молотит MeTube.
|
||||
3. **Баг 2:** неавторитетные события (`updated`) никогда не меняют статус терминального джоба (completed/failed/deleted) — ни ветка `downloading/postprocessing`, ни ветка `error`; при этом для активных джобов ветка `error` по-прежнему переводит в `failed`, а авторитетный `completed` по-прежнему завершает джоб.
|
||||
4. **Баг 3 (YouTube):** при включённом пользовательском предпочтении субтитров после `onReady` субтитры выключены; CC-кнопка в UI плеера остаётся и вручную включает дорожку. `cc_load_policy: 0` сохраняется.
|
||||
5. **Баг 3 (локально):** поздние in-band дорожки, добавленные после `loadedmetadata`, получают `mode='disabled'`; ранее включённая пользователем дорожка при этом не трогается; нативная CC-кнопка работает.
|
||||
6. **Регрессий нет:** `pytest` (все существующие тесты зелёные, включая `test_handle_metube_event_error_marks_failed` и `test_transient_finished_in_updated_event_does_not_complete_job`), `npm run lint`, `npm run build` чистые.
|
||||
7. **Деплой:** `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
|
||||
|
||||
## План
|
||||
|
||||
1. **Баг 2, `_apply_metube_info`** (ядро фикса, от него зависят тесты Бага 1):
|
||||
- После обновления `metube_job_id`/`started_at`/`progress_percent` добавить гвард: `if not authoritative and job.status in TERMINAL_STATUSES: return` — статус терминального джоба не трогается, `error_message`/`completed_at`/`media_url` не перезаписываются.
|
||||
- Ветку `failed` и `elif our_status != "unknown"` не менять: они продолжают работать для активных/`unknown` джобов; авторитетный `completed` завершает любой джоб (включая failed), как сейчас.
|
||||
2. **Баг 1, `download_jobs.py`**:
|
||||
- Константа порога, напр. `STALE_JOB_AFTER = timedelta(minutes=5)`.
|
||||
- `def job_is_stale(job, *, now=None) -> bool`: `job.status in ACTIVE_STATUSES and now - job.requested_at > STALE_JOB_AFTER and now - job.updated_at > STALE_JOB_AFTER`. Второе условие (`updated_at`) реализует «один раз»: пока события идут, `updated_at` свежий и сверка не нужна; после пропавшего события оба поля устаревают.
|
||||
- `def reconcile_stale_job(db, job) -> None`: `MeTubeClient().fetch_history()` → `by_url` из bucket'ов в том же порядке, что `reconcile_on_startup` (queue/pending неавторитетные, done авторитетный) → match по `video.youtube_url` → `_apply_metube_info(job, info, authoritative)`; нет совпадения → `job.status = "unknown"`; исключение fetch → логировать и **не менять статус** (в отличие от startup-reconcile: здесь это не перезапуск, а транзиентная ошибка поллинга). В конце явно `job.updated_at = datetime.now(timezone.utc)` и `db.commit()` — это и есть «один раз сверить»: следующая попытка не раньше чем через `STALE_JOB_AFTER` после этой (commit и так бампает `updated_at` через `onupdate`, но только если был реальный UPDATE — явное присваивание гарантирует бэкофф и при fetch-ошибке и при «ничего не изменилось»).
|
||||
- `get_db` не коммитит — хелпер обязан закоммитить сам (как `request_download`/`delete_local_copy`).
|
||||
3. **Баг 1, `api/videos.py::download_status`**:
|
||||
- После `get_latest_job`: `if job and download_jobs.job_is_stale(job):` → в try/except вызвать `reconcile_stale_job(db, job)` (исключение → `logger.warning`, ответ отдаём по текущему состоянию). Импортировать `ACTIVE_STATUSES` не нужно (предикат внутри хелпера); импорт `job_is_stale`/`reconcile_stale_job`.
|
||||
4. **Баг 3, `Player.tsx`**:
|
||||
- Расширить `YoutubePlayerApi`: `loadModule(module: string): void`, `unloadModule(module: string): void`.
|
||||
- В `onReady` (после гардов `cancelled/timedOut`, сброса таймаута и `setYtApiStatus('ready')`): `event.target.unloadModule('captions'); event.target.loadModule('captions')` — сброс на выкл, CC-кнопка остаётся. Использовать `event.target` (типизированный), а не замыкание на `player` (чище, без TDZ-нюансов).
|
||||
- Локальная ветка: добавить обработчик `addtrack` на `video.textTracks` (`TextTrackList` — EventTarget), в колбэке `event.track.mode = 'disabled'` (только новая дорожка — ручной выбор пользователя на существующих не трогаем). Регистрировать в `useEffect` с deps `[useLocal, video.local.media_url]` (guard `videoRef.current`, cleanup с `removeEventListener`) — поверх существующего `onLoadedMetadata`, который не трогаем.
|
||||
- Фолбэк plain-iframe не менять (API недоступно — см. «Риски»); `cc_load_policy: 0` оставить в обеих YouTube-ветках.
|
||||
5. **Тесты** (mock'и, реальные вызовы MeTube запрещены — AGENTS.md):
|
||||
- `tests/test_download_jobs.py`: `reconcile_stale_job` — done[finished]→completed+media_url; done[error]→failed+error_message; в queue/pending→остаётся active; нет в истории→unknown; fetch упал→статус не меняется. Гвард Бага 2 — completed + `updated` downloading/postprocessing/error → остаётся completed (`media_url`/`completed_at` целы, `error_message` не пишется); failed + `updated` downloading → остаётся failed; авторитетный `completed` завершает queued-джоб. Существующие тесты `_apply_metube_info` не ломаются.
|
||||
- `tests/test_videos.py`: `GET download-status` — устаревший queued → самоисцеление в completed (mock history); свежий job → `fetch_history` не вызывается (monkeypatch, который падает при вызове); терминальный job → не вызывается; нет в истории → `unknown`.
|
||||
- **Внимание к SQLite:** `func.now()` в тестах наивен (naive), сравнение с `datetime.now(timezone.utc)` упадёт `TypeError` — в тестах явно задавать `requested_at`/`updated_at` осознанными (aware) значениями в прошлом.
|
||||
6. **Проверки:** `backend/.venv/bin/python -m pytest tests/ -v`; `cd frontend && npm run lint && npm run build`; ручные: зависший job → самоисцеление при открытии страницы; скачивание → шкала заполняется один раз; YouTube-видео при включённом предпочтении субтитров → off после ready, CC-кнопка включает; локальное видео с субтитрами → off, поздние дорожки тоже off; деплой + health.
|
||||
|
||||
## Риски и ограничения
|
||||
|
||||
- **GET с побочным эффектом:** `download-status` мутирует состояние. Принято осознанно (задано оркестратором): операция идемпотентная и вынесена в сервис-хелпер; REST-чистота (AGENTS.md:13, mobile-клиент) сохранена на уровне семантики «статус с ленивой самокоррекцией». Фоновый воркер-самоисцелитель — альтернатива, отклонена как избыточная для single-user.
|
||||
- **Молотилка MeTube:** без гварда по `updated_at` поллинг раз в 2 с при длинной (>5 мин) загрузке дёргал бы `/history` на каждый запрос — гвард и явный touch `updated_at` дают одну сверку за окно `STALE_JOB_AFTER`. Раса двух параллельных GET безвредна (обе сверки идемпотентны).
|
||||
- **`unknown` vs «оставить queued»:** выбрано `unknown` — согласовано с семантикой `reconcile_on_startup` (не сверяемое состояние) и с UI (`DownloadButton.tsx:106` показывает «Загрузить снова»). Оставить queued = вечный поллинг.
|
||||
- **Задержка самоисцеления:** до 5 минут после пропажи событий; live-события Socket.IO остаются основным каналом, сверка — страховка.
|
||||
- **Баг 2, деградация `deleted`:** авторитетный `completed` на джобе со статусом `deleted` формально может «воскресить» его (media_url перезапишется). Практически невозможно (после /delete MeTube шлёт `cleared`, replay'ов нет); гвардить отдельно не будем — зафиксировано, вне скоупа.
|
||||
- **Баг 3, фолбэк-iframe:** без IFrame API сброс captions невозможен — в деградированном пути субтитры могут показаться по предпочтению пользователя. Принято: фолбэк — редкий путь при сбое API.
|
||||
- **Баг 3, порядок unload/load:** `unloadModule('captions')` сбрасывает на выкл, `loadModule('captions')` возвращает модуль (CC-кнопка); без повторного load кнопка CC пропала бы. Порядок обязателен.
|
||||
- **Worktree:** правки `Player.tsx`/`App.css` двух предыдущих задач (`2026-09-25-subtitles-off-by-default.md`, `2026-09-25-player-blank-page-after-download.md`) не коммичены — фиксы накладываются поверх, не откатывать. Номера строк Player.tsx указаны для текущего состояния worktree.
|
||||
- **MeTube-конфиг субтитров** (`subtitle_mode prefer_manual`) живёт на mediaVM, вне нашего контроля (AGENTS.md:11) — мы обрабатываем результат (in-band дорожки) у себя.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
- 2026-09-25 (ОТКАТ Бага 3): **Баг 3 (субтитры) откатывается вместе с `analytics/2026-09-25-subtitles-off-by-default.md`** — решение отменено пользователем (у YouTube нет force-off субтитров без потери кнопки CC; пользователь сам отключит субтитры в настройках YouTube-аккаунта: Настройки → Воспроизведение и производительность → Субтитры и CC → снять «Всегда показывать субтитры»). Из `Player.tsx` убираются: `unloadModule`/`loadModule('captions')` после `onReady`, методы `loadModule`/`unloadModule` из `YoutubePlayerApi`, `onLoadedMetadata` с `mode='disabled'` и эффект с обработчиком `addtrack`, `cc_load_policy` из `playerVars` и `src` фолбэк-iframe. **Баги 1 и 2 остаются в силе и реализуются в полном объёме** (самоисцеление зависших job, гвард терминальных статусов) — их задачи, критерии, план и тесты не меняются; в «Критериях» пункты 4–5 (Баг 3) снимаются, фолбэк plain-iframe остаётся без параметра `cc_load_policy`.
|
||||
- 2026-09-25: документ создан до реализации. Проверено по коду: `download_status` без сверки (videos.py:97–103), `reconcile_on_startup` только из lifespan (main.py:62–65), `_apply_metube_info` откатывает терминальные статусы веткой `elif our_status != "unknown"` (download_jobs.py:224–225) и веткой `failed` без гварда (209–211), `TERMINAL_STATUSES`/`ACTIVE_STATUSES` (download_job.py:11–12), `fetch_history` и `METUBE_STATUS_MAP` (metube_client.py:37–45, 78–81), поллинг фронта 2 с (DownloadButton.tsx:29–35), `YoutubePlayerApi` без `loadModule/unloadModule` и без `addtrack` (Player.tsx:13–18, 266–270). Решения: самоисцеление по устареванию `requested_at` И `updated_at` (гвард «один раз» через touch `updated_at`, без миграции); «нет в истории» → `unknown`; на ошибке fetch статус не менять; гвард `not authoritative and job.status in TERMINAL_STATUSES` с сохранением ветки error для активных джобов; сброс captions через `unloadModule`+`loadModule` после `onReady` (через `event.target`); `addtrack`-обработчик, отключающий только новую дорожку. README не обновляется: изменения — внутренний hardening, уровень описания («полная MeTube-интеграция … recovery после рестарта», README.md:10) остаётся верным; прецедент — предыдущие документы 2026-09-25 также без README-правок. Баг 3 — продолжение `2026-09-25-subtitles-off-by-default.md` (его риски подтвердились: force-off нет, дорожки могут появляться после `loadedmetadata`), туда добавлена журнальная запись-ссылка.
|
||||
78
analytics/2026-09-25-player-blank-page-after-download.md
Normal file
78
analytics/2026-09-25-player-blank-page-after-download.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Чёрная/пустая страница после завершения скачивания (краш при переключении плеера YouTube → локальная копия)
|
||||
|
||||
## Задача
|
||||
|
||||
Устранить краш приложения: после завершения скачивания видео страница видео становится полностью чёрной/пустой. Причина — создание `YT.Player` прямо на React-управляемом div; YT.Player заменяет его iframe'ом, и React при переключении ветки падает в commit-фазе (`NotFoundError`), размонтируя весь root.
|
||||
|
||||
Фикс — канонический паттерн интеграции YT.Player с React: React рендерит только контейнер `<div ref={ytHostRef} className="yt-player-host" />`, а сам плеер создаётся на императивно созданном дочернем div, который YT.Player и «съедает». Тогда React никогда не пытается удалить отсоединённый узел.
|
||||
|
||||
## Контекст
|
||||
|
||||
**Почему это происходит (причина подтверждена по коду):**
|
||||
|
||||
1. `frontend/src/components/Player.tsx:292` рендерит React-управляемый `<div ref={ytHostRef} className="yt-player-host" />` в YouTube-ветке.
|
||||
2. `Player.tsx:176–178`: эффект берёт `const host = ytHostRef.current` и вызывает `new window.YT.Player(host, {...})`. **YT.Player заменяет переданный элемент на `<iframe>`** (документированное поведение IFrame Player API): исходный div отсоединяется от DOM, на его месте стоит iframe. React при этом считает div по-прежнему дочерним элементом `.player-wrapper`.
|
||||
3. Цепочка после завершения скачивания: `DownloadButton.tsx:55–64` при переходе статуса в `completed` делает `invalidateQueries(['video', id])` → `VideoPage.tsx:14` перезапрашивает `['video', youtubeVideoId]` → `video.local.available` становится `true` → `Player.tsx:100` `useLocal` переключается → ре-рендер уходит в локальную ветку.
|
||||
4. В commit-фазе React удаляет поддерево YouTube-ветки: `removeChild(playerWrapper, hostDiv)` бросает `NotFoundError` (div уже не ребёнок — его вытеснил iframe). Неперехваченная ошибка в commit → React 19 (`react`/`react-dom` 19.2.8) размонтирует весь root → чёрная страница. Error boundary в приложении нет (grep по `frontend/src`: `ErrorBoundary`/`componentDidCatch` не найдены).
|
||||
5. Cleanup-эффект с `destroy()` (`Player.tsx:204–209`) не спасает: passive-очистка выполняется **после** commit'а; а при переходе в локальную ветку новый запуск эффекта сразу выходит по `if (useLocal) return` (строка 158), так что и `destroy()` в начале эффекта (строки 162–163) не выполняется. В любом случае — слишком поздно.
|
||||
|
||||
**Тот же дефект в других сценариях (фикс покрывает все):**
|
||||
|
||||
- **Размонтирование Player** (уход со страницы видео): React удаляет host-div, отсоединённый YT.Player → тот же `NotFoundError`.
|
||||
- **Таймаут-путь**: если `YT.Player` уже создан (div заменён), но `onReady` не сработал за 6 с, ветка таймаута (`Player.tsx:165–171`) вызывает `destroy()` и `setYtApiStatus('timeout')` → React меняет host-div на plain-iframe → удаление отсоединённого div → краш.
|
||||
|
||||
**Обратное переключение (локальная → YouTube) не крашит:** host-div при этом монтируется заново, YT.Player заменяет его уже после монтирования — удаления отсоединённого узла нет.
|
||||
|
||||
**Факты по смежным файлам:**
|
||||
|
||||
- `VideoPage.tsx:24`: `<Player key={video.youtube_video_id} … />` — key по video id, при переключении доступности локальной копии key не меняется → ремоунта нет, переключение ветки происходит «на месте» (реконсиляцией внутри того же инстанса). Key на сценарий не влияет.
|
||||
- `App.css:184–187`: `.player-wrapper` — `position: relative; aspect-ratio: 16/9`; `.player-wrapper iframe` — `position: absolute; inset: 0; width/height 100%` (это правило стилизует и iframe, созданный YT.Player — потому плеер сейчас и выглядит корректно); `.yt-player-host { position: absolute; inset: 0; }`.
|
||||
- **Внимание:** в worktree уже есть незакоммиченные правки `Player.tsx` от задачи «субтитры выключены по умолчанию» (`cc_load_policy: 0` в `playerVars` и в `src` фолбэк-iframe, отключение `textTracks` в локальной ветке). Фикс накладывается поверх текущего состояния worktree и эти правки сохраняет.
|
||||
- `main.tsx`: приложение в `StrictMode` — в dev эффект выполняется дважды, фикс должен переживать двойной вызов без дублей и ошибок.
|
||||
|
||||
## Затронутые подсистемы и файлы
|
||||
|
||||
Только frontend, два файла:
|
||||
|
||||
- `frontend/src/components/Player.tsx` — YouTube-эффект (строки 157–210): создание плеера на дочернем div, удаление дочернего div в cleanup и в ветке таймаута.
|
||||
- `frontend/src/App.css` — новое правило `.yt-player-host > div { width: 100%; height: 100%; }` рядом со строкой 187.
|
||||
|
||||
Для справки (без изменений): `frontend/src/pages/VideoPage.tsx` (key по `youtube_video_id`, строка 24), `frontend/src/components/DownloadButton.tsx` (инвалидации на `completed`, строки 55–64), backend, миграции, тесты, README.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. После завершения скачивания (статус `completed` → инвалидация → `local.available=true`) страница **не крашится**: контент остаётся, плеер переключается на локальную копию (`<video controls>` с `media_url`).
|
||||
2. Переключение локальная → YouTube (удаление копии «Удалить копию», `onError` локального видео) и обратно — без крашей, iframe YouTube-плеера создаётся и корректно отображается.
|
||||
3. Размонтирование Player (уход на ленту) — без ошибок в консоли; таймаут-путь (сбой загрузки скрипта или отсутствие `onReady` за 6 с) — рендерится plain-iframe, ошибок нет.
|
||||
4. StrictMode (dev, двойной вызов эффекта): нет дублирующихся iframe'ов и ошибок; cleanup уничтожает плеер и удаляет дочерний div.
|
||||
5. `npm run lint` (oxlint) и `npm run build` (`tsc -b && vite build`) в `frontend/` — чистые.
|
||||
6. Backend не тронут: изменения только в `frontend/src/components/Player.tsx`, `frontend/src/App.css`, `analytics/`.
|
||||
7. Деплой по правилу: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
|
||||
|
||||
## План
|
||||
|
||||
1. `Player.tsx`, YouTube-эффект (строки 157–210):
|
||||
- В начале эффекта завести `let child: HTMLDivElement | null = null`.
|
||||
- В `.then()` после существующих гардов (`cancelled || timedOut || !window.YT?.Player`) и проверки `const host = ytHostRef.current; if (!host) return`:
|
||||
```ts
|
||||
child = document.createElement('div')
|
||||
host.appendChild(child)
|
||||
const player = new window.YT.Player(child, { …существующие опции без изменений… })
|
||||
```
|
||||
- В ветке таймаута (строки 165–171) после `ytPlayerRef.current?.destroy()` добавить `child?.remove(); child = null;`.
|
||||
- В cleanup (строки 204–209) после `ytPlayerRef.current?.destroy()` добавить `child?.remove(); child = null;`.
|
||||
- Гарды `cancelled`/`timedOut` и вся существующая логика (`onReady`, сброс таймаута, `setYtApiStatus`) сохраняются.
|
||||
2. `App.css`: после строки 187 добавить `.yt-player-host > div { width: 100%; height: 100%; }`. **Решение: CSS-правило, а не inline-стили** — единообразно с существующей таблицей стилей; iframe YT.Player после замены остаётся внутри `.yt-player-host` и продолжает попадать под `.player-wrapper iframe` (строка 185) — визуальная раскладка не меняется.
|
||||
3. Проверки: `npm run lint`, `npm run build`; ручная — скачать видео на странице YouTube-видео и дождаться переключения на локальную копию; «Удалить копию» → обратное переключение; уход со страницы видео; таймаут (блокировка `www.youtube.com` в DevTools); dev-режим со StrictMode; деплой + health.
|
||||
|
||||
## Риски и ограничения
|
||||
|
||||
- **Незакоммиченные правки Player.tsx** (задача про субтитры: `cc_load_policy`, `textTracks`): фикс применяется поверх них, их не откатывать и не «причёсывать» попутно.
|
||||
- **Механика DOM у YT.Player:** при создании плеера widgetapi отсоединяет дочерний div (заменяя его iframe'ом) — поэтому `child.remove()` в cleanup в этом случае no-op (по спецификации `Element.remove()` без родителя ничего не делает, ошибку не бросает) и обязателен для сценария, когда плеер так и не был создан (сбой загрузки скрипта).
|
||||
- **Позиционирование iframe:** YT-iframe остаётся внутри `.yt-player-host`, который перекрывает всю `.player-wrapper` (`inset: 0`), правило `.player-wrapper iframe` продолжает работать — раскладка не меняется; проверить визуально при ручном тесте.
|
||||
- **Отсутствие error boundary:** React 19 без границы ошибок продолжит «выносить» root при любой другой неперехваченной commit-ошибке; фикс устраняет именно эту ошибку. Добавление общего error boundary — отдельная задача, в объём не входит.
|
||||
- **StrictMode:** двойной запуск эффекта в dev создаёт плеер дважды последовательно (cleanup между вызовами уничтожает первый) — дублей iframe быть не должно; проверить в ручном тесте.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
- 2026-09-25: документ создан до реализации. Гипотеза **подтверждена** по коду: `new YT.Player(host, …)` на React-управляемом div (`Player.tsx:178` + `:292`); YT.Player заменяет host-элемент iframe'ом; при переключении `useLocal` (инвалидация из `DownloadButton.tsx:61` после `completed`) React в commit-фазе удаляет отсоединённый div → `NotFoundError` → размонтирование root (React 19.2.8, error boundary нет); cleanup с `destroy()` выполняется после commit'а и не спасает. Тот же дефект на unmount и в таймаут-пути. Зафиксирован план: плеер на императивном дочернем div, `child.remove()` в cleanup и таймаут-ветке, CSS-правило `.yt-player-host > div` (выбор: CSS, не inline). README и backend не затрагиваются.
|
||||
77
analytics/2026-09-25-subtitles-off-by-default.md
Normal file
77
analytics/2026-09-25-subtitles-off-by-default.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Субтитры выключены по умолчанию в обоих плеерах (кнопка CC остаётся)
|
||||
|
||||
> **Статус (2026-09-25): решение ОТМЕНЕНО, правки откатываются.** Пользователь отказался от обходных путей ради субтитров: force-off у YouTube без потери кнопки CC не существует, поэтому субтитры пользователь отключит сам в настройках YouTube-аккаунта (Настройки → Воспроизведение и производительность → Субтитры и CC → снять «Всегда показывать субтитры»). Актуальное состояние — в журнале ниже.
|
||||
|
||||
## Задача
|
||||
|
||||
На странице видео субтитры не должны показываться сами по себе: по умолчанию они ВЫКЛЮЧЕНЫ и в локальном `<video>`, и в YouTube-embed. Кнопка/меню субтитров (CC) остаётся доступной — пользователь может включить дорожку вручную, как и раньше.
|
||||
|
||||
Три точки изменения в `frontend/src/components/Player.tsx`:
|
||||
|
||||
1. Локальная ветка `<video>`: на событии `loadedmetadata` пройтись по `video.textTracks` и выставить `mode = 'disabled'` всем дорожкам (стандартный HTML5 API). Нативная кнопка CC в контролах плеера останется и сможет включить дорожку вручную.
|
||||
2. YouTube-ветка (IFrame Player API): добавить `cc_load_policy: 0` в `playerVars` (существующие `playsinline: 1, autoplay: 1` сохранить).
|
||||
3. Фолбэк plain-iframe: параметр `cc_load_policy=0` в `src` (аналог).
|
||||
|
||||
## Контекст
|
||||
|
||||
- Текущее состояние `Player.tsx` (300 строк, последний коммит `b1faeb3` «Autoplay video when opening the video page»; рабочая ветка `master`, дерево чистое):
|
||||
- Локальная ветка (строки 251–261): `<video ref={videoRef} controls autoPlay playsInline src={video.local.media_url!} onError={…} />` — обработчика `loadedmetadata` и какой-либо работы с `textTracks` **нет** (grep по `frontend/src`: `textTracks`, `loadedmetadata`, `cc_load_policy` не встречаются).
|
||||
- `<track>`-элементов в JSX нет и backend `.vtt`/субтитры не отдаёт (grep по `*.py` пуст): в локальном плеере дорожки возможны только **встроенные в медиаконтейнер** (in-band, например MP4), отданные через `media_url` MeTube-прокси.
|
||||
- YouTube-ветка (строки 178–193): `new YT.Player(host, { videoId, host: 'https://www.youtube-nocookie.com', playerVars: { playsinline: 1, autoplay: 1 }, events: { onReady } })` — `cc_load_policy` отсутствует.
|
||||
- Фолбэк при таймауте API (строки 279–285): `<iframe src="https://www.youtube-nocookie.com/embed/{id}?autoplay=1" … />`.
|
||||
- `Player` монтируется только в `VideoPage.tsx:24` с `key={video.youtube_video_id}`: смена видео — ремоунт, обработчик `loadedmetadata` будет срабатывать на каждый свежий монтаж, отдельной навигационной логики не требуется.
|
||||
- **HTML5 API (факт для приёмки):** у `TextTrack.mode` три значения — `'disabled'` / `'hidden'` / `'showing'`. По WHATWG HTML дорожка `<track>` без атрибута `default` стартует с `mode='disabled'`, однако у in-band дорожек браузеры могут включить показ по флагам контейнера (forced) или по пользовательским настройкам («always show captions» в Chrome/системных настройках субтитров) — поэтому явный проход с `mode='disabled'` на `loadedmetadata` является защитной мерой, а не пустым действием. Присваивание `mode` у in-band дорожки легально (свойство writable); дорожки `kind='metadata'` не отображаются в любом случае, установка им `mode='disabled'` безвредна.
|
||||
- **Нативная CC-кнопка (факт для приёмки):** в нативных контролах браузера кнопка/меню субтитров отображается, когда `video.textTracks.length > 0`. Установка `mode='disabled'` не удаляет дорожки из `TextTrackList` и не прячет кнопку: выбор дорожки из меню ставит `mode='showing'`. Требуется ручная проверка в реальном браузере (Chrome/Firefox) — зафиксирована в критериях.
|
||||
- **YouTube `cc_load_policy` (факт для рисков):** по документации IFrame API значение `1` принудительно включает субтитры; `0`/отсутствие параметра — «по предпочтению пользователя». **Force-off у YouTube не существует**: `0` означает «не включать принудительно», но если у пользователя субтитры включены в настройках YouTube (embed хранит предпочтение в cookie), они могут показаться и с `0`.
|
||||
- Frontend без тестовой инфраструктуры; проверки — `npm run lint` (oxlint) и `npm run build` (`tsc -b && vite build`). Backend, миграции — не трогаем.
|
||||
|
||||
## Затронутые подсистемы и файлы
|
||||
|
||||
Только frontend, один файл:
|
||||
|
||||
- `frontend/src/components/Player.tsx`:
|
||||
- локальная ветка: на `<video>` добавить `onLoadedMetadata` — обойти `textTracks`, всем `mode = 'disabled'`;
|
||||
- YouTube-ветка: `playerVars: { playsinline: 1, autoplay: 1 }` → `{ playsinline: 1, autoplay: 1, cc_load_policy: 0 }`;
|
||||
- фолбэк-iframe: `src` → `https://www.youtube-nocookie.com/embed/{id}?autoplay=1&cc_load_policy=0`.
|
||||
|
||||
`App.css`, `VideoPage.tsx`, backend, тесты, миграции, README — без изменений.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Локальный плеер: субтитры не показываются по умолчанию — после `loadedmetadata` у всех дорожек `video.textTracks[*].mode === 'disabled'`; проверено на файле со **встроенной** дорожкой (in-band MP4).
|
||||
2. YouTube-embed: субтитры не включаются принудительно — `cc_load_policy: 0` присутствует и в `playerVars`, и в `src` фолбэк-iframe.
|
||||
3. Кнопка CC доступна и работает: в локальном плеере при наличии дорожек нативная кнопка/меню CC в контролах видна и ручной выбор дорожки включает её показ; у YouTube-embed CC-кнопка в UI плеера работает как раньше.
|
||||
4. Существующая логика не сломана: autoplay (обе ветки, включая фолбэк), двойной тап локального плеера с индикатором, кнопки «−10 сек»/«+10 сек» (disabled до `onReady` в YouTube-ветке), `onError`→переключение на YouTube, `onReady`-логика (сброс таймаута), фолбэк plain-iframe по таймауту, `destroy()` в cleanup — работают как раньше.
|
||||
5. `npm run lint` и `npm run build` в `frontend/` чистые.
|
||||
6. Backend не тронут: изменения только в `frontend/src/components/Player.tsx` и `analytics/`; `pytest` не требуется.
|
||||
7. Деплой по правилу: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
|
||||
|
||||
## План
|
||||
|
||||
1. `Player.tsx`, локальная ветка (строки 251–261): к `<video>` добавить React-обработчик `onLoadedMetadata`, который по `e.currentTarget.textTracks` выставляет `mode = 'disabled'` всем дорожкам, например:
|
||||
```tsx
|
||||
onLoadedMetadata={(e) => {
|
||||
Array.from(e.currentTarget.textTracks).forEach((track) => {
|
||||
track.mode = 'disabled'
|
||||
})
|
||||
}}
|
||||
```
|
||||
Существующие атрибуты (`controls`, `autoPlay`, `playsInline`, `src`, `onError`) не трогать. `Array.from` по `TextTrackList` (iterable) корректен и по TS DOM lib.
|
||||
2. `Player.tsx`, YouTube-ветка (строка 181): `playerVars: { playsinline: 1, autoplay: 1, cc_load_policy: 0 }` (тип `Record<string, string | number>` — число допустимо). `onReady`, таймеры, `host: youtube-nocookie.com` не трогать.
|
||||
3. `Player.tsx`, фолбэк (строка 281): `src` → `` `https://www.youtube-nocookie.com/embed/${video.youtube_video_id}?autoplay=1&cc_load_policy=0` ``. `title`/`allow`/`allowFullScreen` не менять.
|
||||
4. Проверки: `npm run lint`, `npm run build`; ручная — локальное видео со встроенной дорожкой (дорожки не показываются, CC-кнопка есть и включает), локальное видео без дорожек (поведение не изменилось), YouTube-видео (субтитры не включаются сами; автозапуск, кнопки ±10 сек, фолбэк при блокировке youtube.com в DevTools); деплой + health.
|
||||
|
||||
## Риски и ограничения
|
||||
|
||||
- **У YouTube нет force-off (главный риск, принят осознанно):** `cc_load_policy: 0` — это «не включать принудительно», поведение равно дефолту и учитывает предпочтение пользователя. Если у пользователя субтитры включены в настройках YouTube (embed-контекст хранит выбор в cookie), они могут показаться несмотря на `0`. Это ограничение YouTube, а не баг; явное `0` фиксирует намерение и защищает от случайного перехода на `1` в будущем.
|
||||
- **In-band дорожки и момент появления:** обычно `textTracks` заполнены уже к `loadedmetadata`; в редких случаях браузер добавляет дорожки позже (событие `addtrack`). Приёмка включает проверку на реальном файле с дорожкой; если окажется, что дорожки появляются после `loadedmetadata`, усилить обработчиком `addtrack` на `video.textTracks` (превентивно не делать — держим минимальный дифф).
|
||||
- **CC-кнопка появляется только при наличии дорожек:** если в медиафайле дорожек нет, нативной кнопки CC нет — это ожидаемо (нечего показывать), а не регресс. При наличии дорожек `mode='disabled'` кнопку не прячет.
|
||||
- **Браузерные/системные настройки пользователя:** Chrome/Edge «always show captions», системные субтитры Windows/macOS могут заставлять браузер показывать дорожки даже после нашей установки `'disabled'`; это вне контроля приложения и относится к пользовательским предпочтениям, а не к дефолту плеера.
|
||||
- **Не трогать существующую логику:** изменение аддитивное — один обработчик на `<video>`, один ключ в `playerVars`, один query-параметр в `src`; `onError`/recheck, таймауты, cleanup, рендер фолбэка не затрагиваются.
|
||||
- **Ремоунт по `key`:** `onLoadedMetadata` срабатывает при каждом монтировании `Player` (переход на страницу, смена видео) — это ожидаемо и идемпотентно.
|
||||
|
||||
## Журнал изменений
|
||||
|
||||
- 2026-09-25: документ создан перед реализацией. Зафиксированы факты по коду: `<video controls autoPlay playsInline>` без `onLoadedMetadata` и без работы с `textTracks` (Player.tsx:251–261); `<track>` в JSX нет, backend `.vtt` не отдаёт — локальные дорожки только in-band через `media_url`; `playerVars: { playsinline: 1, autoplay: 1 }` без `cc_load_policy` (строка 181); фолбэк-iframe `src` = `?autoplay=1` (строки 279–285); `Player` монтируется только в `VideoPage.tsx:24` с `key`. Решения: `onLoadedMetadata` с `mode='disabled'` по всем `textTracks` (нативная CC-кнопка остаётся, ручное включение работает — проверяется в приёмке); `cc_load_policy: 0` в `playerVars` и `&cc_load_policy=0` в `src` фолбэка; отсутствие force-off у YouTube зафиксировано в «Рисках». README не обновляется (описывает плеер на уровне «YouTube-плеер», README.md:10, без деталей опций); backend не трогаем.
|
||||
- 2026-09-25 (follow-up): подтвердились оба риска из «Рисков» — `cc_load_policy: 0` не отключает субтитры при включённом пользовательском предпочтении (force-off не существует), и in-band дорожки могут появляться после `loadedmetadata`. Продолжение зафиксировано как Баг 3 в `analytics/2026-09-25-download-status-fixes.md`: сброс `unloadModule('captions')` + `loadModule('captions')` через IFrame API после `onReady` (YouTube) и обработчик `addtrack` на `textTracks`, отключающий новые дорожки (локальное видео). Реализация выполняется в рамках той задачи; этот документ не переписывается.
|
||||
- 2026-09-25 (ОТКАТ): **решение отменено пользователем.** Обходные пути ради субтитров больше не делаем — все правки этой задачи откатываются в `frontend/src/components/Player.tsx`: убраны `cc_load_policy: 0` из `playerVars` и `cc_load_policy=0` из `src` фолбэк-iframe, вызовы `unloadModule('captions')`/`loadModule('captions')` в `onReady` и соответствующие методы `loadModule`/`unloadModule` из интерфейса `YoutubePlayerApi`, `onLoadedMetadata` с переводом `textTracks` в `mode='disabled'`, отдельный `useEffect` с обработчиком `addtrack`. Причина: у YouTube нет force-off субтитров без потери кнопки CC (подтверждено практикой реализации) — пользователь сам отключит субтитры в настройках YouTube-аккаунта (Настройки → Воспроизведение и производительность → Субтитры и CC → снять «Всегда показывать субтитры»). Незатронутые правки сохраняются: autoplay-атрибуты обеих веток, императивный дочерний div (фикс чёрной страницы), кнопки ±10 сек, двойной тап с индикатором, таймаут-фолбэк, cleanup. Баг 3 в `analytics/2026-09-25-download-status-fixes.md` откатывается вместе с этой задачей; Баги 1 и 2 там остаются в силе. Критерий отката: в `Player.tsx` не остаётся ни одного упоминания `captions`/`cc_load_policy`/`textTracks`/`addtrack`.
|
||||
|
|
@ -14,6 +14,8 @@ from app.services.download_jobs import (
|
|||
MeTubeRejected,
|
||||
delete_local_copy,
|
||||
get_latest_job,
|
||||
job_is_stale,
|
||||
reconcile_stale_job,
|
||||
request_download,
|
||||
)
|
||||
from app.services.metube_client import MeTubeClient
|
||||
|
|
@ -100,6 +102,15 @@ def download_status(youtube_video_id: str, db: Session = Depends(get_db)) -> dic
|
|||
job = get_latest_job(db, video.id)
|
||||
if job is None:
|
||||
return {"status": "not_downloaded", "progress_percent": None, "media_url": None, "error_message": None}
|
||||
# Lazy self-heal: an active job whose Socket.IO events stopped (e.g. the
|
||||
# terminal 'completed' event was missed while we were down) is reconciled
|
||||
# against MeTube history on poll -- at most once per stale window.
|
||||
if job_is_stale(job):
|
||||
try:
|
||||
reconcile_stale_job(db, job)
|
||||
except Exception:
|
||||
logger.warning("Could not self-heal stale download job %s", job.id, exc_info=True)
|
||||
db.rollback()
|
||||
return _serialize_job(job)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.download_job import ACTIVE_STATUSES, DownloadJob
|
||||
from app.models.download_job import ACTIVE_STATUSES, TERMINAL_STATUSES, DownloadJob
|
||||
from app.models.video import Video
|
||||
from app.services.metube_client import METUBE_STATUS_MAP, MeTubeClient
|
||||
|
||||
|
|
@ -206,6 +206,14 @@ def _apply_metube_info(job: DownloadJob, info: dict, *, authoritative: bool) ->
|
|||
if isinstance(percent, (int, float)):
|
||||
job.progress_percent = int(percent)
|
||||
|
||||
# Late, delayed 'updated' events must never roll a terminal job back (or
|
||||
# forward into 'failed'): Socket.IO delivers events out of order, so a
|
||||
# lagging progress tick often arrives after the authoritative 'completed'.
|
||||
# Progress/metube_job_id above are still taken; the status (and its
|
||||
# error_message/completed_at/media_url) is left alone.
|
||||
if not authoritative and job.status in TERMINAL_STATUSES:
|
||||
return
|
||||
|
||||
if our_status == "failed":
|
||||
job.status = "failed"
|
||||
job.error_message = info.get("msg") or info.get("error") or "Ошибка загрузки"
|
||||
|
|
@ -266,3 +274,74 @@ def reconcile_on_startup(db: Session) -> None:
|
|||
|
||||
db.commit()
|
||||
logger.info("Reconciled %d download job(s) against MeTube history", len(jobs))
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime | None:
|
||||
"""SQLite stores/returns naive datetimes regardless of the column's
|
||||
timezone=True, while Postgres returns aware ones -- normalize before any
|
||||
comparison with datetime.now(timezone.utc)."""
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def job_is_stale(job: DownloadJob, threshold_minutes: int = 5) -> bool:
|
||||
"""True when an active job had no events for a while: both requested_at and
|
||||
updated_at are older than the threshold. The updated_at leg implements the
|
||||
'reconcile once per window' backoff -- while events keep flowing, updated_at
|
||||
stays fresh and no MeTube history fetch is needed."""
|
||||
if job.status not in ACTIVE_STATUSES:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
threshold = timedelta(minutes=threshold_minutes)
|
||||
requested_at = _as_utc(job.requested_at)
|
||||
updated_at = _as_utc(job.updated_at)
|
||||
if requested_at is None or updated_at is None:
|
||||
return False
|
||||
return now - requested_at > threshold and now - updated_at > threshold
|
||||
|
||||
|
||||
def reconcile_stale_job(db: Session, job: DownloadJob) -> None:
|
||||
"""One-shot lazy self-heal for an active job whose Socket.IO events stopped
|
||||
(the backend may have missed the terminal event entirely). Mirrors
|
||||
reconcile_on_startup's matching, but is called from the download-status
|
||||
endpoint while the service is running:
|
||||
- 'done' entry is authoritative (finished -> completed, error -> failed);
|
||||
- 'queue'/'pending' entries keep the job active (still in flight);
|
||||
- no entry at all -> 'unknown' (UI shows a retry action);
|
||||
- history fetch failure is transient here (not a restart) -> status is left
|
||||
untouched, so the polling client keeps its last known state.
|
||||
Always touches updated_at and commits: that is the backoff that prevents
|
||||
the 2s polling loop from hammering /history (next reconcile only after
|
||||
the stale window has passed again)."""
|
||||
client = MeTubeClient()
|
||||
try:
|
||||
history = client.fetch_history()
|
||||
except Exception:
|
||||
logger.warning("Could not fetch MeTube history to self-heal stale job %s", job.id, exc_info=True)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# (item, authoritative) -- only the 'done' bucket is a terminal, trustworthy
|
||||
# outcome; 'queue'/'pending' are still in flight (see handle_metube_event).
|
||||
by_url: dict[str, tuple[dict, bool]] = {}
|
||||
for bucket, authoritative in (("queue", False), ("pending", False), ("done", True)):
|
||||
for item in history.get(bucket, []) or []:
|
||||
url = item.get("url")
|
||||
if url:
|
||||
by_url[url] = (item, authoritative)
|
||||
|
||||
video = db.get(Video, job.video_id)
|
||||
match = by_url.get(video.youtube_url) if video else None
|
||||
if match is None:
|
||||
job.status = "unknown"
|
||||
else:
|
||||
info, authoritative = match
|
||||
_apply_metube_info(job, info, authoritative=authoritative)
|
||||
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("Self-healed stale download job %s to status=%s", job.id, job.status)
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
|
|||
.player-wrapper iframe, .player-wrapper video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
|
||||
.player-wrapper video { touch-action: manipulation; }
|
||||
.yt-player-host { position: absolute; inset: 0; }
|
||||
.yt-player-host > div { width: 100%; height: 100%; }
|
||||
.video-page-meta-row { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; margin: 11px 0 22px; }
|
||||
.video-page-meta-row .video-page-source { margin: 0; }
|
||||
.video-page-source { color: var(--muted); font-size: 12px; }
|
||||
|
|
|
|||
|
|
@ -153,12 +153,15 @@ function Player({ video }: Props) {
|
|||
}, [useLocal])
|
||||
|
||||
// YouTube-ветка: динамическая идемпотентная загрузка IFrame Player API,
|
||||
// создание YT.Player в host-div с nocookie-хостом, фолбэк на plain-iframe по таймауту.
|
||||
// создание YT.Player на императивном дочернем div в host-div (YT.Player заменяет
|
||||
// переданный элемент iframe'ом, React управляет только контейнером), nocookie-хост,
|
||||
// фолбэк на plain-iframe по таймауту.
|
||||
useEffect(() => {
|
||||
if (useLocal) return
|
||||
let cancelled = false
|
||||
let timedOut = false
|
||||
let timeoutId: number | null = null
|
||||
let child: HTMLDivElement | null = null
|
||||
ytPlayerRef.current?.destroy()
|
||||
ytPlayerRef.current = null
|
||||
|
||||
|
|
@ -167,6 +170,8 @@ function Player({ video }: Props) {
|
|||
timedOut = true
|
||||
ytPlayerRef.current?.destroy()
|
||||
ytPlayerRef.current = null
|
||||
if (child?.parentNode) child.remove()
|
||||
child = null
|
||||
if (!cancelled) setYtApiStatus('timeout')
|
||||
}, YT_API_TIMEOUT_MS)
|
||||
|
||||
|
|
@ -175,7 +180,9 @@ function Player({ video }: Props) {
|
|||
if (cancelled || timedOut || !window.YT?.Player) return
|
||||
const host = ytHostRef.current
|
||||
if (!host) return
|
||||
const player = new window.YT.Player(host, {
|
||||
child = document.createElement('div')
|
||||
host.appendChild(child)
|
||||
const player = new window.YT.Player(child, {
|
||||
videoId: video.youtube_video_id,
|
||||
host: 'https://www.youtube-nocookie.com',
|
||||
playerVars: { playsinline: 1, autoplay: 1 },
|
||||
|
|
@ -206,6 +213,8 @@ function Player({ video }: Props) {
|
|||
if (timeoutId !== null) window.clearTimeout(timeoutId)
|
||||
ytPlayerRef.current?.destroy()
|
||||
ytPlayerRef.current = null
|
||||
if (child?.parentNode) child.remove()
|
||||
child = null
|
||||
}
|
||||
}, [useLocal, video.youtube_video_id])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -389,3 +390,267 @@ def test_reconcile_leaves_completed_jobs_untouched(monkeypatch, db_session):
|
|||
db_session.refresh(job)
|
||||
assert job.status == "completed"
|
||||
assert job.media_url == "http://x/f.mp4"
|
||||
|
||||
|
||||
# --- Bug 2: non-authoritative 'updated' events must never roll terminal
|
||||
# statuses back (double progress bar bug) ---------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_event_does_not_roll_back_completed_job(db_session):
|
||||
"""A late 'updated' event arriving after the authoritative 'completed' must
|
||||
not flip the job back to downloading/postprocessing/failed -- that used to
|
||||
re-trigger the progress bar after completion. Progress ticks are still
|
||||
taken; status and its payload (media_url/completed_at/error_message) are
|
||||
left alone."""
|
||||
video = _seed_video(db_session)
|
||||
completed_at = datetime(2026, 9, 20, tzinfo=timezone.utc)
|
||||
job = DownloadJob(
|
||||
video_id=video.id,
|
||||
status="completed",
|
||||
metube_job_id="vid1.vid1",
|
||||
media_url="http://metube.local/download/f.mp4",
|
||||
progress_percent=100,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(
|
||||
db_session,
|
||||
"updated",
|
||||
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "downloading", "percent": 12}),
|
||||
)
|
||||
db_session.refresh(job)
|
||||
assert job.status == "completed"
|
||||
assert job.progress_percent == 12
|
||||
assert job.media_url == "http://metube.local/download/f.mp4"
|
||||
assert job.completed_at is not None
|
||||
assert job.error_message is None
|
||||
|
||||
await download_jobs.handle_metube_event(
|
||||
db_session,
|
||||
"updated",
|
||||
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "postprocessing", "percent": 55}),
|
||||
)
|
||||
db_session.refresh(job)
|
||||
assert job.status == "completed"
|
||||
assert job.progress_percent == 55
|
||||
assert job.media_url == "http://metube.local/download/f.mp4"
|
||||
|
||||
# a late 'error' tick must not turn it into failed either
|
||||
await download_jobs.handle_metube_event(
|
||||
db_session,
|
||||
"updated",
|
||||
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "error", "msg": "late blip"}),
|
||||
)
|
||||
db_session.refresh(job)
|
||||
assert job.status == "completed"
|
||||
assert job.media_url == "http://metube.local/download/f.mp4"
|
||||
assert job.completed_at is not None
|
||||
assert job.error_message is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_event_does_not_roll_back_failed_job(db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="failed", metube_job_id="vid1.vid1", error_message="boom")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(
|
||||
db_session,
|
||||
"updated",
|
||||
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "downloading", "percent": 30}),
|
||||
)
|
||||
db_session.refresh(job)
|
||||
assert job.status == "failed"
|
||||
assert job.error_message == "boom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_event_does_not_touch_deleted_job(db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="deleted")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(
|
||||
db_session,
|
||||
"updated",
|
||||
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "postprocessing", "percent": 10}),
|
||||
)
|
||||
db_session.refresh(job)
|
||||
assert job.status == "deleted"
|
||||
assert job.media_url is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authoritative_completed_still_finalizes_queued_job(monkeypatch, db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.build_media_url",
|
||||
lambda self, filename: f"http://metube.local/download/{filename}",
|
||||
)
|
||||
|
||||
await download_jobs.handle_metube_event(
|
||||
db_session,
|
||||
"completed",
|
||||
json.dumps({"id": "vid1.vid1", "url": video.youtube_url, "status": "finished", "filename": "f.mp4"}),
|
||||
)
|
||||
db_session.refresh(job)
|
||||
assert job.status == "completed"
|
||||
assert job.media_url == "http://metube.local/download/f.mp4"
|
||||
|
||||
|
||||
# --- Bug 1: stale-job detection and lazy self-heal --------------------------
|
||||
|
||||
|
||||
def test_job_is_stale_requires_both_timestamps_old(db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
old = now - timedelta(minutes=10)
|
||||
|
||||
job.requested_at = old
|
||||
job.updated_at = old
|
||||
assert download_jobs.job_is_stale(job) is True
|
||||
|
||||
# events still flowing: fresh updated_at means no reconcile needed
|
||||
job.updated_at = now
|
||||
assert download_jobs.job_is_stale(job) is False
|
||||
|
||||
# terminal jobs are never reconciled
|
||||
job.updated_at = old
|
||||
job.status = "completed"
|
||||
assert download_jobs.job_is_stale(job) is False
|
||||
|
||||
|
||||
def test_reconcile_stale_job_completed_from_done(monkeypatch, db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.fetch_history",
|
||||
lambda self: {
|
||||
"queue": [],
|
||||
"pending": [],
|
||||
"done": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "finished", "filename": "f.mp4"}],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.build_media_url",
|
||||
lambda self, filename: f"http://metube.local/download/{filename}",
|
||||
)
|
||||
|
||||
download_jobs.reconcile_stale_job(db_session, job)
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "completed"
|
||||
assert job.media_url == "http://metube.local/download/f.mp4"
|
||||
assert job.progress_percent == 100
|
||||
|
||||
|
||||
def test_reconcile_stale_job_failed_from_done_error(monkeypatch, db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.fetch_history",
|
||||
lambda self: {
|
||||
"queue": [],
|
||||
"pending": [],
|
||||
"done": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "error", "msg": "HTTP 429 bot check"}],
|
||||
},
|
||||
)
|
||||
|
||||
download_jobs.reconcile_stale_job(db_session, job)
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "failed"
|
||||
assert job.error_message == "HTTP 429 bot check"
|
||||
|
||||
|
||||
def test_reconcile_stale_job_keeps_active_when_still_in_queue(monkeypatch, db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.fetch_history",
|
||||
lambda self: {
|
||||
"queue": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "pending"}],
|
||||
"pending": [],
|
||||
"done": [],
|
||||
},
|
||||
)
|
||||
|
||||
download_jobs.reconcile_stale_job(db_session, job)
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "queued"
|
||||
|
||||
|
||||
def test_reconcile_stale_job_unknown_when_not_in_history(monkeypatch, db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.fetch_history",
|
||||
lambda self: {"queue": [], "pending": [], "done": []},
|
||||
)
|
||||
|
||||
download_jobs.reconcile_stale_job(db_session, job)
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "unknown"
|
||||
|
||||
|
||||
def test_reconcile_stale_job_fetch_error_leaves_status(monkeypatch, db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="downloading")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
def raise_error(self):
|
||||
raise RuntimeError("connection refused")
|
||||
|
||||
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", raise_error)
|
||||
|
||||
download_jobs.reconcile_stale_job(db_session, job)
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "downloading"
|
||||
|
||||
|
||||
def test_reconcile_stale_job_touches_updated_at_for_backoff(monkeypatch, db_session):
|
||||
"""updated_at is bumped on every reconcile attempt (success or failure),
|
||||
so the 2s polling loop triggers at most one history fetch per window."""
|
||||
video = _seed_video(db_session)
|
||||
old = datetime.now(timezone.utc) - timedelta(minutes=10)
|
||||
job = DownloadJob(video_id=video.id, status="queued", requested_at=old, updated_at=old)
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.fetch_history",
|
||||
lambda self: {"queue": [], "pending": [], "done": []},
|
||||
)
|
||||
|
||||
assert download_jobs.job_is_stale(job) is True
|
||||
download_jobs.reconcile_stale_job(db_session, job)
|
||||
assert download_jobs.job_is_stale(job) is False
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -128,6 +128,149 @@ def test_download_status_not_downloaded(client, db_session):
|
|||
assert resp.json()["status"] == "not_downloaded"
|
||||
|
||||
|
||||
def _seed_job_with_age(db_session, video, status="queued", age_minutes=10):
|
||||
"""Seeds a job whose requested_at/updated_at are both `age_minutes` in the
|
||||
past (aware datetimes -- see AGENTS.md: SQLite stores them naive, the
|
||||
production code normalizes them before comparing)."""
|
||||
from app.models.download_job import DownloadJob
|
||||
|
||||
old = datetime.now(timezone.utc) - timedelta(minutes=age_minutes)
|
||||
job = DownloadJob(video_id=video.id, status=status, requested_at=old, updated_at=old)
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
return job
|
||||
|
||||
|
||||
def test_download_status_self_heals_stale_job_to_completed(client, db_session, monkeypatch):
|
||||
"""Bug 1: a job stuck in 'queued' because the terminal Socket.IO event was
|
||||
missed is reconciled against MeTube history on poll."""
|
||||
video = _seed_single_video(db_session)
|
||||
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.fetch_history",
|
||||
lambda self: {
|
||||
"queue": [],
|
||||
"pending": [],
|
||||
"done": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "finished", "filename": "f.mp4"}],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.build_media_url",
|
||||
lambda self, filename: f"http://metube.local/download/{filename}",
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "completed"
|
||||
assert body["media_url"] == "http://metube.local/download/f.mp4"
|
||||
|
||||
|
||||
def test_download_status_self_heals_stale_job_to_failed(client, db_session, monkeypatch):
|
||||
video = _seed_single_video(db_session)
|
||||
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.fetch_history",
|
||||
lambda self: {
|
||||
"queue": [],
|
||||
"pending": [],
|
||||
"done": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "error", "msg": "HTTP 429 bot check"}],
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "failed"
|
||||
assert body["error_message"] == "HTTP 429 bot check"
|
||||
assert body["media_url"] is None
|
||||
|
||||
|
||||
def test_download_status_stale_job_still_in_queue_stays_active(client, db_session, monkeypatch):
|
||||
video = _seed_single_video(db_session)
|
||||
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.fetch_history",
|
||||
lambda self: {
|
||||
"queue": [{"id": "vid1.vid1", "url": video.youtube_url, "status": "pending"}],
|
||||
"pending": [],
|
||||
"done": [],
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "queued"
|
||||
|
||||
|
||||
def test_download_status_stale_job_not_in_history_is_unknown(client, db_session, monkeypatch):
|
||||
video = _seed_single_video(db_session)
|
||||
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.fetch_history",
|
||||
lambda self: {"queue": [], "pending": [], "done": []},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "unknown"
|
||||
|
||||
|
||||
def test_download_status_fresh_job_does_not_fetch_history(client, db_session, monkeypatch):
|
||||
video = _seed_single_video(db_session)
|
||||
_seed_job_with_age(db_session, video, status="queued", age_minutes=0)
|
||||
|
||||
def fail_if_called(self):
|
||||
raise AssertionError("fetch_history must not be called for a fresh job")
|
||||
|
||||
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", fail_if_called)
|
||||
|
||||
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "queued"
|
||||
|
||||
|
||||
def test_download_status_terminal_job_does_not_fetch_history(client, db_session, monkeypatch):
|
||||
video = _seed_single_video(db_session)
|
||||
_seed_job_with_age(db_session, video, status="completed", age_minutes=10)
|
||||
|
||||
def fail_if_called(self):
|
||||
raise AssertionError("fetch_history must not be called for a terminal job")
|
||||
|
||||
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", fail_if_called)
|
||||
|
||||
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "completed"
|
||||
|
||||
|
||||
def test_download_status_fetch_error_keeps_status(client, db_session, monkeypatch):
|
||||
"""A transient history-fetch failure must not bubble up nor change the
|
||||
job's status -- the poll response keeps the last known state."""
|
||||
video = _seed_single_video(db_session)
|
||||
_seed_job_with_age(db_session, video, status="queued", age_minutes=10)
|
||||
|
||||
def raise_error(self):
|
||||
raise RuntimeError("connection refused")
|
||||
|
||||
monkeypatch.setattr("app.services.metube_client.MeTubeClient.fetch_history", raise_error)
|
||||
|
||||
resp = client.get(f"/api/videos/{video.youtube_video_id}/download-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "queued"
|
||||
|
||||
|
||||
def test_recheck_local_downgrades_to_unknown_when_media_missing(client, db_session, monkeypatch):
|
||||
from app.models.download_job import DownloadJob
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue