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

26 KiB
Raw Permalink Blame History

Разделение видео на 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=.

Фильтр по типу:

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 — фильтр и ссылки

  1. client.ts: is_short: boolean в FeedVideoDto; VideoType = 'all' | 'long' | 'short'; в getFeed — type (шлём type=all явно или опускаем, единообразно) и anchor.
  2. 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»).
  3. ChannelVideos.tsx: type из URL, сегмент-контрол, getFeed({ channelId, type, ... }), queryKey с type.
  4. AppShell.tsx (Sidebar): обобщить viewQuery в набор сохраняемых query-параметров view/type; переносить их в ссылки / и /category/:id. ?new=1 (badge) — feed-специфичен, оставить как сейчас.
  5. 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

  1. App.tsx: <Route path="/shorts/:youtubeVideoId" element={<ShortsFeed />} />.
  2. ShortsFeed.tsx: useInfiniteQuery по тем же context-параметрам с type='short' и anchor=youtubeVideoId; вертикальный скролл-контейнер; активный слайд; бесконечная подгрузка; empty/error/loading; кнопка «Назад» (как в VideoPage: state.from → navigate(-1) с fallback).
  3. Механика активного слайда (см. ниже).
  4. App.css: контейнер, слайды, оверлей, адаптив, reduced-motion.

Проверка

  1. pytest (новые + регресс), npm run lint, npm run build в frontend/.
  2. Ручная проверка в браузере: фильтр на всех 6 местах, сохранение в URL при переключении категории/refresh; short → лента, обычное → страница; автоплей/пауза; подгрузка; режим «Каналы» не затронут.
  3. Деплой по правилу команды: 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).