- 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.
26 KiB
Разделение видео на shorts/обычные и вертикальная лента Shorts
Задача
Разделить видео на shorts и обычные по длительности и дать пользователю:
- Сегмент-фильтр «Все | Обычные | Shorts» во всех списках видео, с состоянием в URL (
?type=all|long|short). - Разные точки перехода по клику: short → вертикальная лента
/shorts/:youtubeVideoId, обычное → существующая страница/video/:youtubeVideoId. - Вертикальную 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, defaultall) и опциональныйanchor; фильтр поVideo.duration_seconds.backend/app/services/video_presentation.py—is_shortв DTOserialize_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
config.py:shorts_max_duration_seconds: int = 180. В.env.example— секция/строка с комментарием: «Видео короче или равны этому числу (сек) считаются Shorts; неизвестная длительность — обычное видео». В.env— то же значение по образцу.video_presentation.py:is_shortвserialize_video(импортsettings).feed.py: параметрыvideo_type/anchor; фильтр длительности; разбор якоря (Video.youtube_video_id == anchor), условие старта страницы; порядок — фильтры → якорь → курсор →order_by→ limit+1.- Тесты: фикстура
clientкак вtests/test_feed.py; сиды с явнымиduration_seconds(NULL, 179, 180, 181) и выполнением условий. - Обновить
tests/test_channel_activity.py::test_activity_item_shape(добавитьis_short); вtests/test_videos.pyдобавить проверкуis_short(pytest).
Frontend — фильтр и ссылки
client.ts:is_short: booleanвFeedVideoDto;VideoType = 'all' | 'long' | 'short'; вgetFeed—type(шлёмtype=allявно или опускаем, единообразно) иanchor.Feed.tsx:type=searchParams.get('type')с валидацией (defaultall),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»).
ChannelVideos.tsx:typeиз URL, сегмент-контрол,getFeed({ channelId, type, ... }), queryKey сtype.AppShell.tsx(Sidebar): обобщитьviewQueryв набор сохраняемых query-параметровview/type; переносить их в ссылки/и/category/:id.?new=1(badge) — feed-специфичен, оставить как сейчас.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
App.tsx:<Route path="/shorts/:youtubeVideoId" element={<ShortsFeed />} />.ShortsFeed.tsx:useInfiniteQueryпо тем же context-параметрам сtype='short'иanchor=youtubeVideoId; вертикальный скролл-контейнер; активный слайд; бесконечная подгрузка; empty/error/loading; кнопка «Назад» (как в VideoPage:state.from→navigate(-1)с fallback).- Механика активного слайда (см. ниже).
App.css: контейнер, слайды, оверлей, адаптив, reduced-motion.
Проверка
pytest(новые + регресс),npm run lint,npm run buildвfrontend/.- Ручная проверка в браузере: фильтр на всех 6 местах, сохранение в URL при переключении категории/refresh; short → лента, обычное → страница; автоплей/пауза; подгрузка; режим «Каналы» не затронут.
- Деплой по правилу команды:
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/снап-анимации.
Критерии приёмки
- Сегмент-контрол «Все | Обычные | Shorts» показан на
/,/category/:id,/uncategorized,/search,/local,/channels/:id/videos; в режиме?view=channelsон не показывается и фильтр не применяется (эндпоинт активностиtypeне получает). typeхранится в URL (?type=all|long|short); при переключении категории (сайдбар, mobile-nav), локальных фильтров, обновлении страницы (F5), back/forward фильтр сохраняется. Значение по умолчаниюallможет не выводиться в URL, поведение идентично.type=shortвозвращает только видео сduration_seconds <= SHORTS_MAX_DURATION_SECONDS;type=long—> порогаиNULL;type=all— всё. Граница ровно на пороге (180) входит в shorts; 181 — в long;NULL— в long. Недопустимыйtype→422.- Порог настраивается через
SHORTS_MAX_DURATION_SECONDS(в.env/.env.example), изменение конфига сдвигает границу без правок кода. is_shortприсутствует в DTO ленты,GET /api/videos/{id}и вvideosGET /api/channels/activity; для NULL/длинных —false.- Пагинация в отфильтрованном наборе корректна: страницы
type=short(иlong) не пересекаются и не теряют элементы,next_cursor=nullв конце.anchorзаставляет первую страницу начинаться с указанного видео и продолжаться в сторону старых; неизвестный якорь не ломает запрос. - Клик по short-карточке (
VideoCard,CompactVideoCard, страница канала, лента) ведёт на/shorts/:youtubeVideoId; по обычной — на/video/:youtubeVideoIdкак раньше (с сохранением скроллаstate.from). - Лента
/shorts/:id: открывается на указанном видео; вертикальный свайп/скролл листает по одному; активный слайд воспроизводится (локальный<video>/ YouTube iframe), неактивные не играют; при достижении конца подгружаются следующие shorts в том же контексте (категория/поиск/скачанное/канал); контекст берётся из URLshorts-ссылки и переживает refresh. - Пустые состояния: нет shorts в контексте — понятный empty-state с возвратом; ошибка — «Повторить»; загрузка — скелетон слайда. Лента доступна с клавиатуры, слайды подписаны, есть кнопка «Назад».
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 в ленте; покрыть тестами (старт с якоря, неизвестный якорь). Курсор остаётся штатным.typevs 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), монтаж плеера только для активного слайда; контекст ленты в URLshorts-ссылки (category/uncategorized/downloaded/q/channel).