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

23 KiB
Raw Permalink Blame History

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