- 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.
23 KiB
Фиксы статусов загрузки: самоисцеление зависших 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 (самоисцеление): при
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. - Баг 1 (не чаще раза за окно): свежий job (события шли недавно) и терминальный job не триггерят
fetch_history; повторный поллинг (2 с) не молотит MeTube. - Баг 2: неавторитетные события (
updated) никогда не меняют статус терминального джоба (completed/failed/deleted) — ни веткаdownloading/postprocessing, ни веткаerror; при этом для активных джобов веткаerrorпо-прежнему переводит вfailed, а авторитетныйcompletedпо-прежнему завершает джоб. - Баг 3 (YouTube): при включённом пользовательском предпочтении субтитров после
onReadyсубтитры выключены; CC-кнопка в UI плеера остаётся и вручную включает дорожку.cc_load_policy: 0сохраняется. - Баг 3 (локально): поздние in-band дорожки, добавленные после
loadedmetadata, получаютmode='disabled'; ранее включённая пользователем дорожка при этом не трогается; нативная CC-кнопка работает. - Регрессий нет:
pytest(все существующие тесты зелёные, включаяtest_handle_metube_event_error_marks_failedиtest_transient_finished_in_updated_event_does_not_complete_job),npm run lint,npm run buildчистые. - Деплой:
docker compose up -d --build,curl http://localhost:8080/api/health→ OK.
План
- Баг 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), как сейчас.
- После обновления
- Баг 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).
- Константа порога, напр.
- Баг 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.
- После
- Баг 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](guardvideoRef.current, cleanup сremoveEventListener) — поверх существующегоonLoadedMetadata, который не трогаем. - Фолбэк plain-iframe не менять (API недоступно — см. «Риски»);
cc_load_policy: 0оставить в обеих YouTube-ветках.
- Расширить
- Тесты (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 +updateddownloading/postprocessing/error → остаётся completed (media_url/completed_atцелы,error_messageне пишется); failed +updateddownloading → остаётся 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) значениями в прошлом.
- Проверки:
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на каждый запрос — гвард и явный touchupdated_atдают одну сверку за окноSTALE_JOB_AFTER. Раса двух параллельных GET безвредна (обе сверки идемпотентны). unknownvs «оставить 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(гвард «один раз» через touchupdated_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), туда добавлена журнальная запись-ссылка.