myYouTube/analytics/2026-09-17-channels-view-toggle.md
vrubelroman d4950f6979 Add channels view toggle on the main page
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.
2026-09-18 12:13:32 +00:00

17 KiB
Raw Blame History

Режим «Каналы» на главной: переключатель «Лента | Каналы» и эндпоинт активности каналов

Задача

Добавить на главную страницу переключатель «Лента | Каналы». Режим «Лента» остаётся как сейчас. Режим «Каналы» — вместо сетки видео список подписанных каналов, отсортированный по свежести последнего видео; у каждого канала — три последних видео в одну широкую строку (компактные карточки). Фильтрация категориями из левого сайдбара действует и в режиме каналов. Режим задаётся 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 (миниатюра + длительность + название + относительное время).