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