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:
vrubelroman 2026-09-27 23:36:09 +00:00
parent b1faeb3729
commit 43adec5224
9 changed files with 758 additions and 5 deletions

View 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`), туда добавлена журнальная запись-ссылка.

View 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 не затрагиваются.

View 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`.