myYouTube/analytics/2026-09-25-download-status-fixes.md
vrubelroman 43adec5224 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.
2026-09-27 23:36:09 +00:00

90 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Фиксы статусов загрузки: самоисцеление зависших 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`), туда добавлена журнальная запись-ссылка.