New GET /api/channels/activity returns subscribed channels sorted by their latest video (no-video channels last) with the 3 newest videos per channel and cursor pagination. The feed page gains a 'Лента | Каналы' toggle (?view=channels) with compact video cards per channel row; category filters and the sidebar preserve the view.
17 KiB
17 KiB
Режим «Каналы» на главной: переключатель «Лента | Каналы» и эндпоинт активности каналов
Задача
Добавить на главную страницу переключатель «Лента | Каналы». Режим «Лента» остаётся как сейчас. Режим «Каналы» — вместо сетки видео список подписанных каналов, отсортированный по свежести последнего видео; у каждого канала — три последних видео в одну широкую строку (компактные карточки). Фильтрация категориями из левого сайдбара действует и в режиме каналов. Режим задаётся 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; DTOFeedVideoDtoуже типизован в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.
Критерии приёмки
- Переключатель «Лента | Каналы» виден на
/,/category/:id,/uncategorized; на/localи/searchего нет,?view=channelsтам игнорируется (остаётся лента). Активный режим визуально выделен. - Режим в URL:
?view=channels; переключение сохраняет текущий маршрут; refresh сохраняет режим. Ссылки категорий и «Все видео» в сайдбаре и в mobile-category-nav переносят текущийviewпри переключении категории; обратный переключатель на «Ленту» сбрасывает параметры ({pathname}без query). - Список каналов отсортирован по дате последнего видео (desc); каналы без видео — в конце. Строка: аватар + название (ссылка на
/channels/{id}/videos) + 3 последних видео канала в одну широкую строку. - Компактная карточка: миниатюра + длительность + название + относительное время; клик открывает страницу видео. Кнопок скачивания в строке нет.
- Фильтр категории применяется в режиме каналов:
/category/:id?view=channels— только каналы категории;/uncategorized?view=channels— только каналы без категорий;/— все подписанные. Отписанные каналы не попадают. - Пагинация: ~20 каналов на страницу (DEFAULT_LIMIT 20, max 100) + кнопка «Показать ещё»; граница «каналы с видео → каналы без видео» не теряет и не дублирует каналы.
- Пустое состояние режима каналов (нет подписок) с подсказкой и ссылкой на
/channels; при идущем синке — состояние «Импортируем каналы». Ошибка загрузки — с кнопкой «Повторить». - Страница
/channels(управление) не изменена и работает как раньше. - Backend
GET /api/channels/activityотвечает 200 (не 422 — проверка порядка маршрутов),GET /api/channels/{id}продолжает работать; невалидный курсор → 400. pytest(включая новыйtests/test_channel_activity.py),npm run lint,npm run build— зелёные; деплой по правилу команды:docker compose up -d --build,curl http://localhost:8080/api/health→ OK.
План
- 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.
- Параметры:
- Frontend — API (
client.ts):ChannelActivityDto,getChannelActivity; кодирование query-параметров как вgetFeed. - Frontend — компоненты:
CompactVideoCard.tsx,ChannelActivityList.tsx(queryKey:['channel-activity', location.pathname, categoryNumber ?? null, isUncategorized];getNextPageParamпоnext_cursor; скелетоны/пусто/ошибка/«Показать ещё» в паттернах Feed.tsx). - Feed.tsx:
const isChannelsView = searchParams.get('view') === 'channels'; переключатель рендерится при!isLocal && !search; приisChannelsView— рендерChannelActivityListвместо сетки; подписи/titleоставить; вuseEffectпоvideos.finished_atдополнительноinvalidateQueries(['channel-activity']); mobile-category-nav — ссылки с сохранениемview. - AppShell.tsx (Sidebar):
useLocation;to={{ pathname: '/', search: viewQuery }}и аналогично для категорий (черезURLSearchParams); badge-ссылки?new=1оставить безview(они ведут в ленту «только новые» — feed-специфичны). - App.css:
.view-toggle(сегментированный контроль в стиле.category-nav/.local-filter-nav), строки каналов (гридrepeat(3, minmax(0, 1fr))для видео), компактные карточки, media-правила ≤900/≤620px. - Тесты
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.
- Проверка:
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 (миниатюра + длительность + название + относительное время).