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