myYouTube/analytics/2026-09-27-shorts-split-and-feed.md
vrubelroman 252f38597d Split shorts from regular videos with a vertical shorts feed
- Detect shorts by duration (SHORTS_MAX_DURATION_SECONDS, default 180)
  and expose is_short in feed, video and channel-activity DTOs.
- Feed gains type=all|long|short and anchor; lists show a two-mode
  'Обычные | Shorts' filter (no 'Все') persisted in the URL.
- Clicking a short opens /shorts/🆔 a vertical scroll-snap feed with
  autoplay for the active slide, context-aware endpoints and infinite
  loading.
- Keep the sound choice across swipes, syncing with the player's own
  mute control and guarding against the widget's stale isMuted() reads.
2026-09-28 09:33:35 +00:00

175 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Разделение видео на shorts/обычные и вертикальная лента Shorts
## Задача
Разделить видео на **shorts** и **обычные** по длительности и дать пользователю:
1. Сегмент-фильтр «Все | Обычные | Shorts» во всех списках видео, с состоянием в URL (`?type=all|long|short`).
2. Разные точки перехода по клику: short → вертикальная лента `/shorts/:youtubeVideoId`, обычное → существующая страница `/video/:youtubeVideoId`.
3. Вертикальную full-screen ленту Shorts (YouTube/TikTok-подобную) с автоплеем активного ролика, паузой неактивных и бесконечной подгрузкой — в контексте того фильтра, откуда её открыли.
Порог определяется новым конфигом `SHORTS_MAX_DURATION_SECONDS` (по умолчанию `180`). Видео с неизвестной длительностью (`duration_seconds IS NULL`) считаются **обычными**. Миграции не нужны: `duration_seconds` уже хранится, shorts попадают тем же путём — через uploads-плейлист канала.
## Контекст
Проверено по фактическому коду (worktree чист, ветка `master`):
- Длительность уже сохраняется: `sync.py:221` парсит `duration_iso8601` → `Video.duration_seconds` (`backend/app/models/video.py:19`, nullable), а `youtube_client.py:242` берёт `contentDetails.duration` из videos.list по id из **uploads-плейлиста** (`sync.py:190-197`). Отдельный sync для shorts не нужен.
- Фильтрация ленты — `GET /api/feed` (`backend/app/api/feed.py:77-151`): параметры `category_id/uncategorized/channel_id/downloaded/search/new_only/limit/cursor`, курсор `(published_at, id)` (`_encode_cursor`/`_decode_cursor`), сериализация через `serialize_video`. Фильтры применяются к одному `query` до курсора, поэтому новый фильтр по длительности естественно пагинируется вместе с остальными.
- `serialize_video` (`backend/app/services/video_presentation.py:38-57`) — единая точка сериализации DTO видео для ленты, `GET /api/videos/{id}` (`videos.py:48-58`) и видео внутри `GET /api/channels/activity` (`channels.py:226-229`). Значит `is_short` достаточно добавить здесь один раз.
- `GET /api/channels/activity` (`channels.py:130-233`) отдаёт в `videos` тот же DTO. По решению пользователя фильтр `type` в режиме «Каналы» (`?view=channels`) **не применяется** и на эндпоинт не передаётся.
- `frontend/src/pages/Feed.tsx` обслуживает `/`, `/category/:id`, `/uncategorized`, `/local`, `/search`; режим `?view=channels` уже читается из `searchParams` (`Feed.tsx:22`). `supportsViewToggle` = `!isLocal && pathname !== '/search'` (`Feed.tsx:21`) — на `/local` и `/search` переключателя «Лента|Каналы» нет, но фильтр типа там нужен.
- `frontend/src/pages/ChannelVideos.tsx` — отдельная страница списка видео канала; сейчас без query-фильтров и без view-toggle. Фильтр `type` добавляется и сюда.
- Ссылки на видео строятся в `VideoCard.tsx:13` и `CompactVideoCard.tsx:11` (`/video/{youtube_video_id}`, `state={{ from }}`, сохранение скролла `feed-scroll:*`). Они — единственные места видео-ссылок (VideoPage/ProductPage используют их; `ChannelActivityList` рендерит `CompactVideoCard`).
- `frontend/src/components/AppShell.tsx:69` (`Sidebar`) сохраняет `?view=channels` в ссылках «Все видео» и категорий — по этой же схеме нужно сохранять `?type`.
- `frontend/src/api/client.ts:173-194` — `FeedVideoDto`; `getFeed` (`226-249`) кодирует query-параметры.
- Роуты — `App.tsx:22-35`; `/video/:youtubeVideoId` есть, `/shorts/:youtubeVideoId` нет.
- В README «Что осталось» shorts-фильтр указан как отложенный (`README.md:20`) — пункт нужно убрать после реализации.
Существующие тесты, которые **затронет** добавление поля: `tests/test_channel_activity.py:243-254` (`test_activity_item_shape`) проверяет точный набор ключей DTO видео — его нужно дополнить `is_short`. `tests/test_feed.py:303-317` (`test_feed_item_shape`) набор ключей видео целиком не фиксирует — безопасно.
## Затронутые подсистемы и файлы
Backend:
- `backend/app/config.py` — новый `shorts_max_duration_seconds: int = 180` (рядом с `new_videos_window_days`).
- `backend/app/api/feed.py` — новый query-параметр `type` (`all|long|short`, default `all`) и опциональный `anchor`; фильтр по `Video.duration_seconds`.
- `backend/app/services/video_presentation.py` — `is_short` в DTO `serialize_video`.
- `backend/app/api/channels.py` — без изменений логики фильтра (в `videos` появится `is_short` автоматически через `serialize_video`).
- `.env.example` и `.env` — `SHORTS_MAX_DURATION_SECONDS=180` с комментарием.
- Миграции — не нужны.
Frontend:
- `frontend/src/api/client.ts` — `FeedVideoDto.is_short`, параметры `type`/`anchor` в `getFeed`, экспорт типа `VideoType`.
- `frontend/src/pages/Feed.tsx` — чтение `type`, сегмент-контрол, проброс в `getFeed`, сохранение `type` в ссылках mobile-category-nav/local-filter-nav/view-toggle, учёт в queryKey.
- `frontend/src/pages/ChannelVideos.tsx` — `type` из URL, сегмент-контрол, проброс в `getFeed`, сохранение в queryKey.
- `frontend/src/components/AppShell.tsx` — `Sidebar` сохраняет `type` (и `view`) в ссылках «Все видео» и категорий.
- `frontend/src/components/VideoCard.tsx`, `CompactVideoCard.tsx` — href по `is_short` + контекст ленты для `/shorts/:id`.
- `frontend/src/App.tsx` — роут `/shorts/:youtubeVideoId`.
- `frontend/src/pages/ShortsFeed.tsx` — **новый**: вертикальная лента; `frontend/src/components/ShortsPlayer.tsx` — **новый** (императивный плеер активного слайда, если выносить отдельно).
- `frontend/src/App.css` — сегмент-контрол типа, лента shorts (scroll-snap), оверлеи, адаптив/`prefers-reduced-motion`.
Тесты:
- `tests/test_feed.py` (или новый `tests/test_shorts_feed.py`) — фильтр `type`, границы, NULL, `is_short`, пагинация в отфильтрованном наборе, `anchor`; обновить `tests/test_channel_activity.py::test_activity_item_shape`.
Документация:
- `README.md` — пункт в «Что сделано», чистка отложенного списка.
## API-контракт
`GET /api/feed` (добавление **обратносовместимое**, дефолт `all` = текущее поведение):
| Параметр | Тип | Дефолт | Поведение |
|---|---|---|---|
| `type` | `all \| long \| short` | `all` | `all` — без фильтра; `short` — `duration_seconds IS NOT NULL AND duration_seconds <= shorts_max_duration_seconds`; `long` — `duration_seconds IS NULL OR duration_seconds > shorts_max_duration_seconds`. Недопустимое значение → `422` (валидация через `Literal`). |
| `anchor` | `str` (youtube_video_id) | нет | Если задан и видео найдено — страница начинается с этого видео и продолжается в сторону более старых (`published_at < a.published_at OR (== AND id <= a.id)`) поверх остальных фильтров; используется лентой Shorts для открытия конкретного ролика. Неизвестный якорь — деградация к обычной первой странице (без ошибки). |
Имя параметра в Python-сигнатуре — `video_type: Literal[...] = Query("all", alias="type")` (alias, чтобы не затенять builtin `type`); наружу это `?type=`.
Фильтр по типу:
```python
if video_type == "short":
query = query.filter(
Video.duration_seconds.is_not(None),
Video.duration_seconds <= settings.shorts_max_duration_seconds,
)
elif video_type == "long":
query = query.filter(
or_(
Video.duration_seconds.is_(None),
Video.duration_seconds > settings.shorts_max_duration_seconds,
)
)
```
NULL-длительность не проходит `<=`/`>` автоматически, поэтому `long` обязан явно включать `IS NULL`. Курсор применяется после фильтров, как сейчас.
Поле DTO: во все сериализованные видео добавляется
```
"is_short": video.duration_seconds is not None and video.duration_seconds <= settings.shorts_max_duration_seconds
```
в `serialize_video` → автоматически во **feed items**, `GET /api/videos/{id}` и `videos` внутри `GET /api/channels/activity`. Порог читается из `settings` в момент вызова (тестируемо через `monkeypatch.setattr(settings, ...)`).
`GET /api/channels/activity` параметр `type` **не принимает** (режим «Каналы» не фильтруется), но возвращаемые видео получают `is_short`.
## План
### Backend
1. `config.py`: `shorts_max_duration_seconds: int = 180`. В `.env.example` — секция/строка с комментарием: «Видео короче или равны этому числу (сек) считаются Shorts; неизвестная длительность — обычное видео». В `.env` — то же значение по образцу.
2. `video_presentation.py`: `is_short` в `serialize_video` (импорт `settings`).
3. `feed.py`: параметры `video_type`/`anchor`; фильтр длительности; разбор якоря (`Video.youtube_video_id == anchor`), условие старта страницы; порядок — фильтры → якорь → курсор → `order_by` → limit+1.
4. Тесты: фикстура `client` как в `tests/test_feed.py`; сиды с явными `duration_seconds` (NULL, 179, 180, 181) и выполнением условий.
5. Обновить `tests/test_channel_activity.py::test_activity_item_shape` (добавить `is_short`); в `tests/test_videos.py` добавить проверку `is_short` (pytest).
### Frontend — фильтр и ссылки
6. `client.ts`: `is_short: boolean` в `FeedVideoDto`; `VideoType = 'all' | 'long' | 'short'`; в `getFeed` — `type` (шлём `type=all` явно или опускаем, единообразно) и `anchor`.
7. `Feed.tsx`:
- `type` = `searchParams.get('type')` с валидацией (default `all`), `typeQuery`.
- Сегмент-контрол «Все | Обычные | Shorts» в `page-heading` рядом с view-toggle; рендер при `!isChannelsView` (то есть на `/`, `/category/:id`, `/uncategorized`, `/search`, `/local`); в режиме «Каналы» фильтр не показывается.
- `getFeed({ ..., type })`, `type` в queryKey.
- Сохранять `type` в ссылках: mobile-category-nav, local-filter-nav, view-toggle (переход «Лента» из «Каналов» — с текущим `type`, если он есть), «Показать все» (`new=1`) — тоже с `type`.
- Пустое состояние для `type=short`/`long` — отдельные тексты (например, «Здесь пока нет Shorts»).
8. `ChannelVideos.tsx`: `type` из URL, сегмент-контрол, `getFeed({ channelId, type, ... })`, queryKey с `type`.
9. `AppShell.tsx` (`Sidebar`): обобщить `viewQuery` в набор сохраняемых query-параметров `view`/`type`; переносить их в ссылки `/` и `/category/:id`. `?new=1` (badge) — feed-специфичен, оставить как сейчас.
10. `VideoCard.tsx` / `CompactVideoCard.tsx`: `href = video.is_short ? shortHref(video, location) : '/video/'+id`; для short добавлять контекст текущего списка в query строку ленты.
Соглашение о контексте Shorts (параметры самой ссылки `/shorts/:id`):
| Источник | Контекст в URL `/shorts/:id` |
|---|---|
| `/` | без параметров |
| `/category/:id` | `category=<id>` |
| `/uncategorized` | `uncategorized=true` |
| `/local` (+ его `category`/`uncategorized`) | `downloaded=true[&category=..][&uncategorized=true]` |
| `/search?q=..` | `q=<поисковый запрос>` |
| `/channels/:id/videos` | `channel=<id>` |
`ShortsFeed` разбирает эти параметры и вызывает `getFeed({ type: 'short', categoryId, uncategorized, downloaded, search, channelId, anchor, cursor, limit })`. Контекст хранится в URL → refresh/шаринг сохраняют фильтр.
### Frontend — лента Shorts
11. `App.tsx`: `<Route path="/shorts/:youtubeVideoId" element={<ShortsFeed />} />`.
12. `ShortsFeed.tsx`: `useInfiniteQuery` по тем же context-параметрам с `type='short'` и `anchor=youtubeVideoId`; вертикальный скролл-контейнер; активный слайд; бесконечная подгрузка; empty/error/loading; кнопка «Назад» (как в VideoPage: `state.from` → `navigate(-1)` с fallback).
13. Механика активного слайда (см. ниже).
14. `App.css`: контейнер, слайды, оверлей, адаптив, reduced-motion.
### Проверка
15. `pytest` (новые + регресс), `npm run lint`, `npm run build` в `frontend/`.
16. Ручная проверка в браузере: фильтр на всех 6 местах, сохранение в URL при переключении категории/refresh; short → лента, обычное → страница; автоплей/пауза; подгрузка; режим «Каналы» не затронут.
17. Деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
## Решение по механике ленты Shorts (без костылей)
- **Разметка**: `.shorts-feed` — вертикальный скролл-контейнер (`overflow-y: auto; height: calc(100dvh - <topbar>); scroll-snap-type: y mandatory; overscroll-behavior: contain`); каждый `.shorts-slide` — `height: 100%; scroll-snap-align: start; scroll-snap-stop: always`. Один слайд за жест, без ручного расчёта позиций.
- **Определение активного слайда**: один `IntersectionObserver` на контейнер, `threshold: 0.6`, наблюдает все слайды; слайд с `intersectionRatio >= 0.6` становится активным (`setActiveIndex`). Это чище, чем ручной `onScroll` + деление `scrollTop` на высоту: устойчиво к динамической высоте (мобильные панели, `100dvh`) и к разной вёрстке слайдов.
- **Играет только активный**: плеер монтируется **только для активного слайда** (и, опционально, предзагружается соседний). Неактивные слайды содержат лишь постер-миниатюру. Анмаунт плеера = гарантированная остановка звука/воспроизведения, не нужен ручной `pause` для каждого типа плеера:
- локальная копия — `<video autoPlay playsInline controls src={media_url}>`;
- YouTube — `YT.Player` (уже есть загрузчик IFrame API в `Player.tsx:40-60`, переиспользовать) с `playerVars { autoplay: 1, playsinline: 1 }`; при размонтировании — `destroy()`.
- Компромисс: ремоунт плеера при свайпе даёт короткую перезагрузку; для single-user приемлемо и предсказуемо. Альтернатива «держать все плееры и вызывать `pauseVideo`» дороже и даёт десятки одновременных iframe.
- **Бесконечная подгрузка**: `IntersectionObserver`-sentinel в конце списка (или условие `activeIndex >= items.length - 3`) → `fetchNextPage()` при `hasNextPage`. Курсор — штатный `next_cursor` из `/api/feed`.
- **Клавиатура/доступность**: контейнер `tabIndex={0}`, `ArrowDown/ArrowUp` → `scrollIntoView` соседнего слайда; на слайде видимые заголовок, канал, кнопка «Назад», кнопка mute/play (autoplay-политика), ссылка «Открыть на YouTube»; `aria-label` на слайде; `prefers-reduced-motion: reduce` отключает `scroll-behavior`/снап-анимации.
## Критерии приёмки
1. Сегмент-контрол «Все | Обычные | Shorts» показан на `/`, `/category/:id`, `/uncategorized`, `/search`, `/local`, `/channels/:id/videos`; в режиме `?view=channels` он **не показывается** и фильтр не применяется (эндпоинт активности `type` не получает).
2. `type` хранится в URL (`?type=all|long|short`); при переключении категории (сайдбар, mobile-nav), локальных фильтров, обновлении страницы (F5), back/forward фильтр сохраняется. Значение по умолчанию `all` может не выводиться в URL, поведение идентично.
3. `type=short` возвращает только видео с `duration_seconds <= SHORTS_MAX_DURATION_SECONDS`; `type=long` — `> порога` **и** `NULL`; `type=all` — всё. Граница ровно на пороге (180) входит в shorts; 181 — в long; `NULL` — в long. Недопустимый `type` → `422`.
4. Порог настраивается через `SHORTS_MAX_DURATION_SECONDS` (в `.env`/`.env.example`), изменение конфига сдвигает границу без правок кода.
5. `is_short` присутствует в DTO ленты, `GET /api/videos/{id}` и в `videos` `GET /api/channels/activity`; для NULL/длинных — `false`.
6. Пагинация в отфильтрованном наборе корректна: страницы `type=short` (и `long`) не пересекаются и не теряют элементы, `next_cursor` = `null` в конце. `anchor` заставляет первую страницу начинаться с указанного видео и продолжаться в сторону старых; неизвестный якорь не ломает запрос.
7. Клик по short-карточке (`VideoCard`, `CompactVideoCard`, страница канала, лента) ведёт на `/shorts/:youtubeVideoId`; по обычной — на `/video/:youtubeVideoId` как раньше (с сохранением скролла `state.from`).
8. Лента `/shorts/:id`: открывается на указанном видео; вертикальный свайп/скролл листает по одному; активный слайд воспроизводится (локальный `<video>` / YouTube iframe), неактивные не играют; при достижении конца подгружаются следующие shorts в **том же** контексте (категория/поиск/скачанное/канал); контекст берётся из URL `shorts`-ссылки и переживает refresh.
9. Пустые состояния: нет shorts в контексте — понятный empty-state с возвратом; ошибка — «Повторить»; загрузка — скелетон слайда. Лента доступна с клавиатуры, слайды подписаны, есть кнопка «Назад».
10. `pytest` (включая новые тесты `type`/границ/NULL/`is_short`/пагинации/`anchor` и обновлённый shape-тест), `npm run lint`, `npm run build` — зелёные; деплой `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
## Риски и ограничения
- **Autoplay-политика браузеров**: программный `playVideo()` для YouTube на свайпе без «свежего» жеста может быть заблокирован. Митигация: старт с mute + видимая кнопка включения звука; локальный `<video>` автоиграет после перехода по клику. Это ограничение платформы, а не баг.
- **Производительность**: держим в DOM плеер только активного слайда; постеры неактивных — `loading="lazy"`. Иначе десятки iframe/видео.
- **`anchor` усложняет `/api/feed`**: добавлен только ради открытия конкретного short в ленте; покрыть тестами (старт с якоря, неизвестный якорь). Курсор остаётся штатным.
- **`type` vs builtin**: в Python-сигнатуре используем alias; не путать query-имя `type` с внутренним `video_type`.
- **NULL и БД**: `duration_seconds > x` сам исключает NULL — ветку `long` нельзя писать одним сравнением; тест на NULL обязателен (SQLite в тестах ведёт себя так же, как Postgres для этого случая).
- **Рост `Feed.tsx`**: логика сохранения `view`/`type`/`new`/локальных фильтров уже плотная; вынести построение query-строки в маленький helper, не дублировать строки в каждом `to=`.
- **Full-screen вёрстка**: `.main-content` имеет сайдбар и `.page`-отступы; лента shorts должна аккуратно «вырезать» их (negative margins / собственный контейнер), не сломав мобильную раскладку. Высота — через `100dvh` и высоту topbar (66px desktop / 112px mobile).
- **`test_activity_item_shape`** проверяет точный набор ключей DTO — без правки теста `pytest` упадёт; включить обновление в задачу Coder.
- **Полноэкранность и скролл страницы**: нужно `overscroll-behavior: contain`, чтобы свайп ленты не тянул страницу приложения; для `prefers-reduced-motion` снап-поведение упрощается.
- **Правило AGENTS.md 5 (не делать YouTube Home/Recommendations)**: лента Shorts — это не рекомендации, а тот же фильтрованный набор ленты; порядок только `published_at DESC`, без «похожих».
## Журнал изменений
- 2026-09-27: документ создан перед реализацией. Зафиксированы решения пользователя: `SHORTS_MAX_DURATION_SECONDS=180` (NULL → long); фильтр `?type=all|long|short` во всех списках видео, кроме режима `?view=channels`; short → `/shorts/:youtubeVideoId`, long → `/video/:youtubeVideoId`; вертикальная лента shorts в контексте (тот же `/api/feed` с `type=short`); без миграций. Добавлены проектные решения Analyst: `is_short` централизованно в `serialize_video`; опциональный `anchor` в `/api/feed` для открытия конкретного ролика; механика ленты — CSS scroll-snap + `IntersectionObserver (threshold 0.6)`, монтаж плеера только для активного слайда; контекст ленты в URL `shorts`-ссылки (`category`/`uncategorized`/`downloaded`/`q`/`channel`).