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.
This commit is contained in:
vrubelroman 2026-09-28 09:33:35 +00:00
parent 43adec5224
commit 252f38597d
25 changed files with 1297 additions and 88 deletions

View file

@ -93,6 +93,10 @@ VIDEOS_KNOWN_STOP_THRESHOLD=50
# и в сайдбаре категорий, а также фильтр «только новые» по клику на число # и в сайдбаре категорий, а также фильтр «только новые» по клику на число
NEW_VIDEOS_WINDOW_DAYS=2 NEW_VIDEOS_WINDOW_DAYS=2
# Видео короче или равные этому числу секунд считаются Shorts.
# Неизвестная длительность (NULL) считается обычным видео.
SHORTS_MAX_DURATION_SECONDS=180
# ---------- Логи ---------- # ---------- Логи ----------
LOG_LEVEL=INFO LOG_LEVEL=INFO

View file

@ -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 после рестарта). - Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта).
- Режим «Каналы» на главной (`?view=channels`): переключатель «Лента | Каналы», список подписанных каналов по свежести последнего видео с тремя последними роликами в строке; фильтр категорий из сайдбара действует в обоих режимах. - Режим «Каналы» на главной (`?view=channels`): переключатель «Лента | Каналы», список подписанных каналов по свежести последнего видео с тремя последними роликами в строке; фильтр категорий из сайдбара действует в обоих режимах.
- Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`. - Раздел **«На сервере»** — скачанные видео; `/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, поиск по названиям видео и каналов, мобильное меню. - Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню.
- Отписанные каналы скрываются из списка каналов. - Отписанные каналы скрываются из списка каналов.
- Реальная отписка от канала на YouTube (`subscriptions.delete`) реализована по запросу пользователя сверх исходного MVP. Из-за этого OAuth scope расширен с `youtube.readonly` до полного `youtube` (read/write) — см. `backend/app/services/google_oauth.py`. - Реальная отписка от канала на 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). - Hardening (retry/recovery, тесты) выполнялся по факту находок в live-тестировании, а не отдельным проходом — см. `git log` для конкретных багфиксов (flapping статусов загрузки, неверный id в MeTube `/delete`, OAuth scope mismatch).
## Стек ## Стек

View file

@ -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=<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`).

View file

@ -0,0 +1,99 @@
# Багфикс: звук в ленте Shorts сбрасывается при листании
## Задача
Устранить регрессию ленты Shorts `/shorts/:youtubeVideoId`: при листании к следующему ролику звук снова выключен, даже если пользователь включил его на текущем слайде. Требуется, чтобы выбор звука («вкл/выкл») сохранялся на все последующие слайды в рамках открытой ленты, не ломая autoplay.
Это отдельный багфикс поверх механики ленты из `analytics/2026-09-27-shorts-split-and-feed.md` (документ исторический, его решения не переписываются).
## Контекст
Баг воспроизводится и подтверждён по фактическому коду (ветка `master`, правки shorts в незакоммиченном worktree):
- `frontend/src/components/ShortsPlayer.tsx:16` держит звук локально: `const [muted, setMuted] = useState(true)`. Это состояние живёт ровно столько, сколько смонтирован конкретный плеер.
- `frontend/src/pages/ShortsFeed.tsx:162-163` монтирует `<ShortsPlayer video={video} />` **только для активного слайда**, а неактивные показывает постером. При смене активного слайда старый плеер размонтируется, новый создаётся заново — и снова с `muted = true`.
- Для YouTube это усилено на двух уровнях: `playerVars: { ..., mute: 1 }` при создании (`ShortsPlayer.tsx:57`) и `player.mute?.()` в `onReady` (`ShortsPlayer.tsx:66`).
- Итог: состояние звука не переносится между роликами, потому что единственный его владелец — плеер одного слайда.
Это следствие осознанного решения «плеер монтируется только для активного слайда» из `2026-09-27-shorts-split-and-feed.md` (гарантированная остановка звука/воспроизведения при свайпе без ручного `pause`). Решение остаётся в силе; меняется только владелец флага звука.
Потребителей у `ShortsPlayer` ровно один — `ShortsFeed.tsx` (подтверждено grep по `frontend/src`), поэтому изменение пропсов безопасно и локально.
## Затронутые подсистемы и файлы
Frontend (единственная подсистема):
- `frontend/src/pages/ShortsFeed.tsx` — новый источник правды для звука: `const [muted, setMuted] = useState(true)` на уровне ленты; передать `muted` и `onToggleMute` в `ShortsPlayer`.
- `frontend/src/components/ShortsPlayer.tsx` — убрать локальный `useState(muted)`; принимать `muted: boolean` и `onToggleMute: () => void`; применять состояние к обоим типам плеера; кнопка звука управляет общим состоянием.
- `frontend/src/utils/youtube.ts` — изменений не требуется (`YoutubePlayerApi` уже содержит опциональные `mute?`/`unMute?`).
- `frontend/src/App.css` — изменений не требуется (класс `.shorts-mute` и иконка не меняются).
Backend, миграции, env — не затрагиваются. Тестов на frontend в проекте нет; проверка — `npm run lint` и `npm run build`.
## Проектное решение (без костылей)
### Владелец состояния
Единый источник правды — `ShortsFeed` (живёт, пока открыта лента, включая подгрузку следующих страниц). `ShortsPlayer` становится полностью контролируемым презентационным компонентом: пропсы `muted` + `onToggleMute`, без собственного `useState`.
Почему не `sessionStorage`/`localStorage` и не глобальный store: это единичное UI-состояние одной страницы, лишняя инфраструктура не нужна; кроме того, персист между перезагрузками запрещён (см. ниже).
### Локальное видео
`<video ... muted={muted} />` — React выставляет DOM-свойство `muted` при каждом рендере, включение/выключение не требует императивных вызовов.
### YouTube (IFrame API)
- При создании: `playerVars: { playsinline: 1, autoplay: 1, mute: muted ? 1 : 0 }`, где `muted` берётся из пропса (значение на момент монтажа слайда).
- Значение `muted` **нельзя** добавлять в зависимости эффекта создания плеера (`[useLocal, video.youtube_video_id]`): иначе каждый клик по кнопке будет пересоздавать `YT.Player` (перезагрузка ролика). Поэтому:
- актуальное значение для `onReady` держим в рефе: `const mutedRef = useRef(muted); mutedRef.current = muted`; в `onReady` вызываем `mutedRef.current ? player.mute?.() : player.unMute?.()` (вместо безусловного `player.mute?.()`);
- **отдельный эффект** `useEffect(() => { const p = ytRef.current; if (!p) return; if (muted) p.mute?.(); else p.unMute?.(); }, [muted])` — применяет изменение звука к уже созданному плееру. На маунте плеера может быть ещё null — безопасно; фактическое начальное состояние обеспечивают `playerVars.mute` и `onReady`.
- После включения звука пользователем браузер уже получил жест, поэтому программный `unMute()` на следующих слайдах autoplay не ломает.
### Кнопка звука и фолбэк на plain-iframe
- Кнопка остаётся в `ShortsPlayer`, но вызывает `onToggleMute` (родительский сеттер); `aria-label`/иконка — от эффективного состояния.
- **Решение по фолбэку:** если IFrame API не поднялся (`ytFailed`, `ShortsPlayer.tsx:117-124`), plain-iframe остаётся с `mute=1`, а кнопка — `disabled`, как сейчас. Причина: у plain-iframe нет JS-управления без перезагрузки `src`, а `mute=0` ломает autoplay деградационного пути. Чтобы UI не «врал», в режиме фолбэка иконка/`aria-label` показывают «выключено»: `const effectivelyMuted = (!useLocal && ytFailed) ? true : muted`. Сессионное `muted` при этом не меняется и продолжает действовать на последующих слайдах с рабочим API.
### Дефолт и персист
- При входе в ленту звук **выключен** (`useState(true)`) — иначе браузеры блокируют программный автоплей без «свежего» жеста пользователя.
- После явного включения состояние держится до конца жизни компонента `ShortsFeed` (включая уже подгруженные и ещё подгружаемые слайды). Уход со страницы/перезагрузка → снова выключено.
- **Персист между перезагрузками сознательно НЕ делаем** (ни `localStorage`, ни `sessionStorage`, ни cookie). Причина: восстановленное «включено» применилось бы к автоплею без жеста пользователя, и браузер заблокировал бы воспроизведение на первом слайде — это худший сценарий, чем повторное включение звука одним тапом.
## Критерии приёмки
1. Включить звук на текущем слайде и пролистать вперёд/назад — звук остаётся включённым на следующем/предыдущем ролике; сброса в «выключено» нет.
2. Работает для локального файла (`<video muted={muted}>` обновляется при листании) и для YouTube (новый плеер стартует с `mute: 0`, при необходимости звук применяется через `mute()/unMute()`).
3. Autoplay не сломан: при входе в ленту звук выключен и активный ролик автоматически воспроизводится без жеста; переключение звука не приводит к перезагрузке/пересозданию плеера.
4. Переключение звука на текущем слайде по-прежнему работает кнопкой; иконка и `aria-label` соответствуют фактическому состоянию (в фолбэке — «выключено»).
5. Фолбэк на plain-iframe (`ytFailed`) не регрессирует: iframe автоплеит с `mute=1`, кнопка `disabled`, ошибок/белого экрана нет; состояние звука после возврата на слайд с рабочим API сохраняется.
6. Гонка «переключил звук до готовности плеера» безопасна: `onReady` применяет актуальное значение через реф, а не захваченное на маунте.
7. Состояние живёт в пределах открытой ленты и сбрасывается в «выключено» при уходе/перезагрузке; между перезагрузками ничего не персистится.
8. `npm run lint` и `npm run build` в `frontend/` — зелёные.
9. Деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
## План
1. `ShortsFeed.tsx`: добавить `const [muted, setMuted] = useState(true)`; прокинуть `<ShortsPlayer video={video} muted={muted} onToggleMute={() => setMuted((m) => !m)} />`. Учесть, что при смене `routeKey`/контекста (сброс на первый слайд, `:63-66`) звук по решению сессии **не** сбрасывается — флаг живёт до размонтирования `ShortsFeed`.
2. `ShortsPlayer.tsx`:
- обновить `Props`: `video`, `muted: boolean`, `onToggleMute: () => void`;
- удалить `useState(muted)` и локальный `toggleMute`; кнопку перевести на `onToggleMute`, иконку/`aria-label` — от `effectivelyMuted`;
- YouTube: `playerVars.mute` от пропса, `onReady` через `mutedRef`, отдельный эффект на `[muted]` для `mute()/unMute()`;
- локальное видео: `muted={muted}`; фолбэк-iframe: `mute=1`, кнопка `disabled`.
3. Ручная проверка в браузере: локальный ролик и YouTube по очереди — включить звук, пролистать 2-3 слайда (в т.ч. через клавиатуру), проверить отсутствие сброса; открыть ленту заново — звук выключен; сыграть сценарий с недоступным IFrame API (фолбэк).
4. `npm run lint` и `npm run build` в `frontend/`.
5. Деплой: `docker compose up -d --build`, health-check.
## Риски и ограничения
- **Пересоздание плеера:** главный риск фикса — добавить `muted` в зависимости эффекта создания `YT.Player`. Это недопустимо (каждый тоggl → перезагрузка ролика со сбросом позиции). Митигация: реф + отдельный эффект; критерий приёмки №3.
- **Autoplay-политика браузеров:** причина дефолта `muted=true` и отказа от персиста. Программный `unMute()` на последующих слайдах допустим (жест уже был), но `mute=0` при первом автоплее без жеста — нет.
- **Рассинхрон UI и фолбэка:** plain-iframe всегда беззвучен, поэтому в этом режиме кнопка должна показывать «выключено»; иначе пользователь видит «звук включён», а слышит тишину.
- **Гонка до `onReady`:** без рефа `onReady` применит устаревшее значение и сбросит только что включённый звук; критерий приёмки №6.
- **Сброс при навигации внутри ленты:** смена `routeKey` (другой контекст/якорь) пересобирает список, но `ShortsFeed` не размонтируется — звук сохраняется. Это ожидаемо; сброс происходит только при уходе со страницы.
- **Тестовое покрытие:** frontend-тестов нет, регрессия ловится только lint/build и ручной проверкой; автоматизировать e2e в этой задаче не планируется.
## Журнал изменений
- 2026-09-28: документ создан перед реализацией. Зафиксирован баг (локальный `muted` в `ShortsPlayer.tsx:16` + ремоунт плеера на активный слайд в `ShortsFeed.tsx:162-163` + `mute: 1`/`player.mute()` для YouTube) и фикс: поднять состояние звука в `ShortsFeed`, передавать `muted`/`onToggleMute`; для YouTube — `playerVars.mute` при создании и отдельный эффект `mute()/unMute()` (реф для `onReady`, чтобы не пересоздавать плеер); фолбэк plain-iframe остаётся `mute=1` с `disabled`-кнопкой; дефолт «выключено»; персист между перезагрузками сознательно не делается из-за autoplay-политики. Потребитель `ShortsPlayer` единственный — `ShortsFeed`.

View file

@ -0,0 +1,113 @@
# Фильтр типа видео: два режима «Обычные | Shorts» (без «Все»)
## Задача
Упростить сегмент-фильтр типа видео: убрать вариант **«Все»**, оставить два взаимоисключающих режима — **«Обычные»** (`long`) и **«Shorts»** (`short`). Всегда выбран ровно один режим.
Решения пользователя и Analyst, которые нужно зафиксировать:
1. **По умолчанию** (в URL нет `?type`) — **«Обычные»** (`long`).
2. **`?type=all`, `?type=long` и любое неизвестное значение** в URL трактуются как **«Обычные»**. UI больше не предлагает «Все».
3. **URL-конвенция:** `?type=short` — для Shorts; для «Обычных» параметр **не нужен** (URL без `?type`). Выбор обоснован ниже.
4. При переключении категории/вида сохраняется **только `type=short`** (для «Обычных» сохранять нечего — это отсутствие параметра).
5. **Backend `type=all` в API остаётся** без изменений — совместимость REST для будущего mobile/PWA-клиента (AGENTS.md, правило 13). UI этот вариант просто не использует.
Это уточнение к решению из `analytics/2026-09-27-shorts-split-and-feed.md` (там UI-фильтр был «Все | Обычные | Shorts»); сам документ — исторический, не переписывается.
## Контекст
Проверено по фактическому worktree (ветка `master`, правка shorts уже в незакоммиченных изменениях).
- Сегмент-контрол и парсинг `?type` задублированы в двух местах:
- `frontend/src/pages/Feed.tsx:10-14` — `TYPE_OPTIONS` с тремя значениями (`all/long/short`);
`:30-31` — `videoType = typeParam === 'short' || typeParam === 'long' ? typeParam : 'all'`;
`:36` — сохранение `type` при переключении категории/вида (`if (videoType !== 'all')`);
`:39-45` — `typeLink` (`all` → удалить `type`, иначе установить);
`:46-53` — `feedViewLink`/`channelsViewSearch` (пробрасывают `type`, если не `all`);
`:118,150` — empty-states с веткой `all`.
- `frontend/src/pages/ChannelVideos.tsx:9-13,21-29,31,34-35,46` — то же для страницы канала.
- `frontend/src/components/AppShell.tsx:67-78` (`Sidebar`) сохраняет `?type` в ссылках «Все видео»/категорий/бейджей: `activeType = currentType === 'short' || currentType === 'long' ? currentType : null`. Комментарий `:67-69` ещё упоминает «Все | Обычные | Shorts».
- `frontend/src/utils/videoLinks.ts:13-39` — контекст ссылок `/shorts/:youtubeVideoId` (`category`/`uncategorized`/`downloaded`/`q`/`channel`). Параметр `type` там **не нужен** (лента Shorts всегда `type: 'short'`, `ShortsFeed.tsx:38`). Изменений в файле нет.
- `frontend/src/api/client.ts:173` — `VideoType = 'all' | 'long' | 'short'`; `:239,250` — `getFeed` шлёт `type` только если он truthy.
- `backend/app/api/feed.py:86` — `video_type: Literal["all","long","short"] = Query("all", alias="type")`; `:124-135` — фильтр по длительности (`short` / `long`, `all` = без фильтра). **Файл не трогаем.**
- Backend-тесты `tests/test_shorts_feed.py` (в т.ч. `test_feed_type_all_matches_default`, `?type=all`) проверяют REST и остаются как есть. Frontend-тестов нет, проверка — `npm run lint` + `npm run build`.
### Ключевой нюанс: URL-конвенция ≠ вызов API
Дефолт backend `type=all` означает **«без фильтра»**, а не «Обычные». Поэтому frontend **обязан всегда слать `type=long` или `type=short` явно** — опускать параметр нельзя, иначе «Обычные» вернут все видео. URL страницы при этом может не содержать `?type` (это лишь UI-конвенция для «Обычных»).
### Обоснование URL-конвенции (`Обычные` = нет `?type`)
- Совпадает с прежним «дефолт не пишем в URL» (`all` раньше не выводился) — минимальная правка `typeLink`/`preservedParams`.
- Чистые URL для самого частого режима; «Обычные» и так дефолт.
- `?type=long` и `?type=all` из старых закладок продолжают работать (нормализуются в «Обычные»), хотя теперь означают не «все видео», а «Обычные» — это осознанное изменение поведения.
- Альтернатива (`?type=long` явно) отвергнута: лишний параметр на каждый переход и дублирование дефолта.
## Затронутые подсистемы и файлы
Frontend (единственная меняемая подсистема):
- `frontend/src/pages/Feed.tsx` — `TYPE_OPTIONS` из двух пунктов; нормализация `videoType: 'long' | 'short'`; `typeLink`/`preservedParams`/`feedViewLink`/`channelsViewSearch` под `type=short`-only; empty-states без ветки `all`; замена CTA «Показать все видео».
- `frontend/src/pages/ChannelVideos.tsx` — то же (сегмент-контрол, нормализация, empty-states, CTA).
- `frontend/src/components/AppShell.tsx` — `Sidebar`: сохранять только `type=short`; актуализировать комментарий `:67-69`.
- `frontend/src/api/client.ts` — **без обязательных изменений**: `VideoType` можно оставить `'all' | 'long' | 'short'` как зеркало REST; UI просто не использует `all`. `getFeed` уже шлёт `type`, если он задан. Если решено сузить тип UI — допустимо.
Не меняются:
- `frontend/src/utils/videoLinks.ts` — контекст shorts-ссылок не зависит от `type`.
- `frontend/src/pages/ShortsFeed.tsx` — всегда `type: 'short'`.
- `backend/**` (в т.ч. `backend/app/api/feed.py`, `config.py`, `video_presentation.py`), `migrations/**`, `tests/**` — без изменений.
- `.env.example` / `.env` — без изменений.
Документация:
- `README.md:13` — обновить описание фильтра (см. «План», п. 6): «Обычные | Shorts», дефолт «Обычные», `?type=short`; отметить, что REST `type=all` сохранён.
## Критерии приёмки
1. Во всех местах фильтра (`Feed.tsx` — `/`, `/category/:id`, `/uncategorized`, `/search`, `/local`; `ChannelVideos.tsx` — `/channels/:id/videos`) сегмент-контрол содержит **ровно два** пункта: «Обычные» и «Shorts». Варианта «Все» нигде нет.
2. Всегда выбран ровно один режим. Без `?type` в URL активен **«Обычные»**; запрос к API идёт с `type=long`.
3. `?type=short` → активен «Shorts», запрос идёт с `type=short`.
4. `?type=all`, `?type=long` и любое неизвестное значение → активен **«Обычные»** (и запрос с `type=long`); ошибок/пустых страниц нет. `?type=all` больше не означает «все видео».
5. Переключение в URL: Shorts → `?type=short`; «Обычные» → параметр `type` удаляется (URL без `?type`).
6. При переключении категории (сайдбар, mobile-category-nav, local-filter-nav), вида («Лента | Каналы»), бейджа `?new=1` и при F5/back/forward сохраняется `type=short`; для «Обычных» параметр отсутствует. Никакие переходы не превращают Shorts в «Обычные» и наоборот.
7. Ссылки на shorts (`videoLinks.ts` → `/shorts/:youtubeVideoId`) работают как раньше; лента Shorts открывается и подгружается (она всегда `type: 'short'`).
8. Режим «Каналы» (`?view=channels`) — без фильтра: сегмент-контрол скрыт, эндпоинт активности `type` не получает. Допустимо переносить `type=short` в URL вида для восстановления состояния при возврате в «Ленту» (фильтр всё равно не применяется), но контрол в этом режиме не показывается.
9. Empty-states корректны для двух режимов (например, «Здесь пока нет обычных видео» / «Здесь пока нет Shorts»); неактуальная ветка `all` удалена. Действие «Показать все видео» больше не ведёт на `type=all` (см. п. 4 «Плана»).
10. Backend не тронут: `GET /api/feed` по-прежнему принимает `type=all|long|short` с дефолтом `all`; тесты `tests/**` не меняются и проходят.
11. `npm run lint` и `npm run build` в `frontend/` чистые.
12. Деплой: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
## План
1. **`Feed.tsx`:**
- `TYPE_OPTIONS`: удалить `{ value: 'all', label: 'Все' }`; оставить `long` («Обычные», первый) и `short` («Shorts»).
- Нормализация: `const videoType: 'long' | 'short' = searchParams.get('type') === 'short' ? 'short' : 'long'` (всё прочее — `long`).
- `typeLink(value)`: `value === 'short'` → `params.set('type', 'short')`; иначе `params.delete('type')` (заодно убирает legacy `all`/`long`).
- `preservedParams`: `if (videoType === 'short') preservedParams.set('type', 'short')` — сохранение при переходах по категориям/mobile-nav.
- `feedViewLink`: `if (videoType === 'short') params.set('type', 'short')`.
- `channelsViewSearch`: `view=channels`; `type=short` добавлять только для сохранения состояния (или не добавлять вовсе — зафиксировать в коде комментарием; критерий 8 не нарушается в обоих случаях).
- `queryKey` (`:68`) и `getFeed({ type: videoType })` (`:75`) — без изменений по форме, но `videoType` теперь всегда `long`/`short` (явный параметр обязателен, дефолт backend `all` не должен протекать).
- `typeEmptyTitle`/`typeEmptyText` и блок empty-state `:150` — убрать ветку `all`; CTA «Показать все видео» либо убрать, либо заменить ссылкой на **другой** режим (см. «Риски», п. про CTA).
2. **`ChannelVideos.tsx`:** зеркально: `TYPE_OPTIONS` из двух пунктов, нормализация `short`/`long`, `typeLink` (long → delete, short → set), `queryKey`/`getFeed` всегда с явным `type`, empty-states двухрежимные, CTA без `all`.
3. **`AppShell.tsx` (`Sidebar`):** `activeType` = `currentParams.get('type') === 'short' ? 'short' : null`; `typeQuery`/`typeSuffix` строить только для `short`. Обновить комментарий `:67-69` («Все | Обычные | Shorts» → «Обычные | Shorts»).
4. **Empty-state CTA (решение Analyst):** так как «Все» больше нет, кнопка «Показать все видео» теряет смысл. В пустом «Обычные» предлагать переход в «Смотреть Shorts» (`typeLink('short')`), в пустом «Shorts» — «Смотреть обычные» (`typeLink('long')`); там, где CTA был скрыт (search/local/sync), поведение сохранить. Допустимо просто убрать CTA — тогда обязательно наличие сегмент-контрола в шапке.
5. **`client.ts`:** изменений не требуется — оставить `VideoType = 'all' | 'long' | 'short'` как зеркало REST; UI передаёт только `long`/`short`. (Сужать тип не обязательно; если сужать — убедиться, что нигде не осталось сравнений с `'all'`.)
6. **`README.md:13`:** обновить строку: сегмент-фильтр «Обычные | Shorts» (`?type=short` для Shorts; «Обычные» — по умолчанию, без параметра), сохранить описание вертикальной ленты Shorts; добавить, что REST `GET /api/feed` по-прежнему поддерживает `type=all` для будущего mobile-клиента, но UI его не использует.
7. **Проверки:** `npm run lint`, `npm run build` в `frontend/`; ручной прогон — default/`?type=all`/`?type=long`/`?type=short`/мусорный `type` на всех точках входа, сохранение при переключении категории/вида/бейджа/F5, режим «Каналы» без контрола, клик по short-карточке → лента. Деплой и health.
8. Backend/миграции/тесты — не трогать.
## Риски и ограничения
- **Привычка к «Все»:** пользователь мог пользоваться режимом «Все» (он был дефолтом). Теперь дефолт — «Обычные», то есть после обновления без явного выбора Shorts **скрыты**. Это осознанное решение пользователя; митигация — заметный сегмент-контрол и (опционально) empty-state CTA «Смотреть Shorts». Отметить при ручной проверке.
- **Слом старых закладок/ссылок `?type=all`:** теперь они открывают «Обычные», а не «все видео». Поведение задокументировано (критерий 4), но это видимое изменение; при необходимости можно один раз редиректить/канонизировать URL — не обязательно.
- **Протечка backend-дефолта `all`:** если frontend случайно **опустит** `type` для «Обычных», API вернёт все видео (short + long) — баг. Обязательное требование: `getFeed` всегда вызывается с `type: 'long' | 'short'`. Проверить оба места (`Feed.tsx`, `ChannelVideos.tsx`) и `ShortsFeed.tsx` (там уже `'short'`).
- **Сохранение состояния:** легко забыть один из переходов (`typeLink`, `preservedParams`, `feedViewLink`, `channelsViewSearch`, `Sidebar`). Требуется ручная проверка каждого (критерий 6).
- **Режим «Каналы»:** фильтр не применяется к `/api/channels/activity`; не добавить `type` в запрос активности и не показать контрол. URL может нести `type=short` как «память» — это не фильтрация.
- **Мёртвые ветки `'all'`:** после правки не должно остаться сравнений `videoType !== 'all'`/`typeLink('all')`; иначе пустые состояния или CTA будут вести на удалённый режим. `noUnusedLocals`/type-check (`tsc -b`) помогут отловить часть.
- **REST-совместимость (AGENTS.md 13):** backend `type=all` и `Literal` не менять; mobile-клиент в будущем сможет запрашивать все видео. Никаких миграций.
- **Комментарии/документация:** обновить комментарии в `Feed.tsx`/`AppShell.tsx`, где ещё упоминается «Все»; `analytics/2026-09-27-shorts-split-and-feed.md` — исторический документ, не править.
## Журнал изменений
- 2026-09-28: документ создан перед реализацией. Зафиксированы решения пользователя: убрать «Все» из UI-фильтра, оставить «Обычные»/«Shorts»; дефолт — «Обычные»; `?type=all`/`long`/неизвестное → «Обычные»; URL «Обычных» — без `?type`, Shorts — `?type=short`; сохранять только `type=short`; backend `type=all` оставить (REST, правило 13). Решения Analyst: URL-конвенция «Обычные = нет параметра» (обоснована выше); frontend обязан всегда слать явный `type=long|short` (дефолт backend `all` нельзя опускать); `videoLinks.ts`/`ShortsFeed.tsx` не меняются; CTA «Показать все видео» перепрофилируется на переход в другой режим; README обновляется по строке `:13`.

View file

@ -1,5 +1,6 @@
import base64 import base64
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@ -82,6 +83,8 @@ def get_feed(
downloaded: bool = False, downloaded: bool = False,
search: str | None = Query(None, max_length=200), search: str | None = Query(None, max_length=200),
new_only: bool = False, new_only: bool = False,
video_type: Literal["all", "long", "short"] = Query("all", alias="type"),
anchor: str | None = None,
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
cursor: str | None = None, cursor: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -118,6 +121,33 @@ def get_feed(
or_(Video.title.ilike(needle, escape="\\"), Channel.title.ilike(needle, escape="\\")) or_(Video.title.ilike(needle, escape="\\"), Channel.title.ilike(needle, escape="\\"))
) )
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,
)
)
# The anchor is a one-off starting point for the Shorts feed: only the
# first page (no cursor) is shifted to start at that video and go older.
# Unknown anchors degrade silently to a regular first page.
if anchor and not cursor:
anchor_video = db.query(Video).filter(Video.youtube_video_id == anchor).first()
if anchor_video is not None:
query = query.filter(
(Video.published_at < anchor_video.published_at)
| (
(Video.published_at == anchor_video.published_at)
& (Video.id <= anchor_video.id)
)
)
if cursor: if cursor:
cursor_published_at, cursor_id = _decode_cursor(cursor) cursor_published_at, cursor_id = _decode_cursor(cursor)
query = query.filter( query = query.filter(

View file

@ -45,6 +45,9 @@ class Settings(BaseSettings):
# we have already synced). # we have already synced).
videos_known_stop_threshold: int = 50 videos_known_stop_threshold: int = 50
new_videos_window_days: int = 2 new_videos_window_days: int = 2
# Videos with a known duration at or below this threshold (seconds) are
# treated as Shorts; unknown duration (NULL) counts as a regular video.
shorts_max_duration_seconds: int = 180
log_level: str = "INFO" log_level: str = "INFO"

View file

@ -1,6 +1,7 @@
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings
from app.models.category import Category from app.models.category import Category
from app.models.channel import Channel from app.models.channel import Channel
from app.models.channel_category import channel_categories from app.models.channel_category import channel_categories
@ -51,6 +52,8 @@ def serialize_video(
"thumbnail_url": video.thumbnail_url, "thumbnail_url": video.thumbnail_url,
"published_at": video.published_at, "published_at": video.published_at,
"duration_seconds": video.duration_seconds, "duration_seconds": video.duration_seconds,
"is_short": video.duration_seconds is not None
and video.duration_seconds <= settings.shorts_max_duration_seconds,
"youtube_url": video.youtube_url, "youtube_url": video.youtube_url,
"categories": categories, "categories": categories,
"local": _serialize_local(download_job), "local": _serialize_local(download_job),

View file

@ -97,10 +97,11 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); } .download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); }
.download-action-error { color: var(--error); font-size: 12px; } .download-action-error { color: var(--error); font-size: 12px; }
.load-more { display: flex; margin: 34px auto 0; } .load-more { display: flex; margin: 34px auto 0; }
.view-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); } .page-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.view-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; } .view-toggle, .type-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
.view-toggle a:hover { color: var(--text); background: var(--hover); } .view-toggle a, .type-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; }
.view-toggle a.active { color: var(--accent); background: var(--accent-soft); } .view-toggle a:hover, .type-toggle a:hover { color: var(--text); background: var(--hover); }
.view-toggle a.active, .type-toggle a.active { color: var(--accent); background: var(--accent-soft); }
.channel-activity-list { list-style: none; display: grid; gap: 25px; padding: 0; margin: 0; } .channel-activity-list { list-style: none; display: grid; gap: 25px; padding: 0; margin: 0; }
.channel-activity-row { display: grid; grid-template-columns: 228px minmax(0, 1fr); gap: 18px; align-items: start; min-width: 0; padding-bottom: 25px; border-bottom: 1px solid var(--border); } .channel-activity-row { display: grid; grid-template-columns: 228px minmax(0, 1fr); gap: 18px; align-items: start; min-width: 0; padding-bottom: 25px; border-bottom: 1px solid var(--border); }
.channel-activity-identity { display: flex; align-items: center; gap: 12px; min-width: 0; padding-top: 2px; color: var(--text); text-decoration: none; } .channel-activity-identity { display: flex; align-items: center; gap: 12px; min-width: 0; padding-top: 2px; color: var(--text); text-decoration: none; }
@ -252,6 +253,36 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; padding: 30px; color: var(--muted); text-align: center; } .app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; padding: 30px; color: var(--muted); text-align: center; }
.app-loading h1 { font-size: 24px; } .app-loading h1 { font-size: 24px; }
.app-loading button { min-height: 42px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); } .app-loading button { min-height: 42px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); }
.shorts-page { position: relative; background: #000; }
.shorts-feed { position: relative; height: calc(100dvh - 66px); overflow-y: auto; overscroll-behavior: contain; scroll-snap-type: y mandatory; scroll-behavior: smooth; background: #000; outline: none; }
.shorts-feed:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.shorts-feed.shorts-centered { display: grid; place-items: center; }
.shorts-slide { position: relative; height: 100%; overflow: hidden; scroll-snap-align: start; scroll-snap-stop: always; background: #000; }
.shorts-player, .shorts-poster { position: absolute; inset: 0; }
.shorts-video { width: 100%; height: 100%; border: 0; object-fit: contain; background: #000; }
.shorts-yt-host > div { width: 100%; height: 100%; }
/* YT.Player replaces our imperatively-created child with an <iframe
width=640 height=360>; size it back to the full slide. */
.shorts-yt-host iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.shorts-poster img { width: 100%; height: 100%; object-fit: cover; }
.shorts-poster .video-thumbnail-placeholder { width: 100%; height: 100%; }
.shorts-overlay { position: absolute; inset: auto 0 0 0; z-index: 2; display: flex; flex-direction: column; gap: 6px; max-width: min(760px, 82%); padding: 22px clamp(28px, 4vw, 54px) 28px; background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.72) 70%); pointer-events: none; }
.shorts-overlay a { pointer-events: auto; }
.shorts-channel { align-self: flex-start; color: #fff; font-size: 15px; font-weight: 700; text-decoration: none; text-shadow: 0 1px 6px rgba(0,0,0,.8); }
.shorts-channel:hover { color: var(--accent); }
.shorts-title { margin: 0; color: #fff; font-size: clamp(16px, 1.6vw, 21px); line-height: 1.35; text-shadow: 0 1px 8px rgba(0,0,0,.8); }
.shorts-meta { display: flex; align-items: center; gap: 14px; color: rgba(255,255,255,.75); font-size: 12px; }
.shorts-external { color: rgba(255,255,255,.85); text-decoration: none; font-weight: 600; }
.shorts-external:hover { color: var(--accent); text-decoration: underline; }
.shorts-mute { position: absolute; z-index: 3; right: clamp(16px, 3vw, 34px); bottom: 26px; display: grid; place-items: center; width: 46px; height: 46px; border: 1px solid rgba(255,255,255,.28); border-radius: 50%; background: rgba(0,0,0,.5); color: #fff; }
.shorts-mute:hover { background: rgba(0,0,0,.75); }
.shorts-mute:disabled { opacity: .45; cursor: default; }
.shorts-back { position: absolute; z-index: 5; top: 14px; left: 14px; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 14px 0 10px; border: 1px solid rgba(255,255,255,.22); border-radius: 999px; background: rgba(0,0,0,.55); color: #fff; font-size: 13px; font-weight: 600; }
.shorts-back:hover { background: rgba(0,0,0,.8); }
.shorts-sentinel { height: 1px; scroll-snap-align: none; }
.shorts-loading { position: absolute; z-index: 4; left: 50%; bottom: 22px; transform: translateX(-50%); padding: 8px 16px; border-radius: 999px; background: rgba(0,0,0,.66); color: #fff; font-size: 13px; }
.shorts-skeleton .skeleton-thumbnail { border-radius: 0; position: absolute; inset: 0; }
.shorts-skeleton .shorts-overlay { z-index: 1; }
@media (min-width: 1800px) { .video-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } } @media (min-width: 1800px) { .video-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
@media (min-width: 1400px) and (max-width: 1799px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } @media (min-width: 1400px) and (max-width: 1799px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (min-width: 1100px) and (max-width: 1399px) { .video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (min-width: 1100px) and (max-width: 1399px) { .video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@ -286,7 +317,7 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.page { padding: 24px 16px 58px; } .page { padding: 24px 16px 58px; }
.page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; } .page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
.page-heading h1 { font-size: 27px; } .page-heading h1 { font-size: 27px; }
.view-toggle a { min-height: 38px; padding: 0 12px; } .view-toggle a, .type-toggle a { min-height: 38px; padding: 0 12px; }
.mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; } .mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; }
.channel-activity-videos { grid-template-columns: repeat(2, minmax(0, 1fr)); } .channel-activity-videos { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; } .video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
@ -317,15 +348,22 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger { tran
.category-count { flex-basis: 100%; margin-left: 21px; } .category-count { flex-basis: 100%; margin-left: 21px; }
.category-edit-form input { flex-basis: 100%; } .category-edit-form input { flex-basis: 100%; }
.sync-popover { right: -7px; max-width: calc(100vw - 30px); } .sync-popover { right: -7px; max-width: calc(100vw - 30px); }
.page-heading { flex-wrap: wrap; }
.page-controls { justify-content: flex-start; }
.shorts-feed { height: calc(100dvh - 112px); }
.shorts-overlay { max-width: 100%; padding: 18px 16px 26px; }
.shorts-back { top: 10px; left: 10px; }
.shorts-mute { right: 16px; bottom: 20px; }
} }
@media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } } @media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } }
@media (pointer: coarse) { @media (pointer: coarse) {
.video-actions .button-secondary, .download-badge, .video-page-actions .button-secondary, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; } .video-actions .button-secondary, .download-badge, .video-page-actions .button-secondary, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; }
.sidebar-badge { display: inline-flex; align-items: center; } .sidebar-badge { display: inline-flex; align-items: center; }
.view-toggle a { min-height: 44px; } .view-toggle a, .type-toggle a { min-height: 44px; }
.reorder-buttons .icon-button { width: 40px; height: 40px; } .reorder-buttons .icon-button { width: 40px; height: 40px; }
.popover-create input, .popover-create button { height: 44px; } .popover-create input, .popover-create button { height: 44px; }
.popover-create button { width: 44px; } .popover-create button { width: 44px; }
} }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
@media (prefers-reduced-motion: reduce) { .tap-seek-indicator { animation: none; opacity: 1; } } @media (prefers-reduced-motion: reduce) { .tap-seek-indicator { animation: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .shorts-feed { scroll-snap-type: none; } }

View file

@ -7,6 +7,7 @@ import Feed from './pages/Feed'
import Channels from './pages/Channels' import Channels from './pages/Channels'
import ChannelVideos from './pages/ChannelVideos' import ChannelVideos from './pages/ChannelVideos'
import VideoPage from './pages/VideoPage' import VideoPage from './pages/VideoPage'
import ShortsFeed from './pages/ShortsFeed'
import Categories from './pages/Categories' import Categories from './pages/Categories'
import Settings from './pages/Settings' import Settings from './pages/Settings'
import './App.css' import './App.css'
@ -29,6 +30,7 @@ function App() {
<Route path="/channels" element={<Channels />} /> <Route path="/channels" element={<Channels />} />
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} /> <Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
<Route path="/video/:youtubeVideoId" element={<VideoPage />} /> <Route path="/video/:youtubeVideoId" element={<VideoPage />} />
<Route path="/shorts/:youtubeVideoId" element={<ShortsFeed />} />
<Route path="/settings" element={<Settings email={data.email} />} /> <Route path="/settings" element={<Settings email={data.email} />} />
<Route path="/settings/categories" element={<Categories />} /> <Route path="/settings/categories" element={<Categories />} />
<Route path="/categories" element={<Navigate to="/settings/categories" replace />} /> <Route path="/categories" element={<Navigate to="/settings/categories" replace />} />

View file

@ -170,6 +170,8 @@ export function syncVideos() {
return request<SyncStatus>('/api/sync/videos', { method: 'POST' }) return request<SyncStatus>('/api/sync/videos', { method: 'POST' })
} }
export type VideoType = 'all' | 'long' | 'short'
export interface FeedVideoDto { export interface FeedVideoDto {
youtube_video_id: string youtube_video_id: string
title: string title: string
@ -183,6 +185,7 @@ export interface FeedVideoDto {
thumbnail_url: string | null thumbnail_url: string | null
published_at: string published_at: string
duration_seconds: number | null duration_seconds: number | null
is_short: boolean
youtube_url: string youtube_url: string
categories: { id: number; name: string }[] categories: { id: number; name: string }[]
local: { local: {
@ -233,6 +236,8 @@ export function getFeed(
cursor?: string cursor?: string
limit?: number limit?: number
newOnly?: boolean newOnly?: boolean
type?: VideoType
anchor?: string
} = {}, } = {},
) { ) {
const qs = new URLSearchParams() const qs = new URLSearchParams()
@ -242,6 +247,8 @@ export function getFeed(
if (params.downloaded) qs.set('downloaded', 'true') if (params.downloaded) qs.set('downloaded', 'true')
if (params.search) qs.set('search', params.search) if (params.search) qs.set('search', params.search)
if (params.newOnly) qs.set('new_only', 'true') if (params.newOnly) qs.set('new_only', 'true')
if (params.type) qs.set('type', params.type)
if (params.anchor) qs.set('anchor', params.anchor)
if (params.cursor) qs.set('cursor', params.cursor) if (params.cursor) qs.set('cursor', params.cursor)
if (params.limit != null) qs.set('limit', String(params.limit)) if (params.limit != null) qs.set('limit', String(params.limit))
const suffix = qs.toString() ? `?${qs.toString()}` : '' const suffix = qs.toString() ? `?${qs.toString()}` : ''

View file

@ -64,12 +64,21 @@ function Sidebar({ close }: { close: () => void }) {
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? [] const categories = categoriesQuery.data ?? []
const location = useLocation() const location = useLocation()
// Keep the current view mode ("Лента | Каналы") when switching between // Keep the current view mode ("Лента | Каналы") and the video-type filter
// "Все видео" and category links; badges (?new=1) are feed-specific. // ("Обычные | Shorts") when switching between "Все видео" and category
const viewQuery = new URLSearchParams(location.search).get('view') === 'channels' ? '?view=channels' : '' // links; badges (?new=1) are feed-specific. Only `?type=short` is carried —
// "Обычные" is the default and has no parameter.
const currentParams = new URLSearchParams(location.search)
const preservedParams = new URLSearchParams()
if (currentParams.get('view') === 'channels') preservedParams.set('view', 'channels')
const activeType = currentParams.get('type') === 'short' ? 'short' : null
if (activeType) preservedParams.set('type', activeType)
const viewQuery = preservedParams.toString() ? `?${preservedParams.toString()}` : ''
const typeQuery = activeType ? '?type=short' : ''
const typeSuffix = activeType ? '&type=short' : ''
const links = [ const links = [
{ to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true }, { to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true },
{ to: '/local', icon: 'server' as const, text: 'На сервере' }, { to: `/local${typeQuery}`, icon: 'server' as const, text: 'На сервере' },
] ]
return <nav className="sidebar-nav" aria-label="Основная навигация"> return <nav className="sidebar-nav" aria-label="Основная навигация">
<div className="sidebar-group"> <div className="sidebar-group">
@ -79,7 +88,7 @@ function Sidebar({ close }: { close: () => void }) {
<div className="sidebar-heading">Категории</div> <div className="sidebar-heading">Категории</div>
{categories.map((category) => <div key={category.id} className="sidebar-category-row"> {categories.map((category) => <div key={category.id} className="sidebar-category-row">
<NavLink to={`/category/${category.id}${viewQuery}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink> <NavLink to={`/category/${category.id}${viewQuery}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>} {category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1${typeSuffix}`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
</div>)} </div>)}
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>} {categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}
<Link to="/settings/categories" onClick={close} className="sidebar-link sidebar-add"><Icon name="plus" size={18} /><span>Новая категория</span></Link> <Link to="/settings/categories" onClick={close} className="sidebar-link sidebar-add"><Icon name="plus" size={18} /><span>Новая категория</span></Link>

View file

@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client' import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format' import { formatDuration, formatRelativeTime } from '../utils/format'
import { videoHref } from '../utils/videoLinks'
interface Props { interface Props {
video: FeedVideoDto video: FeedVideoDto
@ -8,8 +9,8 @@ interface Props {
function CompactVideoCard({ video }: Props) { function CompactVideoCard({ video }: Props) {
const duration = formatDuration(video.duration_seconds) const duration = formatDuration(video.duration_seconds)
const href = `/video/${video.youtube_video_id}`
const location = useLocation() const location = useLocation()
const href = videoHref(video, location)
const from = location.pathname + location.search const from = location.pathname + location.search
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY)) const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))

View file

@ -1,6 +1,6 @@
import type { SVGProps } from 'react' import type { SVGProps } from 'react'
export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down' export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down' | 'volume' | 'volume-off'
const paths: Record<IconName, React.ReactNode> = { const paths: Record<IconName, React.ReactNode> = {
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>, menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
@ -25,6 +25,8 @@ const paths: Record<IconName, React.ReactNode> = {
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6m4-6v6" /></>, trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6m4-6v6" /></>,
up: <><path d="m6 15 6-6 6 6" /></>, up: <><path d="m6 15 6-6 6 6" /></>,
down: <><path d="m6 9 6 6 6-6" /></>, down: <><path d="m6 9 6 6 6-6" /></>,
volume: <><path d="M11 5 6 9H3v6h3l5 4V5Z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /></>,
'volume-off': <><path d="M11 5 6 9H3v6h3l5 4V5Z" /><path d="m16 9 5 6M21 9l-5 6" /></>,
} }
export default function Icon({ name, size = 20, ...props }: SVGProps<SVGSVGElement> & { name: IconName; size?: number }) { export default function Icon({ name, size = 20, ...props }: SVGProps<SVGSVGElement> & { name: IconName; size?: number }) {

View file

@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { FeedVideoDto } from '../api/client' import type { FeedVideoDto } from '../api/client'
import { recheckLocal } from '../api/client' import { recheckLocal } from '../api/client'
import type { YoutubePlayerApi } from '../utils/youtube'
import { loadYouTubeIframeApi } from '../utils/youtube'
const DOUBLE_TAP_WINDOW_MS = 300 const DOUBLE_TAP_WINDOW_MS = 300
const DOUBLE_TAP_MAX_DISTANCE_PX = 40 const DOUBLE_TAP_MAX_DISTANCE_PX = 40
@ -10,55 +12,6 @@ const SEEK_STEP_SECONDS = 10
const INDICATOR_DURATION_MS = 750 const INDICATOR_DURATION_MS = 750
const YT_API_TIMEOUT_MS = 6000 const YT_API_TIMEOUT_MS = 6000
interface YoutubePlayerApi {
seekTo(seconds: number, allowSeekAhead: boolean): void
getCurrentTime(): number
getDuration(): number
destroy(): void
}
interface YoutubePlayerCtorOptions {
videoId: string
host?: string
playerVars?: Record<string, string | number>
events?: {
onReady?: (event: { target: YoutubePlayerApi }) => void
}
}
declare global {
interface Window {
YT?: {
Player: new (element: HTMLElement, options: YoutubePlayerCtorOptions) => YoutubePlayerApi
}
onYouTubeIframeAPIReady?: () => void
}
}
let youTubeApiLoadPromise: Promise<void> | null = null
function loadYouTubeIframeApi(): Promise<void> {
if (window.YT?.Player) return Promise.resolve()
if (youTubeApiLoadPromise) return youTubeApiLoadPromise
youTubeApiLoadPromise = new Promise<void>((resolve, reject) => {
const previous = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previous?.()
resolve()
}
const script = document.createElement('script')
script.src = 'https://www.youtube.com/iframe_api'
script.async = true
script.onerror = () => reject(new Error('YouTube IFrame API script failed to load'))
document.head.appendChild(script)
})
youTubeApiLoadPromise = youTubeApiLoadPromise.catch((error: unknown) => {
youTubeApiLoadPromise = null
throw error
})
return youTubeApiLoadPromise
}
function clampSeekTarget(current: number, delta: number, duration: number): number { function clampSeekTarget(current: number, delta: number, duration: number): number {
const target = current + delta const target = current + delta
if (Number.isFinite(duration) && duration > 0) { if (Number.isFinite(duration) && duration > 0) {

View file

@ -0,0 +1,205 @@
import { useEffect, useRef, useState } from 'react'
import type { FeedVideoDto } from '../api/client'
import type { YoutubePlayerApi } from '../utils/youtube'
import { loadYouTubeIframeApi } from '../utils/youtube'
import Icon from './Icon'
const YT_API_TIMEOUT_MS = 6000
const YT_MUTE_POLL_MS = 1000
// mute()/unMute() do not update the widget's cached state immediately (the
// iframe is only polled by www-widgetapi every ~250ms), so sightings right after
// a programmatic change can be stale. Ignore polls for this window.
const YT_MUTE_SUPPRESS_MS = 600
interface Props {
video: FeedVideoDto
muted: boolean
onToggleMute: () => void
onMutedChange: (muted: boolean) => void
}
// The player for a single active Shorts slide. It is mounted only for the
// active slide, so unmounting is what stops playback (no manual pause()).
// Sound is controlled by the parent (`muted` prop) and survives swipes.
function ShortsPlayer({ video, muted, onToggleMute, onMutedChange }: Props) {
const [localFailed, setLocalFailed] = useState(false)
const [ytFailed, setYtFailed] = useState(false)
const videoRef = useRef<HTMLVideoElement | null>(null)
const ytRef = useRef<YoutubePlayerApi | null>(null)
const ytReadyRef = useRef(false)
const hostRef = useRef<HTMLDivElement | null>(null)
// Read inside the player-creation effect without making `muted` a dependency
// (that would recreate YT.Player and reload the video on every toggle).
const mutedRef = useRef(muted)
// Timestamp until which the poll must not trust isMuted().
const suppressPollUntilRef = useRef(0)
const useLocal = video.local.available && !!video.local.media_url && !localFailed
useEffect(() => {
if (useLocal) return
let cancelled = false
let timedOut = false
let child: HTMLDivElement | null = null
let timeoutId: number | null = null
ytRef.current?.destroy()
ytRef.current = null
ytReadyRef.current = false
// Fall back to a plain iframe if the IFrame API does not come up in time.
timeoutId = window.setTimeout(() => {
timeoutId = null
timedOut = true
ytReadyRef.current = false
ytRef.current?.destroy()
ytRef.current = null
if (child?.parentNode) child.remove()
child = null
if (!cancelled) setYtFailed(true)
}, YT_API_TIMEOUT_MS)
loadYouTubeIframeApi()
.then(() => {
if (cancelled || timedOut || !window.YT?.Player) return
const host = hostRef.current
if (!host) return
child = document.createElement('div')
host.appendChild(child)
const player = new window.YT.Player(child, {
videoId: video.youtube_video_id,
host: 'https://www.youtube-nocookie.com',
// Start from the feed-level sound choice. On the first mount this is
// muted, as browser autoplay policies block sound on programmatic
// play; the visible button lets the user opt into sound.
playerVars: { playsinline: 1, autoplay: 1, mute: mutedRef.current ? 1 : 0 },
events: {
onReady: () => {
if (cancelled || timedOut) return
if (timeoutId !== null) {
window.clearTimeout(timeoutId)
timeoutId = null
}
setYtFailed(false)
ytReadyRef.current = true
// Apply the current value, not the one captured at mount: the
// user may have toggled sound before the player became ready.
if (mutedRef.current) player.mute?.()
else player.unMute?.()
// isMuted() lags behind the call above; ignore polls briefly.
suppressPollUntilRef.current = Date.now() + YT_MUTE_SUPPRESS_MS
},
},
})
ytRef.current = player
})
.catch(() => {
if (cancelled || timedOut) return
if (timeoutId !== null) {
window.clearTimeout(timeoutId)
timeoutId = null
}
setYtFailed(true)
})
return () => {
cancelled = true
ytReadyRef.current = false
if (timeoutId !== null) window.clearTimeout(timeoutId)
ytRef.current?.destroy()
ytRef.current = null
if (child?.parentNode) child.remove()
child = null
}
}, [useLocal, video.youtube_video_id])
// Keep the ref current (read by onReady) and apply sound changes to an
// already-created YT player. Kept separate from the creation effect so
// toggling never recreates the player. On mount it may run before the player
// exists — initial state is covered by playerVars and onReady.
useEffect(() => {
mutedRef.current = muted
const player = ytRef.current
if (!player) return
if (muted) player.mute?.()
else player.unMute?.()
// The player's own isMuted() is stale until the next infoDelivery; don't
// let the poll read it and revert this change.
suppressPollUntilRef.current = Date.now() + YT_MUTE_SUPPRESS_MS
}, [muted])
// Poll the YouTube player's own mute state so sound changes made with the
// native YouTube control (not our button) propagate to the feed-level state.
// Skips until onReady (isMuted() before ready can throw), stops on unmount,
// and ignores the stale window right after a programmatic change.
useEffect(() => {
if (useLocal) return
const intervalId = window.setInterval(() => {
if (Date.now() < suppressPollUntilRef.current) return
const player = ytRef.current
if (!ytReadyRef.current || !player?.isMuted) return
let observed: unknown
try {
observed = player.isMuted()
} catch {
return
}
// isMuted() may be undefined until the first infoDelivery.
if (typeof observed !== 'boolean') return
if (observed === mutedRef.current) return
// Record before notifying the parent so a re-render cannot re-report it.
mutedRef.current = observed
onMutedChange(observed)
}, YT_MUTE_POLL_MS)
return () => window.clearInterval(intervalId)
}, [useLocal, onMutedChange])
// The plain-iframe fallback always autoplays muted with no JS control, so the
// UI reports "muted" there regardless of the feed-level choice.
const effectivelyMuted = !useLocal && ytFailed ? true : muted
return (
<div className="shorts-player">
{useLocal ? (
<video
ref={videoRef}
className="shorts-video"
autoPlay
playsInline
loop
muted={muted}
controls
src={video.local.media_url!}
onVolumeChange={(event) => {
// Reflect native-control changes back into the feed-level state so
// the next slide keeps the sound choice. "Muted" is only the muted
// flag, matching YouTube; volume 0 is not treated as muted.
const nextMuted = event.currentTarget.muted
if (nextMuted !== muted) onMutedChange(nextMuted)
}}
onError={() => setLocalFailed(true)}
/>
) : ytFailed ? (
<iframe
className="shorts-video"
src={`https://www.youtube-nocookie.com/embed/${video.youtube_video_id}?autoplay=1&playsinline=1&mute=1`}
title={video.title}
allow="autoplay; encrypted-media; picture-in-picture"
allowFullScreen
/>
) : (
<div ref={hostRef} className="shorts-video shorts-yt-host" />
)}
<button
type="button"
className="shorts-mute"
onClick={onToggleMute}
disabled={!useLocal && ytFailed}
aria-label={effectivelyMuted ? 'Включить звук' : 'Выключить звук'}
>
<Icon name={effectivelyMuted ? 'volume-off' : 'volume'} size={18} />
</button>
</div>
)
}
export default ShortsPlayer

View file

@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client' import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format' import { formatDuration, formatRelativeTime } from '../utils/format'
import { videoHref } from '../utils/videoLinks'
import DownloadButton from './DownloadButton' import DownloadButton from './DownloadButton'
interface Props { interface Props {
@ -10,8 +11,8 @@ interface Props {
function VideoCard({ video, showActions = false }: Props) { function VideoCard({ video, showActions = false }: Props) {
const duration = formatDuration(video.duration_seconds) const duration = formatDuration(video.duration_seconds)
const href = `/video/${video.youtube_video_id}`
const location = useLocation() const location = useLocation()
const href = videoHref(video, location)
const from = location.pathname + location.search const from = location.pathname + location.search
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY)) const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))

View file

@ -1,25 +1,50 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom' import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { getChannel, getFeed } from '../api/client' import { getChannel, getFeed } from '../api/client'
import Icon from '../components/Icon' import Icon from '../components/Icon'
import VideoCard from '../components/VideoCard' import VideoCard from '../components/VideoCard'
import { formatSubscriberCount } from '../utils/format' import { formatSubscriberCount } from '../utils/format'
// Same two-mode filter as the feed: `long` is the default (no URL param),
// `short` is `?type=short`; any other value normalizes to `long`.
type TypeFilter = 'long' | 'short'
const TYPE_OPTIONS: { value: TypeFilter; label: string }[] = [
{ value: 'long', label: 'Обычные' },
{ value: 'short', label: 'Shorts' },
]
function ChannelVideos() { function ChannelVideos() {
const { channelId } = useParams<{ channelId: string }>() const { channelId } = useParams<{ channelId: string }>()
const location = useLocation()
const [searchParams] = useSearchParams()
const id = Number(channelId) const id = Number(channelId)
const valid = Number.isInteger(id) && id > 0 const valid = Number.isInteger(id) && id > 0
const videoType: TypeFilter = searchParams.get('type') === 'short' ? 'short' : 'long'
const typeLink = (value: TypeFilter) => {
const params = new URLSearchParams(location.search)
if (value === 'short') params.set('type', 'short')
else params.delete('type')
const qs = params.toString()
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
}
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid }) const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid })
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id], queryFn: ({ pageParam }) => getFeed({ channelId: id, limit: 20, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid }) const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id, videoType], queryFn: ({ pageParam }) => getFeed({ channelId: id, type: videoType, limit: 20, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? [] const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
const subscriberLabel = channelQuery.data ? formatSubscriberCount(channelQuery.data.subscriber_count) : null const subscriberLabel = channelQuery.data ? formatSubscriberCount(channelQuery.data.subscriber_count) : null
const typeEmptyTitle = videoType === 'short' ? 'У канала пока нет Shorts' : 'У канала пока нет обычных видео'
const typeEmptyText = videoType === 'short' ? 'Короткие ролики появятся здесь, когда попадут в ленту.' : 'Обычные ролики появятся здесь, когда попадут в ленту.'
return <section className="page"> return <section className="page">
<Link to="/channels" className="back-link"><Icon name="arrow" size={17} /> Каналы</Link> <Link to="/channels" className="back-link"><Icon name="arrow" size={17} /> Каналы</Link>
<div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div></div> <div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div>
<div className="page-controls"><div className="type-toggle" role="group" aria-label="Тип видео">
{TYPE_OPTIONS.map((option) => <Link key={option.value} to={typeLink(option.value)} className={videoType === option.value ? 'active' : ''} aria-current={videoType === option.value ? 'page' : undefined}>{option.label}</Link>)}
</div></div>
</div>
{(channelQuery.isError || feedQuery.isError || !valid) && <div className="empty-state" role="alert"><h2>Не удалось открыть канал</h2><Link to="/channels" className="button-primary">К списку каналов</Link></div>} {(channelQuery.isError || feedQuery.isError || !valid) && <div className="empty-state" role="alert"><h2>Не удалось открыть канал</h2><Link to="/channels" className="button-primary">К списку каналов</Link></div>}
{feedQuery.isLoading && <div className="video-grid">{Array.from({ length: 6 }, (_, index) => <div className="video-card" key={index}><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /></div></div>)}</div>} {feedQuery.isLoading && <div className="video-grid">{Array.from({ length: 6 }, (_, index) => <div className="video-card" key={index}><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /></div></div>)}</div>}
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>} {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><h2>Пока нет видео</h2><p>Обнови ленту, чтобы получить новые ролики канала.</p></div>} {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><h2>{typeEmptyTitle}</h2><p>{typeEmptyText}</p><Link className="button-primary" to={typeLink(videoType === 'short' ? 'long' : 'short')}>{videoType === 'short' ? 'Смотреть обычные' : 'Смотреть Shorts'}</Link></div>}
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>} {feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</section> </section>
} }

View file

@ -6,6 +6,17 @@ import ChannelActivityList from '../components/ChannelActivityList'
import Icon from '../components/Icon' import Icon from '../components/Icon'
import VideoCard from '../components/VideoCard' import VideoCard from '../components/VideoCard'
// The UI exposes exactly two mutually exclusive modes. `long` is the default
// and has no URL parameter; `short` is `?type=short`. The REST endpoint still
// accepts the unfiltered value for future mobile clients, but the UI never
// sends it.
type TypeFilter = 'long' | 'short'
const TYPE_OPTIONS: { value: TypeFilter; label: string }[] = [
{ value: 'long', label: 'Обычные' },
{ value: 'short', label: 'Shorts' },
]
function VideoSkeleton() { function VideoSkeleton() {
return <li className="video-card" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /><div className="skeleton-line short" /></div></li> return <li className="video-card" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /><div className="skeleton-line short" /></div></li>
} }
@ -20,8 +31,37 @@ function Feed() {
const isUncategorized = location.pathname === '/uncategorized' const isUncategorized = location.pathname === '/uncategorized'
const supportsViewToggle = !isLocal && location.pathname !== '/search' const supportsViewToggle = !isLocal && location.pathname !== '/search'
const isChannelsView = supportsViewToggle && searchParams.get('view') === 'channels' const isChannelsView = supportsViewToggle && searchParams.get('view') === 'channels'
const viewQuery = isChannelsView ? 'view=channels' : '' // Any value other than `short` (missing, `all`, `long`, garbage) is
const withView = (path: string) => viewQuery ? `${path}?${viewQuery}` : path // normalized to the default `long` mode.
const videoType: TypeFilter = searchParams.get('type') === 'short' ? 'short' : 'long'
// Query params carried across sidebar/mobile-category links: the view mode
// and the video-type filter. Only `?type=short` is ever written; `long` is
// the default and has no parameter. `?new=1` stays feed-specific.
const preservedParams = new URLSearchParams()
if (isChannelsView) preservedParams.set('view', 'channels')
if (videoType === 'short') preservedParams.set('type', 'short')
const preservedQuery = preservedParams.toString()
const withPreserved = (path: string) => (preservedQuery ? `${path}${path.includes('?') ? '&' : '?'}${preservedQuery}` : path)
// "На сервере" has no channels view, so the view mode must not leak into its
// URL; keep only the type filter.
const localNavPath = `/local${videoType === 'short' ? '?type=short' : ''}`
const typeLink = (value: TypeFilter) => {
const params = new URLSearchParams(location.search)
if (value === 'short') params.set('type', 'short')
else params.delete('type')
const qs = params.toString()
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
}
const feedViewLink = () => {
const params = new URLSearchParams()
if (videoType === 'short') params.set('type', 'short')
const qs = params.toString()
return { pathname: location.pathname, search: qs ? `?${qs}` : '' }
}
const channelsViewSearch = new URLSearchParams({ view: 'channels' })
// The filter is not applied in the channels view; carrying `?type=short`
// only restores the user's choice when they switch back to the feed.
if (videoType === 'short') channelsViewSearch.set('type', 'short')
const newOnly = categoryNumber !== undefined && searchParams.get('new') === '1' const newOnly = categoryNumber !== undefined && searchParams.get('new') === '1'
const localCategoryRaw = isLocal ? searchParams.get('category') : null const localCategoryRaw = isLocal ? searchParams.get('category') : null
const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined
@ -36,13 +76,14 @@ function Feed() {
enabled: isUncategorized || (!categoryNumber && !isLocal && !search), enabled: isUncategorized || (!categoryNumber && !isLocal && !search),
}) })
const feedQuery = useInfiniteQuery({ const feedQuery = useInfiniteQuery({
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly], queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly, videoType],
queryFn: ({ pageParam }) => getFeed({ queryFn: ({ pageParam }) => getFeed({
categoryId: isLocal ? localCategory : categoryNumber, categoryId: isLocal ? localCategory : categoryNumber,
uncategorized: isUncategorized || localUncategorized, uncategorized: isUncategorized || localUncategorized,
downloaded: isLocal, downloaded: isLocal,
search, search,
newOnly, newOnly,
type: videoType,
cursor: pageParam, cursor: pageParam,
}), }),
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
@ -85,25 +126,46 @@ function Feed() {
} }
}, [isChannelsView, feedQuery.isLoading, location.pathname, location.search]) }, [isChannelsView, feedQuery.isLoading, location.pathname, location.search])
const hasInvalidCategory = Boolean(categoryId && !categoryNumber) const hasInvalidCategory = Boolean(categoryId && !categoryNumber)
// Empty-state content is derived from the active type filter together with
// the context, so the heading and the hint never contradict each other.
const typeEmptyTitle = videoType === 'short' ? 'Здесь пока нет Shorts' : 'Здесь пока нет обычных видео'
const typeEmptyText = videoType === 'short' ? 'Короткие ролики появятся здесь, когда попадут в ленту.' : 'Обычные ролики появятся здесь, когда попадут в ленту.'
// Offer switching to the other type when this context does have content,
// just not of the selected type.
const otherTypeLink = videoType === 'short' ? typeLink('long') : typeLink('short')
const otherTypeLabel = videoType === 'short' ? 'Смотреть обычные' : 'Смотреть Shorts'
// `/local`: tell "nothing saved at all" apart from "nothing of this type".
const noSavedVideos = savedCountsQuery.data?.all === 0
// `/uncategorized`: tell "all channels already categorized" apart from
// "uncategorized channels exist but have no videos of this type".
const noUncategorizedChannels = countQuery.data !== undefined && countQuery.data.length === 0
// Root feed with zero subscriptions: onboarding takes priority over the
// type-specific empty-state.
const noSubscriptions = location.pathname === '/' && countQuery.data !== undefined && countQuery.data.length === 0
return <section className="page"> return <section className="page">
<div className="page-heading"> <div className="page-heading">
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={`/category/${categoryNumber}`}>Показать все</Link></>}</p></div> <div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={withPreserved(`/category/${categoryNumber}`)}>Показать все</Link></>}</p></div>
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра"> <div className="page-controls">
<Link to={location.pathname} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link> {!isChannelsView && <div className="type-toggle" role="group" aria-label="Тип видео">
<Link to={{ pathname: location.pathname, search: 'view=channels' }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link> {TYPE_OPTIONS.map((option) => <Link key={option.value} to={typeLink(option.value)} className={videoType === option.value ? 'active' : ''} aria-current={videoType === option.value ? 'page' : undefined}>{option.label}</Link>)}
</div>} </div>}
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра">
<Link to={feedViewLink()} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link>
<Link to={{ pathname: location.pathname, search: `?${channelsViewSearch.toString()}` }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link>
</div>}
</div>
</div> </div>
<nav className="mobile-category-nav" aria-label="Категории"> <nav className="mobile-category-nav" aria-label="Категории">
<Link to={withView('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link> <Link to={withPreserved('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
<Link to={withView('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link> <Link to={withPreserved('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link>
<Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link> <Link to={localNavPath} className={isLocal ? 'active' : ''}>На сервере</Link>
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withView(`/category/${item.id}`)} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)} {categoriesQuery.data?.map((item) => <Link key={item.id} to={withPreserved(`/category/${item.id}`)} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
</nav> </nav>
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео"> {isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
<Link to="/local" className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link> <Link to={withPreserved('/local')} className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
<Link to="/local?uncategorized=true" className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link> <Link to={withPreserved('/local?uncategorized=true')} className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/local?category=${item.id}`} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)} {categoriesQuery.data?.map((item) => <Link key={item.id} to={withPreserved(`/local?category=${item.id}`)} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)}
</nav>} </nav>}
{syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>} {syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>}
{location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 && <div className="notice onboarding-notice"><Icon name="folder" size={19} /><span>Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.</span><Link to="/channels">Настроить категории <Icon name="chevron" size={15} /></Link></div>} {location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 && <div className="notice onboarding-notice"><Icon name="folder" size={19} /><span>Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.</span><Link to="/channels">Настроить категории <Icon name="chevron" size={15} /></Link></div>}
@ -111,7 +173,7 @@ function Feed() {
{feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>} {feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>}
{feedQuery.isError && <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить видео</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => feedQuery.refetch()}>Повторить</button></div>} {feedQuery.isError && <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить видео</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => feedQuery.refetch()}>Повторить</button></div>}
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} showActions={isLocal} />)}</ul>} {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} showActions={isLocal} />)}</ul>}
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncRunning && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>} {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncRunning ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? (noSavedVideos ? 'Пока нет сохранённых видео' : typeEmptyTitle) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы распределены' : typeEmptyTitle) : noSubscriptions ? 'Здесь пока нет видео' : typeEmptyTitle}</h2><p>{syncRunning ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? (noSavedVideos ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : typeEmptyText) : isUncategorized ? (noUncategorizedChannels ? 'Все каналы уже распределены по категориям.' : typeEmptyText) : noSubscriptions ? 'Добавь каналы в категорию или обнови ленту.' : typeEmptyText}</p>{!search && !syncRunning && (isLocal ? (!noSavedVideos && <Link className="button-primary" to={otherTypeLink}>{otherTypeLabel}</Link>) : noSubscriptions ? <Link className="button-primary" to="/channels">Перейти к каналам</Link> : isUncategorized && noUncategorizedChannels ? null : <Link className="button-primary" to={otherTypeLink}>{otherTypeLabel}</Link>)}</div>}
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>} {feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</>} </>}
</section> </section>

View file

@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { KeyboardEvent } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import { getFeed } from '../api/client'
import Icon from '../components/Icon'
import ShortsPlayer from '../components/ShortsPlayer'
import { formatRelativeTime } from '../utils/format'
function ShortsSkeleton() {
return <div className="shorts-slide shorts-skeleton" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="shorts-overlay"><div className="skeleton-line short" /><div className="skeleton-line wide" /></div></div>
}
function ShortsFeed() {
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
const location = useLocation()
const navigate = useNavigate()
const from = (location.state as { from?: string } | null)?.from
// The context the feed was opened from travels in the URL, so refresh and
// sharing keep the same filter. See analytics doc, "Соглашение о контексте".
const context = useMemo(() => {
const params = new URLSearchParams(location.search)
const categoryRaw = params.get('category')
const channelRaw = params.get('channel')
return {
categoryId: categoryRaw && /^\d+$/.test(categoryRaw) ? Number(categoryRaw) : undefined,
channelId: channelRaw && /^\d+$/.test(channelRaw) ? Number(channelRaw) : undefined,
uncategorized: params.get('uncategorized') === 'true',
downloaded: params.get('downloaded') === 'true',
search: params.get('q') ?? undefined,
}
}, [location.search])
const query = useInfiniteQuery({
queryKey: ['feed', 'shorts', context, youtubeVideoId],
queryFn: ({ pageParam }) => getFeed({
type: 'short',
...context,
// `anchor` only opens the first page at the requested video; later
// pages use the regular cursor.
anchor: pageParam ? undefined : youtubeVideoId,
cursor: pageParam,
limit: 8,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
})
const items = useMemo(() => query.data?.pages.flatMap((page) => page.items) ?? [], [query.data])
const { fetchNextPage, hasNextPage, isFetchingNextPage } = query
const [activeIndex, setActiveIndex] = useState(0)
// Sound state lives in the feed, not in the player: the player is mounted
// only for the active slide and would otherwise reset to muted on every
// swipe. Not persisted across reloads on purpose (autoplay policy).
const [muted, setMuted] = useState(true)
const containerRef = useRef<HTMLDivElement | null>(null)
const slideRefs = useRef<(HTMLDivElement | null)[]>([])
const sentinelRef = useRef<HTMLDivElement | null>(null)
const focusedRef = useRef(false)
const lastFocusKeyRef = useRef('')
// Reset to the first (anchor) slide when the context/route changes. Doing
// it during render (instead of in an effect) avoids a stale-frame flicker.
const routeKey = `${youtubeVideoId ?? ''}|${location.search}`
const [lastRouteKey, setLastRouteKey] = useState(routeKey)
if (lastRouteKey !== routeKey) {
setLastRouteKey(routeKey)
setActiveIndex(0)
}
// Focus the scroll container once, so ArrowUp/ArrowDown work without an
// extra Tab. preventScroll keeps the anchor slide in place.
useEffect(() => {
if (lastFocusKeyRef.current !== routeKey) {
lastFocusKeyRef.current = routeKey
focusedRef.current = false
}
if (!focusedRef.current && containerRef.current && items.length > 0) {
containerRef.current.focus({ preventScroll: true })
focusedRef.current = true
}
}, [routeKey, items.length])
// A slide becomes active once it is at least 60% visible.
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
const index = Number((entry.target as HTMLElement).dataset.index)
if (!Number.isNaN(index)) setActiveIndex(index)
}
}
},
{ root: container, threshold: 0.6 },
)
slideRefs.current.forEach((slide) => { if (slide) observer.observe(slide) })
return () => observer.disconnect()
}, [routeKey, items.length])
// Fetch the next page when the sentinel at the end of the list is near.
useEffect(() => {
const sentinel = sentinelRef.current
const container = containerRef.current
if (!sentinel || !container) return
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting) && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ root: container, rootMargin: '0px 0px 400px 0px', threshold: 0 },
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [routeKey, items.length, hasNextPage, isFetchingNextPage, fetchNextPage])
const goToSlide = useCallback((index: number) => {
const slides = slideRefs.current
if (slides.length === 0) return
const target = slides[Math.max(0, Math.min(index, slides.length - 1))]
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
target?.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' })
}, [])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowDown' || event.key === 'PageDown') {
event.preventDefault()
goToSlide(activeIndex + 1)
} else if (event.key === 'ArrowUp' || event.key === 'PageUp') {
event.preventDefault()
goToSlide(activeIndex - 1)
}
}
const back = () => { if (from && window.history.length > 1) navigate(-1); else navigate(from ?? '/') }
if (query.isError) {
return <div className="shorts-page"><div className="shorts-feed shorts-centered"><div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить Shorts</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => query.refetch()}>Повторить</button></div></div></div>
}
if (query.isLoading) {
return <div className="shorts-page"><div className="shorts-feed">{Array.from({ length: 2 }, (_, index) => <ShortsSkeleton key={index} />)}</div></div>
}
if (items.length === 0) {
return <div className="shorts-page"><div className="shorts-feed shorts-centered"><div className="empty-state"><span className="empty-icon"><Icon name="youtube" size={28} /></span><h2>Здесь пока нет Shorts</h2><p>В этой подборке нет коротких роликов. Вернись к ленте и выбери другой фильтр.</p><Link className="button-primary" to="/">Ко всем видео</Link></div></div></div>
}
return (
<div className="shorts-page">
<button type="button" className="shorts-back" onClick={back}><Icon name="arrow" size={17} /> Назад</button>
<div className="shorts-feed" ref={containerRef} tabIndex={0} onKeyDown={onKeyDown} aria-label="Лента Shorts">
{items.map((video, index) => (
<div
key={video.youtube_video_id}
data-index={index}
ref={(element) => { slideRefs.current[index] = element }}
className={`shorts-slide ${index === activeIndex ? 'is-active' : ''}`}
role="group"
aria-label={`${video.title} — ${video.channel.title}`}
>
{index === activeIndex ? (
<ShortsPlayer video={video} muted={muted} onToggleMute={() => setMuted((value) => !value)} onMutedChange={setMuted} />
) : (
<div className="shorts-poster">
{video.thumbnail_url ? (
<img src={video.thumbnail_url} alt="" loading="lazy" />
) : (
<div className="video-thumbnail-placeholder" />
)}
</div>
)}
<div className="shorts-overlay" inert={index === activeIndex ? undefined : true}>
<Link to={`/channels/${video.channel.id}/videos`} className="shorts-channel" title={video.channel.title}>{video.channel.title}</Link>
<h2 className="shorts-title">{video.title}</h2>
<div className="shorts-meta">
<span>{formatRelativeTime(video.published_at)}</span>
<a href={video.youtube_url} target="_blank" rel="noreferrer" className="shorts-external">Открыть на YouTube</a>
</div>
</div>
</div>
))}
<div ref={sentinelRef} className="shorts-sentinel" aria-hidden="true" />
</div>
{hasNextPage && isFetchingNextPage && <div className="shorts-loading" role="status">Загрузка…</div>}
</div>
)
}
export default ShortsFeed

View file

@ -0,0 +1,40 @@
import type { FeedVideoDto } from '../api/client'
interface LocationLike {
pathname: string
search: string
}
/**
* Where a video card links to: Shorts open the vertical feed at
* `/shorts/:id` (carrying the current list context so the feed keeps the
* same filter), everything else keeps the existing `/video/:id` page.
*/
export function videoHref(video: FeedVideoDto, location: LocationLike): string {
if (!video.is_short) return `/video/${video.youtube_video_id}`
const params = new URLSearchParams()
const { pathname, search } = location
const searchParams = new URLSearchParams(search)
if (pathname.startsWith('/category/')) {
const categoryId = pathname.split('/')[2]
if (categoryId) params.set('category', categoryId)
} else if (pathname === '/uncategorized') {
params.set('uncategorized', 'true')
} else if (pathname === '/local') {
params.set('downloaded', 'true')
const categoryId = searchParams.get('category')
if (categoryId) params.set('category', categoryId)
if (searchParams.get('uncategorized') === 'true') params.set('uncategorized', 'true')
} else if (pathname === '/search') {
const query = searchParams.get('q')
if (query) params.set('q', query)
} else if (pathname.startsWith('/channels/') && pathname.endsWith('/videos')) {
const channelId = pathname.split('/')[2]
if (channelId) params.set('channel', channelId)
}
const query = params.toString()
return `/shorts/${video.youtube_video_id}${query ? `?${query}` : ''}`
}

View file

@ -0,0 +1,52 @@
export interface YoutubePlayerApi {
seekTo(seconds: number, allowSeekAhead: boolean): void
getCurrentTime(): number
getDuration(): number
mute?(): void
unMute?(): void
isMuted?(): boolean
getVolume?(): number
destroy(): void
}
export interface YoutubePlayerCtorOptions {
videoId: string
host?: string
playerVars?: Record<string, string | number>
events?: {
onReady?: (event: { target: YoutubePlayerApi }) => void
}
}
declare global {
interface Window {
YT?: {
Player: new (element: HTMLElement, options: YoutubePlayerCtorOptions) => YoutubePlayerApi
}
onYouTubeIframeAPIReady?: () => void
}
}
let youTubeApiLoadPromise: Promise<void> | null = null
export function loadYouTubeIframeApi(): Promise<void> {
if (window.YT?.Player) return Promise.resolve()
if (youTubeApiLoadPromise) return youTubeApiLoadPromise
youTubeApiLoadPromise = new Promise<void>((resolve, reject) => {
const previous = window.onYouTubeIframeAPIReady
window.onYouTubeIframeAPIReady = () => {
previous?.()
resolve()
}
const script = document.createElement('script')
script.src = 'https://www.youtube.com/iframe_api'
script.async = true
script.onerror = () => reject(new Error('YouTube IFrame API script failed to load'))
document.head.appendChild(script)
})
youTubeApiLoadPromise = youTubeApiLoadPromise.catch((error: unknown) => {
youTubeApiLoadPromise = null
throw error
})
return youTubeApiLoadPromise
}

View file

@ -248,6 +248,7 @@ def test_activity_item_shape(client, db_session):
"thumbnail_url", "thumbnail_url",
"published_at", "published_at",
"duration_seconds", "duration_seconds",
"is_short",
"youtube_url", "youtube_url",
"categories", "categories",
"local", "local",

171
tests/test_shorts_feed.py Normal file
View file

@ -0,0 +1,171 @@
"""Tests for the Shorts/long split on /api/feed: the `type` filter, the
`is_short` flag and the one-off `anchor` starting point (see
analytics/2026-09-27-shorts-split-and-feed.md)."""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app.core.auth_dependency import require_session
from app.db import get_db
from app.main import app
from app.models.channel import Channel
from app.models.video import Video
@pytest.fixture
def client(db_session):
def _get_db_override():
yield db_session
app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None
# Deliberately not using `with TestClient(app)`: that runs the app's
# lifespan, which would try to reach the real MeTube instance and DB.
yield TestClient(app)
del app.dependency_overrides[get_db]
del app.dependency_overrides[require_session]
BASE = datetime(2026, 9, 10, tzinfo=timezone.utc)
# Insertion order gives increasing ids. published_at also increases with i,
# so the descending feed order is v4, v3, v2, v1, v0.
DURATIONS = {
"v0": None, # unknown -> long
"v1": 179, # short
"v2": 180, # short (boundary, inclusive)
"v3": 181, # long (just above the threshold)
"v4": 100, # short
}
def _seed(db_session):
channel = Channel(youtube_channel_id="chanShorts", title="Channel Shorts", subscribed=True)
db_session.add(channel)
db_session.commit()
videos = []
for i, (youtube_video_id, duration) in enumerate(DURATIONS.items()):
video = Video(
youtube_video_id=youtube_video_id,
channel_id=channel.id,
title=f"Video {youtube_video_id}",
published_at=BASE + timedelta(hours=i),
duration_seconds=duration,
youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}",
)
videos.append(video)
db_session.add_all(videos)
db_session.commit()
return channel, videos
def _ids(resp):
return [item["youtube_video_id"] for item in resp["items"]]
def test_feed_type_short_only_includes_at_or_below_threshold(client, db_session):
_seed(db_session)
assert _ids(client.get("/api/feed?type=short").json()) == ["v4", "v2", "v1"]
def test_feed_type_long_includes_null_and_above_threshold(client, db_session):
_seed(db_session)
assert _ids(client.get("/api/feed?type=long").json()) == ["v3", "v0"]
def test_feed_type_all_matches_default(client, db_session):
_seed(db_session)
explicit = client.get("/api/feed?type=all").json()
default = client.get("/api/feed").json()
assert _ids(explicit) == ["v4", "v3", "v2", "v1", "v0"]
assert explicit["items"] == default["items"]
def test_feed_type_invalid_returns_422(client, db_session):
_seed(db_session)
assert client.get("/api/feed?type=weird").status_code == 422
def test_feed_type_threshold_is_configurable(client, db_session, monkeypatch):
_seed(db_session)
monkeypatch.setattr("app.config.settings.shorts_max_duration_seconds", 181)
assert set(_ids(client.get("/api/feed?type=short").json())) == {"v4", "v3", "v2", "v1"}
assert _ids(client.get("/api/feed?type=long").json()) == ["v0"]
def test_feed_item_is_short_flag(client, db_session):
_seed(db_session)
items = {item["youtube_video_id"]: item for item in client.get("/api/feed").json()["items"]}
assert items["v1"]["is_short"] is True
assert items["v2"]["is_short"] is True
assert items["v4"]["is_short"] is True
assert items["v3"]["is_short"] is False
assert items["v0"]["is_short"] is False
def test_feed_type_short_pagination_in_filtered_set(client, db_session):
_seed(db_session)
page1 = client.get("/api/feed?type=short&limit=2").json()
assert _ids(page1) == ["v4", "v2"]
assert page1["next_cursor"] is not None
page2 = client.get(f"/api/feed?type=short&limit=2&cursor={page1['next_cursor']}").json()
assert _ids(page2) == ["v1"]
assert page2["next_cursor"] is None
def test_feed_type_long_pagination_in_filtered_set(client, db_session):
_seed(db_session)
page1 = client.get("/api/feed?type=long&limit=1").json()
assert _ids(page1) == ["v3"]
assert page1["next_cursor"] is not None
page2 = client.get(f"/api/feed?type=long&limit=1&cursor={page1['next_cursor']}").json()
assert _ids(page2) == ["v0"]
assert page2["next_cursor"] is None
def test_feed_anchor_starts_at_video_and_goes_older(client, db_session):
_seed(db_session)
assert _ids(client.get("/api/feed?anchor=v2").json()) == ["v2", "v1", "v0"]
def test_feed_anchor_unknown_falls_back_to_first_page(client, db_session):
_seed(db_session)
resp = client.get("/api/feed?anchor=does-not-exist").json()
assert _ids(resp) == ["v4", "v3", "v2", "v1", "v0"]
def test_feed_anchor_ignored_when_cursor_present(client, db_session):
_seed(db_session)
page1 = client.get("/api/feed?anchor=v2&limit=2").json()
assert _ids(page1) == ["v2", "v1"]
assert page1["next_cursor"] is not None
# A (different) anchor on a cursor page must not shift the selection:
# only the first page uses the anchor.
page2 = client.get(f"/api/feed?anchor=v4&cursor={page1['next_cursor']}").json()
assert _ids(page2) == ["v0"]
def test_feed_anchor_combines_with_type_filter(client, db_session):
_seed(db_session)
assert client.get("/api/feed?type=short&anchor=v0").json()["items"] == []
assert _ids(client.get("/api/feed?type=short&anchor=v3").json()) == ["v2", "v1"]

View file

@ -57,6 +57,21 @@ def test_get_video_by_youtube_id(client, db_session):
assert body["channel"]["id"] == channel.id assert body["channel"]["id"] == channel.id
assert body["categories"] == [{"id": category.id, "name": "Linux"}] assert body["categories"] == [{"id": category.id, "name": "Linux"}]
assert body["local"]["available"] is False assert body["local"]["available"] is False
# 125s <= default 180s threshold.
assert body["is_short"] is True
def test_get_video_is_short_false_for_long_and_unknown_duration(client, db_session, monkeypatch):
_seed_single_video(db_session, youtube_video_id="vidLong", duration_seconds=3600)
_seed_single_video(db_session, youtube_video_id="vidUnknown")
assert client.get("/api/videos/vidLong").json()["is_short"] is False
assert client.get("/api/videos/vidUnknown").json()["is_short"] is False
# The threshold is read from settings at serialization time.
monkeypatch.setattr("app.config.settings.shorts_max_duration_seconds", 4000)
assert client.get("/api/videos/vidLong").json()["is_short"] is True
assert client.get("/api/videos/vidUnknown").json()["is_short"] is False
def test_get_video_not_found(client): def test_get_video_not_found(client):
@ -64,16 +79,19 @@ def test_get_video_not_found(client):
assert resp.status_code == 404 assert resp.status_code == 404
def _seed_single_video(db_session, youtube_video_id="vid1"): def _seed_single_video(db_session, youtube_video_id="vid1", duration_seconds=None):
channel = Channel(youtube_channel_id="chanA", title="Channel A", subscribed=True) channel = db_session.query(Channel).filter(Channel.youtube_channel_id == "chanA").first()
db_session.add(channel) if channel is None:
db_session.commit() channel = Channel(youtube_channel_id="chanA", title="Channel A", subscribed=True)
db_session.add(channel)
db_session.commit()
video = Video( video = Video(
youtube_video_id=youtube_video_id, youtube_video_id=youtube_video_id,
channel_id=channel.id, channel_id=channel.id,
title="Video One", title="Video One",
published_at=datetime(2026, 9, 10, tzinfo=timezone.utc), published_at=datetime(2026, 9, 10, tzinfo=timezone.utc),
duration_seconds=duration_seconds,
youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}", youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}",
) )
db_session.add(video) db_session.add(video)