89 lines
17 KiB
Markdown
89 lines
17 KiB
Markdown
|
|
# Режим «Каналы» на главной: переключатель «Лента | Каналы» и эндпоинт активности каналов
|
|||
|
|
|
|||
|
|
## Задача
|
|||
|
|
|
|||
|
|
Добавить на главную страницу переключатель «Лента | Каналы». Режим «Лента» остаётся как сейчас. Режим «Каналы» — вместо сетки видео список подписанных каналов, отсортированный по свежести последнего видео; у каждого канала — три последних видео в одну широкую строку (компактные карточки). Фильтрация категориями из левого сайдбара действует и в режиме каналов. Режим задаётся URL-параметром `?view=channels`. Страница управления «Каналы» (`/channels`) не меняется.
|
|||
|
|
|
|||
|
|
## Контекст
|
|||
|
|
|
|||
|
|
- Главная уже является комбинированной страницей: `Feed.tsx` обрабатывает `/`, `/category/:id`, `/uncategorized`, `/local`, `/search`; фильтры (`category_id`, `uncategorized`, `new_only`, `downloaded`, `search`) передаются в `GET /api/feed` с курсорной пагинацией (`backend/app/api/feed.py`).
|
|||
|
|
- `listChannels` (`GET /api/channels`) возвращает все подписанные каналы с `category_ids`, но без видео и без сортировки по активности (алфавитная); нарезать «3 последних видео» на клиенте из feed-запросов нельзя — нужен новый backend-эндпоинт.
|
|||
|
|
- Синк видео выполняется в фоне постоянно (`sync_trigger` + APScheduler), поэтому offset-пагинация списка каналов будет «плыть» между страницами (дубли/пропуски при добавлении новых видео). Feed уже использует курсор `(published_at, id)` — для активности каналов берём тот же подход: курсор `(last_published_at, channel_id)`.
|
|||
|
|
- Сериализация видео в ленте — `serialize_video` (`backend/app/services/video_presentation.py`) с `channel_categories_map` и `latest_jobs_map`; DTO `FeedVideoDto` уже типизован в `frontend/src/api/client.ts` — переиспользуем его для видео в строке канала.
|
|||
|
|
- Ссылки категорий и «Все видео» строятся в `Sidebar` (`AppShell.tsx`) без query-параметров; чтобы режим сохранялся при переключении категории, ссылки должны переносить текущий `?view=channels`. Режим в URL гарантирует и сохранение при refresh/back.
|
|||
|
|
- Каналы без видео должны уходить в конец списка (NULLS LAST) — это требует расширения курсора sentinel'ом для NULL-хвоста.
|
|||
|
|
- Иконок для переключателя не требуется: это два текстовых звена в сегментированном контроле; новый набор иконок не вводим.
|
|||
|
|
|
|||
|
|
## Затронутые подсистемы и файлы
|
|||
|
|
|
|||
|
|
Backend:
|
|||
|
|
- `backend/app/api/channels.py` — новый `GET /channels/activity` (объявить **после** `list_channels` и **до** `get_channel`: иначе `/channels/activity` перехватится маршрутом `/channels/{channel_id}` и вернёт 422, т.к. Starlette выбирает маршрут по паттерну, а валидация `int` падает после).
|
|||
|
|
- `backend/app/services/video_presentation.py`, `backend/app/services/download_jobs.py` — только переиспользование (`channel_categories_map`, `latest_jobs_map`, `serialize_video`), без изменений.
|
|||
|
|
- `backend/app/main.py` — без изменений (роутер channels уже подключён).
|
|||
|
|
- Миграции не нужны (новых полей нет; сортировка по `max(videos.published_at)` вычисляется агрегатом).
|
|||
|
|
|
|||
|
|
Frontend:
|
|||
|
|
- `frontend/src/api/client.ts` — новый тип `ChannelActivityDto` (`ChannelDto` + `videos: FeedVideoDto[]`) и функция `getChannelActivity({ categoryId?, uncategorized?, cursor?, limit? })`.
|
|||
|
|
- `frontend/src/pages/Feed.tsx` — чтение `view` из `searchParams`, переключатель в `page-heading` (справа, в освободившееся после переноса кнопки «Обновить» место) только на маршрутах главной, рендер `ChannelActivityList` вместо `video-grid`; mobile-category-nav сохраняет view; инвалидация кэша активности при завершении видео-синка.
|
|||
|
|
- `frontend/src/components/ChannelActivityList.tsx` — **новый**: `useInfiniteQuery`, строка канала (аватар + название-ссылка на `/channels/{id}/videos` + 3 компактные карточки), скелетоны, пустое состояние, ошибка с «Повторить», «Показать ещё».
|
|||
|
|
- `frontend/src/components/CompactVideoCard.tsx` — **новый**: миниатюра 16:9 + бейдж длительности + название (clamp 2 строки) + относительное время; клик → `/video/{youtube_video_id}` с `state={{ from }}` и сохранением скролла как в `VideoCard`. Без DownloadButton (только просмотр).
|
|||
|
|
- `frontend/src/components/AppShell.tsx` — `Sidebar`: сохранять `?view=channels` в ссылках «Все видео» и категорий (через `useLocation`).
|
|||
|
|
- `frontend/src/App.css` — стили переключателя (`.view-toggle`), `.channel-activity-list` / `.channel-activity-row` / `.channel-activity-videos` / `.compact-video-card`, адаптив.
|
|||
|
|
- `frontend/src/components/VideoCard.tsx` — не трогаем (компактная карточка — отдельный лёгкий компонент, не перегружаем VideoCard).
|
|||
|
|
|
|||
|
|
Тесты:
|
|||
|
|
- `tests/test_channel_activity.py` — **новый**, паттерн фикстуры `client` как в `tests/test_channels.py` / `tests/test_feed.py`.
|
|||
|
|
|
|||
|
|
## Критерии приёмки
|
|||
|
|
|
|||
|
|
1. Переключатель «Лента | Каналы» виден на `/`, `/category/:id`, `/uncategorized`; на `/local` и `/search` его нет, `?view=channels` там игнорируется (остаётся лента). Активный режим визуально выделен.
|
|||
|
|
2. Режим в URL: `?view=channels`; переключение сохраняет текущий маршрут; refresh сохраняет режим. Ссылки категорий и «Все видео» в сайдбаре и в mobile-category-nav переносят текущий `view` при переключении категории; обратный переключатель на «Ленту» сбрасывает параметры (`{pathname}` без query).
|
|||
|
|
3. Список каналов отсортирован по дате последнего видео (desc); каналы без видео — в конце. Строка: аватар + название (ссылка на `/channels/{id}/videos`) + 3 последних видео канала в одну широкую строку.
|
|||
|
|
4. Компактная карточка: миниатюра + длительность + название + относительное время; клик открывает страницу видео. Кнопок скачивания в строке нет.
|
|||
|
|
5. Фильтр категории применяется в режиме каналов: `/category/:id?view=channels` — только каналы категории; `/uncategorized?view=channels` — только каналы без категорий; `/` — все подписанные. Отписанные каналы не попадают.
|
|||
|
|
6. Пагинация: ~20 каналов на страницу (DEFAULT_LIMIT 20, max 100) + кнопка «Показать ещё»; граница «каналы с видео → каналы без видео» не теряет и не дублирует каналы.
|
|||
|
|
7. Пустое состояние режима каналов (нет подписок) с подсказкой и ссылкой на `/channels`; при идущем синке — состояние «Импортируем каналы». Ошибка загрузки — с кнопкой «Повторить».
|
|||
|
|
8. Страница `/channels` (управление) не изменена и работает как раньше.
|
|||
|
|
9. Backend `GET /api/channels/activity` отвечает 200 (не 422 — проверка порядка маршрутов), `GET /api/channels/{id}` продолжает работать; невалидный курсор → 400.
|
|||
|
|
10. `pytest` (включая новый `tests/test_channel_activity.py`), `npm run lint`, `npm run build` — зелёные; деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
|
|||
|
|
|
|||
|
|
## План
|
|||
|
|
|
|||
|
|
1. **Backend — `GET /api/channels/activity`** (`backend/app/api/channels.py`):
|
|||
|
|
- Параметры: `category_id: int | None`, `uncategorized: bool = False`, `limit: int = Query(20, ge=1, le=100)`, `cursor: str | None`.
|
|||
|
|
- Подзапрос последнего видео: `select(Video.channel_id, func.max(Video.published_at).label('last_published_at')).group_by(Video.channel_id).subquery()`, `db.query(Channel, sub.c.last_published_at).outerjoin(...)`.
|
|||
|
|
- Фильтры: `Channel.subscribed.is_(True)` всегда; `uncategorized` → `~Channel.id.in_(select(channel_categories.c.channel_id))`; иначе `category_id` → `Channel.id.in_(select(...).where(category_id == ...))` (паттерн как в `list_channels`).
|
|||
|
|
- Сортировка: `last_published_at.desc().nullslast(), Channel.id.desc()`.
|
|||
|
|
- Курсор: base64 `"{iso}|{channel_id}"`, `iso` пустой для каналов без видео. Условие: для непустого `iso` — `(last_published_at < pub) | ((last_published_at == pub) & (Channel.id < cid))`; для пустого — `last_published_at.is_(None) & (Channel.id < cid)`. Забрать `limit + 1` строк, отрезать `next_cursor` как в feed.
|
|||
|
|
- Видео для страницы: один запрос с `row_number() OVER (PARTITION BY video.channel_id ORDER BY video.published_at DESC, video.id DESC)`, фильтр `rn <= 3` по id каналов страницы.
|
|||
|
|
- Сериализация: `_serialize(channel, category_map, new_videos_count)` (уже есть) + `videos: [serialize_video(v, channel, categories, job) ...]` через `channel_categories_map` и `latest_jobs_map` — DTO видео идентичен ленте. Ответ: `{"items": [...], "next_cursor": ...}`.
|
|||
|
|
- Курсорные хелперы `_encode_cursor/_decode_cursor` из `feed.py` приватные и заточены под видео — в `channels.py` сделать локальные версии под `(datetime | None, channel_id)`; общий модуль не выделяем, чтобы не трогать `feed.py`.
|
|||
|
|
2. **Frontend — API** (`client.ts`): `ChannelActivityDto`, `getChannelActivity`; кодирование query-параметров как в `getFeed`.
|
|||
|
|
3. **Frontend — компоненты**: `CompactVideoCard.tsx`, `ChannelActivityList.tsx` (queryKey: `['channel-activity', location.pathname, categoryNumber ?? null, isUncategorized]`; `getNextPageParam` по `next_cursor`; скелетоны/пусто/ошибка/«Показать ещё» в паттернах Feed.tsx).
|
|||
|
|
4. **Feed.tsx**: `const isChannelsView = searchParams.get('view') === 'channels'`; переключатель рендерится при `!isLocal && !search`; при `isChannelsView` — рендер `ChannelActivityList` вместо сетки; подписи/`title` оставить; в `useEffect` по `videos.finished_at` дополнительно `invalidateQueries(['channel-activity'])`; mobile-category-nav — ссылки с сохранением `view`.
|
|||
|
|
5. **AppShell.tsx (Sidebar)**: `useLocation`; `to={{ pathname: '/', search: viewQuery }}` и аналогично для категорий (через `URLSearchParams`); badge-ссылки `?new=1` оставить без `view` (они ведут в ленту «только новые» — feed-специфичны).
|
|||
|
|
6. **App.css**: `.view-toggle` (сегментированный контроль в стиле `.category-nav`/`.local-filter-nav`), строки каналов (грид `repeat(3, minmax(0, 1fr))` для видео), компактные карточки, media-правила ≤900/≤620px.
|
|||
|
|
7. **Тесты** `tests/test_channel_activity.py` (фикстура client как в test_channels, сиды через Channel/Video/Category/channel_categories):
|
|||
|
|
- порядок по свежести последнего видео (каналы вперемешку по датам);
|
|||
|
|
- каналы без видео в конце, после всех «с видео»;
|
|||
|
|
- ровно 3 последних видео на канал в правильном порядке;
|
|||
|
|
- фильтр `category_id` и `uncategorized`; только `subscribed`;
|
|||
|
|
- курсорная пагинация, включая переход через границу «с видео → без видео» (без дублей/потерь);
|
|||
|
|
- shape: поля канала + `videos` ≤ 3, поля видео как в `test_feed_item_shape`;
|
|||
|
|
- `GET /api/channels/activity` → 200 (не 422) и `GET /api/channels/{id}` → 200 (порядок маршрутов); invalid cursor → 400; `limit` вне границ → 422.
|
|||
|
|
8. **Проверка**: `pytest`, `npm run lint`, `npm run build`; ручная проверка сценариев (переключение на всех трёх маршрутах; сохранение view при смене категории в сайдбаре и на мобильном меню; refresh; пагинация; пустое состояние). Деплой по правилу команды.
|
|||
|
|
|
|||
|
|
## Риски и ограничения
|
|||
|
|
|
|||
|
|
- **Порядок маршрутов**: `/channels/activity` обязано быть объявлено до `/channels/{channel_id}`; тест-кейс закрывает регрессию.
|
|||
|
|
- **Курсор с NULL-хвостом**: sentinel «пустой iso» усложняет `_decode_cursor`; граничный тест обязателен. SQLite в тестах (Python 3.13, SQLite ≥ 3.45) поддерживает `NULLS LAST` и оконные функции — совместимо.
|
|||
|
|
- **Feed.tsx растёт**: держим разметку режима каналов в `ChannelActivityList`, в `Feed.tsx` только ветвление; VideoCard не перегружаем.
|
|||
|
|
- **Инвалидация кэша**: `ChannelCard` (страница `/channels`) при смене категорий каналов инвалидирует `['feed']`/`['channels']`/`['categories']`, но не `['channel-activity']` — добавить туда же, иначе строки режима каналов устаревают после изменения категорий.
|
|||
|
|
- **`?new=1` и `view`**: `new_only` — feed-специфичный фильтр; badge в сайдбаре всегда ведёт в ленту. Переключатель «Каналы» с URL `?new=1` сбрасывает `new` (строим `{pathname}?view=channels`), эндпоинт активности его не принимает. В обратную сторону «Лента» даёт `{pathname}` без параметров.
|
|||
|
|
- **Производительность**: 3 запроса на страницу (каналы, видео через оконную функцию, категории+джобы) — приемлемо для single-user; при необходимости окно видео можно ограничить `limit`'ом страницы.
|
|||
|
|
- **Серийная форма видео** в строке включает `local` (из `latest_jobs_map`) — фронт его в компактных карточках не показывает; DTO единый с лентой намеренно (переиспользование типа и потенциальный mobile-клиент).
|
|||
|
|
|
|||
|
|
## Журнал изменений
|
|||
|
|
|
|||
|
|
- 2026-09-18: документ создан перед реализацией. Зафиксированы решения: переключатель на `/`, `/category/:id`, `/uncategorized` (на `/local` и `/search` — нет); режим в URL `?view=channels`; сайдбар сохраняет view; эндпоинт `GET /api/channels/activity`; курсорная пагинация `(last_published_at, channel_id)` с NULL-хвостом; компактные карточки без DownloadButton (миниатюра + длительность + название + относительное время).
|