From 252f38597dcc6f4d3c79c32e25a03fef9f5b554b Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Mon, 28 Sep 2026 09:33:35 +0000 Subject: [PATCH] Split shorts from regular videos with a vertical shorts feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/:id: 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. --- .env.example | 4 + README.md | 3 +- analytics/2026-09-27-shorts-split-and-feed.md | 175 +++++++++++++++ .../2026-09-28-shorts-audio-persistence.md | 99 +++++++++ analytics/2026-09-28-type-filter-two-modes.md | 113 ++++++++++ backend/app/api/feed.py | 30 +++ backend/app/config.py | 3 + backend/app/services/video_presentation.py | 3 + frontend/src/App.css | 50 ++++- frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 7 + frontend/src/components/AppShell.tsx | 19 +- frontend/src/components/CompactVideoCard.tsx | 3 +- frontend/src/components/Icon.tsx | 4 +- frontend/src/components/Player.tsx | 51 +---- frontend/src/components/ShortsPlayer.tsx | 205 ++++++++++++++++++ frontend/src/components/VideoCard.tsx | 3 +- frontend/src/pages/ChannelVideos.tsx | 33 ++- frontend/src/pages/Feed.tsx | 94 ++++++-- frontend/src/pages/ShortsFeed.tsx | 194 +++++++++++++++++ frontend/src/utils/videoLinks.ts | 40 ++++ frontend/src/utils/youtube.ts | 52 +++++ tests/test_channel_activity.py | 1 + tests/test_shorts_feed.py | 171 +++++++++++++++ tests/test_videos.py | 26 ++- 25 files changed, 1297 insertions(+), 88 deletions(-) create mode 100644 analytics/2026-09-27-shorts-split-and-feed.md create mode 100644 analytics/2026-09-28-shorts-audio-persistence.md create mode 100644 analytics/2026-09-28-type-filter-two-modes.md create mode 100644 frontend/src/components/ShortsPlayer.tsx create mode 100644 frontend/src/pages/ShortsFeed.tsx create mode 100644 frontend/src/utils/videoLinks.ts create mode 100644 frontend/src/utils/youtube.ts create mode 100644 tests/test_shorts_feed.py diff --git a/.env.example b/.env.example index cb1d355..f2d773e 100644 --- a/.env.example +++ b/.env.example @@ -93,6 +93,10 @@ VIDEOS_KNOWN_STOP_THRESHOLD=50 # и в сайдбаре категорий, а также фильтр «только новые» по клику на число NEW_VIDEOS_WINDOW_DAYS=2 +# Видео короче или равные этому числу секунд считаются Shorts. +# Неизвестная длительность (NULL) считается обычным видео. +SHORTS_MAX_DURATION_SECONDS=180 + # ---------- Логи ---------- LOG_LEVEL=INFO diff --git a/README.md b/README.md index 2830d78..9c11289 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта). - Режим «Каналы» на главной (`?view=channels`): переключатель «Лента | Каналы», список подписанных каналов по свежести последнего видео с тремя последними роликами в строке; фильтр категорий из сайдбара действует в обоих режимах. - Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`. +- Разделение видео на **Shorts** и обычные по длительности (`SHORTS_MAX_DURATION_SECONDS`, по умолчанию 180 сек; неизвестная длительность — обычное видео): сегмент-фильтр «Обычные | Shorts» во всех списках видео (по умолчанию «Обычные», `?type=short` для Shorts; `?type=all`/`?type=long`/неизвестное трактуются как «Обычные») и вертикальная full-screen лента Shorts (`/shorts/:youtubeVideoId`) с автоплеем активного ролика и бесконечной подгрузкой в контексте открытого фильтра. REST `GET /api/feed` по-прежнему поддерживает `type=all|long|short` (совместимость с будущим mobile/PWA-клиентом), UI вариант `all` не использует. - Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню. - Отписанные каналы скрываются из списка каналов. - Реальная отписка от канала на YouTube (`subscriptions.delete`) реализована по запросу пользователя сверх исходного MVP. Из-за этого OAuth scope расширен с `youtube.readonly` до полного `youtube` (read/write) — см. `backend/app/services/google_oauth.py`. @@ -17,7 +18,7 @@ ### Что осталось / сознательно отложено -- Дополнительные идеи после MVP пока не реализованы: PWA, watch later, Shorts-фильтр, SponsorBlock и т.д. +- Дополнительные идеи после MVP пока не реализованы: PWA, watch later, SponsorBlock и т.д. - Hardening (retry/recovery, тесты) выполнялся по факту находок в live-тестировании, а не отдельным проходом — см. `git log` для конкретных багфиксов (flapping статусов загрузки, неверный id в MeTube `/delete`, OAuth scope mismatch). ## Стек diff --git a/analytics/2026-09-27-shorts-split-and-feed.md b/analytics/2026-09-27-shorts-split-and-feed.md new file mode 100644 index 0000000..55db5b2 --- /dev/null +++ b/analytics/2026-09-27-shorts-split-and-feed.md @@ -0,0 +1,175 @@ +# Разделение видео на 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=` | +| `/uncategorized` | `uncategorized=true` | +| `/local` (+ его `category`/`uncategorized`) | `downloaded=true[&category=..][&uncategorized=true]` | +| `/search?q=..` | `q=<поисковый запрос>` | +| `/channels/:id/videos` | `channel=` | + +`ShortsFeed` разбирает эти параметры и вызывает `getFeed({ type: 'short', categoryId, uncategorized, downloaded, search, channelId, anchor, cursor, limit })`. Контекст хранится в URL → refresh/шаринг сохраняют фильтр. + +### Frontend — лента Shorts +11. `App.tsx`: `} />`. +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 - ); 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` для каждого типа плеера: + - локальная копия — `