Compare commits

..

No commits in common. "d4950f69794bf9a1cc770c1f866d4a60dfb8e61d" and "5fa5a391d70e3d894830414d347bda23a8d5df3b" have entirely different histories.

14 changed files with 35 additions and 815 deletions

View file

@ -8,7 +8,6 @@
### Что сделано
- Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта).
- Режим «Каналы» на главной (`?view=channels`): переключатель «Лента | Каналы», список подписанных каналов по свежести последнего видео с тремя последними роликами в строке; фильтр категорий из сайдбара действует в обоих режимах.
- Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`.
- Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню.
- Отписанные каналы скрываются из списка каналов.

View file

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

View file

@ -1,59 +0,0 @@
# Перенос ручного обновления синков в кнопку «Обновить всё» (попап SyncIndicator)
## Задача
Собрать ручное обновление синков в единую кнопку «Обновить всё» в попапе индикатора синхронизации в шапке (`SyncIndicator` в `AppShell`). Убрать кнопки «Обновить» из заголовков страниц «Лента» (`Feed.tsx`) и «Каналы» (`Channels.tsx`). Backend не менять.
## Контекст
- Кнопка в ленте вызывает `POST /api/sync/videos` — глобальный синк всех каналов, а не текущей категории; вводит в заблуждение. Глобальное действие семантически принадлежит глобальному индикатору «Обновлено X назад» в шапке.
- Порядок «подписки → видео» важен: `sync_subscriptions` заполняет `uploads_playlist_id` новых каналов, а `sync_videos` выбирает только каналы с непустым `uploads_playlist_id` (`backend/app/services/sync.py:169-173`) — новые каналы сразу получают видео.
- Автотриггер: каждый аутентифицированный запрос (`require_session` → `maybe_trigger_videos_sync`) может запустить фоновый синк видео (`backend/app/services/sync_trigger.py`). Важно: сам `POST /api/sync/subscriptions` тоже проходит через `require_session`, поэтому сразу после клика «Обновить всё» видео-синк может уже стартовать в фоне, и следующий `POST /api/sync/videos` вернёт 409 — это штатный сценарий, не ошибка.
- Поведение backend при уже идущем синке: `sync.py` бросает `SyncInProgress`, API отдаёт **409** (`backend/app/api/sync.py`), detail: `"Subscriptions sync already in progress"` / `"Videos sync already in progress"`. Фронтовый `request()` кидает `Error(detail)` (`frontend/src/api/client.ts:27`).
- Поллинг статуса уже есть: query `['sync-status']` с `refetchInterval` 2 с при `running`, иначе 60 с (`AppShell.tsx:13`). `Feed.tsx` и `Channels.tsx` инвалидируют `['feed']` / `['channels']` в `useEffect` при смене `finished_at` — эта механика не зависит от того, откуда запущен синк, и продолжит работать после переноса кнопки.
- Синк подписок может быть занят и без пользователя: он запускается по расписанию (`backend/app/services/scheduler.py:15`).
- Backend не трогаем: `POST /api/sync/subscriptions` и `POST /api/sync/videos` остаются — пригодятся API-клиентам.
## Затронутые подсистемы и файлы
- `frontend/src/components/AppShell.tsx` — `SyncIndicator`: кнопка «Обновить всё» в попапе, последовательный запуск подписки → видео, толерантность к 409, disabled/спиннер, показ ошибки в попапе, инвалидация `['sync-status']`.
- `frontend/src/pages/Feed.tsx` — удалить `syncMutation`, кнопку (строка 84) и notice ошибки мутации (строка 97); сохранить notice по `sync-status` «Последнее обновление видео завершилось ошибкой…» (строка 98) и оба `useEffect`-инвалидации.
- `frontend/src/pages/Channels.tsx` — удалить `syncMutation` (строка 20), кнопку в заголовке (строка 37) и notice ошибки мутации (строка 38); сохранить notice о `failed` (строка 39); заменить подсказку пустого состояния (строка 45) на указание на индикатор в шапке.
- `frontend/src/api/client.ts` — `syncSubscriptions`/`syncVideos` начнёт использовать `AppShell`; сигнатуры не меняются.
- `frontend/src/App.css` — стиль кнопки внутри `.sync-popover`; удалить неиспользуемые правила `.refresh-button` (media query, строки 248–249), если после чистки не останется использований.
- Backend — не изменяется.
## Критерии приёмки
1. Единственная кнопка ручного обновления — «Обновить всё» в попапе `SyncIndicator` (шапка).
2. Запуск строго последовательный: сначала `POST /api/sync/subscriptions`, затем `POST /api/sync/videos`.
3. 409 при запуске видео-синка (в т.ч. из-за автотриггера) — не ошибка: повторный запуск пропускается, состояние показывает поллинг; попап не показывает ошибку в этом случае.
4. Кнопка disabled со спиннером, пока идёт мутация или любой из синков `running` (по `sync-status`); прочие ошибки (400/502/503/сеть) показываются в попапе.
5. `Feed.tsx` и `Channels.tsx` очищены: нет кнопок «Обновить», нет мутаций/вызовов `syncVideos`/`syncSubscriptions`, нет notice ошибок мутаций; notice «…завершилось ошибкой» (по `sync-status`) и `useEffect`-инвалидации кэша по `finished_at` сохранены и работают.
6. Пустое состояние «Каналы» ссылается на индикатор в шапке (текст с «Обновить всё»).
7. `npm run lint` и `npm run build` чистые; `git diff` не затрагивает `backend/`; `pytest` зелёный.
8. Деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK.
## План
1. `AppShell.tsx` (SyncIndicator):
- `useMutation` с `mutationFn`: `await syncSubscriptions()`, затем `await syncVideos()`; при ошибке видео, содержащей `already in progress` (409), «проглотить» и не считать ошибкой; 409 подписок — аналогично не ошибка.
- Кнопка «Обновить всё» в попапе: `disabled` при `isPending` или `data?.videos.running || data?.subscriptions.running`, спиннер при pending/running.
- `onSettled`: `invalidateQueries(['sync-status'])`; блок ошибки мутации в попапе (`role="status"` у попапа при наличии кнопки стоит пересмотреть: интерактивный элемент внутри status-региона — вынести кнопку или убрать role).
2. `Feed.tsx`: удалить `syncMutation`, кнопку, notice мутации и импорты `useMutation`/`syncVideos`; оставить `syncStatusQuery`, оба `useEffect`, notice о `failed`.
3. `Channels.tsx`: то же + новый текст подсказки пустого состояния со ссылкой на индикатор в шапке.
4. `App.css`: стили кнопки в попапе; вычистить `.refresh-button`.
5. Проверка: `npm run lint`, `npm run build`, `pytest`; ручная проверка сценариев (клик → подписки затем видео; повторный клик при идущем видео-синке не даёт ошибки; статусы обновляются поллингом; notice о failed остаётся на страницах).
6. Деплой по правилу команды.
## Риски и ограничения
- Распознавание 409 по тексту `Error.message` (`already in progress`) — хрупко, если backend изменит detail; приемлемо, т.к. backend не меняется. Альтернатива — типизованные ошибки в `client.ts`, но без изменения API.
- 409 подписок (фоновый часовой синк): трактовать симметрично (пропустить, поллинг покажет статус). Если при этом продолжить и запускать видео-синк, он может не увидеть каналы, чьи плейлисты ещё импортируются — приемлемо, следующий синк подхватит.
- После удаления кнопки из `page-heading` правая часть заголовков страниц опустеет — проверить, что вёрстка не ломается (внутренний `div` с заголовком остаётся).
- Попап закрывается по клику вне `ref`; кнопка внутри попапа не должна его закрывать — существующий обработчик это уже учитывает.
- Мобильная вёрстка: попап имеет адаптивное правило (`App.css:278`), кнопка должна быть удобной на узких экранах.
## Журнал изменений
- 2026-09-18: документ создан перед реализацией (первичный спек, backend не меняется).

View file

@ -1,8 +1,7 @@
import base64
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@ -15,19 +14,13 @@ from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.video import Video
from app.services import sync
from app.services.download_jobs import latest_jobs_map
from app.services.google_oauth import OAuthNotConnected
from app.services.video_presentation import channel_categories_map, serialize_video
from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_session)])
ACTIVITY_DEFAULT_LIMIT = 20
ACTIVITY_MAX_LIMIT = 100
ACTIVITY_VIDEOS_PER_CHANNEL = 3
class ChannelCategoriesUpdate(BaseModel):
category_ids: list[int]
@ -108,131 +101,6 @@ def list_channels(
return [_serialize(c, category_map.get(c.id, []), new_videos_map.get(c.id, 0)) for c in channels]
def _encode_activity_cursor(last_published_at: datetime | None, channel_id: int) -> str:
raw = f"{last_published_at.isoformat() if last_published_at else ''}|{channel_id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def _decode_activity_cursor(cursor: str) -> tuple[datetime | None, int]:
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
iso_raw, channel_id_raw = raw.rsplit("|", 1)
published_at = datetime.fromisoformat(iso_raw) if iso_raw else None
if published_at is not None and published_at.tzinfo is None:
published_at = published_at.replace(tzinfo=timezone.utc)
return published_at, int(channel_id_raw)
except Exception:
raise HTTPException(status_code=400, detail="Invalid cursor")
# NOTE: this route must stay declared BEFORE /channels/{channel_id}, otherwise
# "activity" is captured by the path parameter and validation fails with 422.
@router.get("/channels/activity")
def channel_activity(
category_id: int | None = None,
uncategorized: bool = False,
limit: int = Query(ACTIVITY_DEFAULT_LIMIT, ge=1, le=ACTIVITY_MAX_LIMIT),
cursor: str | None = None,
db: Session = Depends(get_db),
) -> dict:
"""Subscribed channels ordered by the freshness of their latest video
(channels without videos go last), with up to 3 recent videos each.
Cursor is (last_published_at, channel_id); an empty iso marks the
no-videos tail so pagination stays lossless across that boundary."""
latest_video = (
select(Video.channel_id, func.max(Video.published_at).label("last_published_at"))
.group_by(Video.channel_id)
.subquery()
)
query = db.query(Channel, latest_video.c.last_published_at).outerjoin(
latest_video, latest_video.c.channel_id == Channel.id
)
query = query.filter(Channel.subscribed.is_(True))
if uncategorized:
categorized_ids = select(channel_categories.c.channel_id)
query = query.filter(~Channel.id.in_(categorized_ids))
elif category_id is not None:
channel_ids_in_category = select(channel_categories.c.channel_id).where(
channel_categories.c.category_id == category_id
)
query = query.filter(Channel.id.in_(channel_ids_in_category))
if cursor:
cursor_published_at, cursor_channel_id = _decode_activity_cursor(cursor)
if cursor_published_at is not None:
# Channels without videos sort last (NULLS LAST), so they are
# also "after" this cursor and must pass the filter; the ORDER BY
# keeps them behind every channel that still has videos.
query = query.filter(
(latest_video.c.last_published_at < cursor_published_at)
| (
(latest_video.c.last_published_at == cursor_published_at)
& (Channel.id < cursor_channel_id)
)
| latest_video.c.last_published_at.is_(None)
)
else:
query = query.filter(
latest_video.c.last_published_at.is_(None) & (Channel.id < cursor_channel_id)
)
rows = (
query.order_by(latest_video.c.last_published_at.desc().nullslast(), Channel.id.desc())
.limit(limit + 1)
.all()
)
next_cursor = None
if len(rows) > limit:
last_channel, last_published_at = rows[limit - 1]
next_cursor = _encode_activity_cursor(last_published_at, last_channel.id)
rows = rows[:limit]
channels_by_id = {channel.id: channel for channel, _ in rows}
channel_ids = list(channels_by_id)
videos_by_channel: dict[int, list[Video]] = {channel_id: [] for channel_id in channel_ids}
if channel_ids:
# Top-N videos per channel in a single windowed query (no N+1).
row_number = func.row_number().over(
partition_by=Video.channel_id,
order_by=(Video.published_at.desc(), Video.id.desc()),
).label("rn")
windowed = (
db.query(Video.id, row_number)
.filter(Video.channel_id.in_(channel_ids))
.subquery()
)
recent_ids = select(windowed.c.id).where(windowed.c.rn <= ACTIVITY_VIDEOS_PER_CHANNEL)
videos = db.query(Video).filter(Video.id.in_(recent_ids)).all()
for video in videos:
videos_by_channel[video.channel_id].append(video)
for channel_videos in videos_by_channel.values():
channel_videos.sort(key=lambda video: (video.published_at, video.id), reverse=True)
categories_map = channel_categories_map(db, channel_ids)
jobs_map = latest_jobs_map(db, [video.id for videos in videos_by_channel.values() for video in videos])
new_videos_map = _new_videos_counts(db, channel_ids)
items = []
for channel, _ in rows:
categories = categories_map.get(channel.id, [])
items.append(
{
"channel": _serialize(
channel, [category["id"] for category in categories], new_videos_map.get(channel.id, 0)
),
"videos": [
serialize_video(video, channel, categories, jobs_map.get(video.id))
for video in videos_by_channel.get(channel.id, [])
],
}
)
return {"items": items, "next_cursor": next_cursor}
@router.get("/channels/{channel_id}")
def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
channel = db.get(Channel, channel_id)

View file

@ -20,8 +20,6 @@
.sync-popover { position: absolute; top: calc(100% + 12px); right: 0; width: 280px; padding: 17px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); box-shadow: 0 16px 50px rgba(0,0,0,.5); font-size: 13px; color: var(--muted); }
.sync-popover strong { display: block; color: var(--text); margin-bottom: 12px; }
.sync-popover p + p { margin-top: 9px; }
.sync-popover .sync-all-button { width: 100%; min-height: 40px; margin-top: 14px; }
.sync-popover .sync-all-button + .error { margin-top: 9px; }
.mobile-menu, .drawer-header, .drawer-backdrop { display: none; }
.app-sidebar { position: fixed; z-index: 30; top: 66px; left: 0; bottom: 0; width: 244px; overflow-y: auto; border-right: 1px solid var(--border); background: var(--bg); }
.sidebar-nav { display: flex; flex-direction: column; min-height: 100%; padding: 20px 12px; }
@ -97,27 +95,6 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
.download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); }
.download-action-error { color: var(--error); font-size: 12px; }
.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); }
.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 a:hover { color: var(--text); background: var(--hover); }
.view-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-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 img, .channel-activity-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; object-fit: cover; }
.channel-activity-avatar { display: grid; place-items: center; background: #344557; color: var(--text); font-size: 17px; font-weight: 700; }
.channel-activity-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 650; }
.channel-activity-identity:hover .channel-activity-name { color: var(--accent); }
.channel-activity-videos { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; min-width: 0; }
.channel-activity-empty-videos { margin: 0; align-self: center; color: var(--subtle); font-size: 12px; }
.compact-video-card { min-width: 0; display: flex; flex-direction: column; }
.compact-thumbnail { position: relative; display: block; overflow: hidden; width: 100%; aspect-ratio: 16 / 9; border-radius: 9px; background: var(--surface-raised); }
.compact-thumbnail img, .compact-thumbnail .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; }
.compact-video-title { margin-top: 7px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--text); text-decoration: none; font-size: 13px; font-weight: 600; line-height: 1.42; }
.compact-video-title:hover { color: var(--accent); }
.compact-video-time { margin-top: 4px; color: var(--muted); font-size: 11px; }
.skeleton-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; background: linear-gradient(90deg, #222 25%, #303030 50%, #222 75%); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; }
.channel-activity-skeleton .skeleton-line { margin: 0; }
.empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); }
.empty-state h2 { margin: 0 0 9px; font-size: 20px; }
.empty-state p { font-size: 14px; line-height: 1.55; margin-bottom: 20px; }
@ -254,8 +231,6 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
.mobile-category-nav { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -5px -28px 25px; padding: 0 28px 5px; }
.mobile-category-nav a { flex: none; min-height: 35px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 0 14px; background: var(--surface); color: var(--muted); font-size: 12px; text-decoration: none; }
.mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); }
.channel-activity-row { grid-template-columns: 1fr; gap: 12px; padding-bottom: 21px; }
.channel-activity-identity { padding-top: 0; }
}
@media (max-width: 620px) {
.topbar { height: 112px; display: grid; grid-template-columns: 44px 1fr auto; grid-template-rows: 55px 49px; column-gap: 7px; row-gap: 0; padding: 0 15px 8px; }
@ -270,9 +245,9 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
.page { padding: 24px 16px 58px; }
.page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
.page-heading h1 { font-size: 27px; }
.view-toggle a { min-height: 38px; padding: 0 12px; }
.refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
.refresh-button svg { flex: none; }
.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)); }
.video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
.video-thumbnail { border-radius: 9px; }
.video-info { padding: 10px 2px 0; }
@ -306,7 +281,6 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt
@media (pointer: coarse) {
.video-actions .button-link, .video-actions .button-secondary, .download-badge, .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; }
.view-toggle a { min-height: 44px; }
.reorder-buttons .icon-button { width: 40px; height: 40px; }
.popover-create input, .popover-create button { height: 44px; }
.popover-create button { width: 44px; }

View file

@ -81,23 +81,6 @@ export function listChannels(
return request<ChannelDto[]>(`/api/channels${suffix}`)
}
export interface ChannelActivityItemDto {
channel: ChannelDto
videos: FeedVideoDto[]
}
export function getChannelActivity(
params: { categoryId?: number; uncategorized?: boolean; cursor?: string; limit?: number } = {},
) {
const qs = new URLSearchParams()
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.cursor) qs.set('cursor', params.cursor)
if (params.limit != null) qs.set('limit', String(params.limit))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return request<{ items: ChannelActivityItemDto[]; next_cursor: string | null }>(`/api/channels/activity${suffix}`)
}
export function setChannelCategories(channelId: number, categoryIds: number[]) {
return request<ChannelDto>(`/api/channels/${channelId}/categories`, {
method: 'PUT',

View file

@ -1,35 +1,17 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
import { getSyncStatus, listCategories, syncSubscriptions, syncVideos } from '../api/client'
import { getSyncStatus, listCategories } from '../api/client'
import { formatRelativeTime } from '../utils/format'
import Icon from './Icon'
function SyncIndicator() {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient()
const { data } = useQuery({
queryKey: ['sync-status'], queryFn: getSyncStatus,
refetchInterval: (query) => query.state.data?.videos.running || query.state.data?.subscriptions.running ? 2000 : 60_000,
})
const syncAllMutation = useMutation({
mutationFn: async () => {
const ignoreInProgress = (error: unknown) => error instanceof Error && error.message.includes('already in progress')
try {
await syncSubscriptions()
} catch (error) {
if (!ignoreInProgress(error)) throw error
}
try {
await syncVideos()
} catch (error) {
if (!ignoreInProgress(error)) throw error
}
},
onMutate: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }) },
onSettled: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }) },
})
useEffect(() => {
if (!open) return
const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) }
@ -37,7 +19,6 @@ function SyncIndicator() {
return () => document.removeEventListener('mousedown', close)
}, [open])
const running = data?.videos.running || data?.subscriptions.running
const busy = syncAllMutation.isPending || running
const failed = data?.videos.status === 'failed' || data?.subscriptions.status === 'failed'
const recent = [data?.videos.finished_at, data?.subscriptions.finished_at].filter((value): value is string => Boolean(value)).sort().at(-1)
const label = running ? 'Обновление...' : failed ? 'Ошибка синхронизации' : recent ? `Обновлено ${formatRelativeTime(recent)}` : 'Синхронизация'
@ -46,16 +27,11 @@ function SyncIndicator() {
<Icon name={failed ? 'alert' : running ? 'refresh' : 'check'} size={17} className={running ? 'spin' : ''} />
<span>{label}</span>
</button>
{open && <div className="sync-popover">
{open && <div className="sync-popover" role="status">
<strong>Синхронизация</strong>
<p>Подписки: {data?.subscriptions.running ? 'обновляются' : data?.subscriptions.finished_at ? formatRelativeTime(data.subscriptions.finished_at) : 'ещё не обновлялись'}</p>
<p>Видео: {data?.videos.running ? 'обновляются' : data?.videos.finished_at ? formatRelativeTime(data.videos.finished_at) : 'ещё не обновлялись'}</p>
{failed && <p className="error">Последнее обновление завершилось ошибкой. Подробности в настройках.</p>}
<button type="button" className="button-secondary sync-all-button" onClick={() => syncAllMutation.mutate()} disabled={busy}>
<Icon name="refresh" size={17} className={busy ? 'spin' : ''} />
{busy ? 'Обновляем…' : 'Обновить всё'}
</button>
{syncAllMutation.isError && <p className="error" role="alert">Не удалось запустить обновление. Попробуйте позже.</p>}
</div>}
</div>
}
@ -63,12 +39,8 @@ function SyncIndicator() {
function Sidebar({ close }: { close: () => void }) {
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? []
const location = useLocation()
// Keep the current view mode ("Лента | Каналы") when switching between
// "Все видео" and category links; badges (?new=1) are feed-specific.
const viewQuery = new URLSearchParams(location.search).get('view') === 'channels' ? '?view=channels' : ''
const links = [
{ to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true },
{ to: '/', icon: 'home' as const, text: 'Все видео', end: true },
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
]
return <nav className="sidebar-nav" aria-label="Основная навигация">
@ -78,7 +50,7 @@ function Sidebar({ close }: { close: () => void }) {
<div className="sidebar-group sidebar-categories">
<div className="sidebar-heading">Категории</div>
{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}`} 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>}
</div>)}
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}

View file

@ -1,81 +0,0 @@
import { useEffect } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { Link, useLocation } from 'react-router-dom'
import { getChannelActivity } from '../api/client'
import CompactVideoCard from './CompactVideoCard'
import Icon from './Icon'
interface Props {
categoryId?: number
uncategorized?: boolean
syncRunning: boolean
}
function RowSkeleton() {
return <li className="channel-activity-row channel-activity-skeleton" aria-hidden="true">
<div className="channel-activity-identity"><span className="skeleton-avatar" /><span className="skeleton-line" /></div>
<div className="channel-activity-videos">
{Array.from({ length: 3 }, (_, index) => <div key={index} className="compact-video-card"><div className="skeleton-thumbnail" /><div className="skeleton-line short" /></div>)}
</div>
</li>
}
function ChannelActivityList({ categoryId, uncategorized, syncRunning }: Props) {
const location = useLocation()
const query = useInfiniteQuery({
queryKey: ['channel-activity', location.pathname, categoryId ?? null, uncategorized],
queryFn: ({ pageParam }) => getChannelActivity({ categoryId, uncategorized, cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
})
const items = query.data?.pages.flatMap((page) => page.items) ?? []
useEffect(() => {
if (query.isLoading) return
const key = `feed-scroll:${location.pathname + location.search}`
const saved = sessionStorage.getItem(key)
if (saved) {
sessionStorage.removeItem(key)
requestAnimationFrame(() => window.scrollTo(0, Number(saved)))
}
}, [query.isLoading, location.pathname, location.search])
if (query.isLoading) {
return <ul className="channel-activity-list">{Array.from({ length: 5 }, (_, index) => <RowSkeleton key={index} />)}</ul>
}
if (query.isError) {
return <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить каналы</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => query.refetch()}>Повторить</button></div>
}
if (items.length === 0) {
const isAll = categoryId === undefined && !uncategorized
return <div className="empty-state">
<span className="empty-icon"><Icon name={syncRunning ? 'refresh' : isAll ? 'users' : 'folder'} size={28} /></span>
<h2>{syncRunning ? 'Импортируем каналы' : isAll ? 'Пока нет каналов' : uncategorized ? 'Все каналы распределены' : 'В этой категории нет каналов'}</h2>
<p>{syncRunning ? 'Подписки и лента обновляются. Каналы появятся здесь автоматически.' : isAll ? 'Подпишись на интересные каналы, чтобы видеть их последние видео.' : uncategorized ? 'Каналы без категории появятся здесь, как только снимешь с них все темы.' : 'Добавь каналы в эту категорию, чтобы видеть их здесь.'}</p>
{isAll && !syncRunning && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}
</div>
}
return <>
<ul className="channel-activity-list">
{items.map((item) => (
<li className="channel-activity-row" key={item.channel.id}>
<Link to={`/channels/${item.channel.id}/videos`} className="channel-activity-identity" aria-label={`Канал: ${item.channel.title}`}>
{item.channel.thumbnail_url ? <img src={item.channel.thumbnail_url} alt="" loading="lazy" /> : <span className="channel-activity-avatar">{item.channel.title.charAt(0).toUpperCase()}</span>}
<span className="channel-activity-name" title={item.channel.title}>{item.channel.title}</span>
</Link>
<div className="channel-activity-videos">
{item.videos.length > 0
? item.videos.map((video) => <CompactVideoCard key={video.youtube_video_id} video={video} />)
: <p className="channel-activity-empty-videos">Нет видео</p>}
</div>
</li>
))}
</ul>
{query.hasNextPage && <button className="button-secondary load-more" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>{query.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</>
}
export default ChannelActivityList

View file

@ -46,7 +46,6 @@ function ChannelCard({ channel, categories }: Props) {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['categories'] })
queryClient.invalidateQueries({ queryKey: ['feed'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
},
})
@ -54,7 +53,6 @@ function ChannelCard({ channel, categories }: Props) {
mutationFn: () => unsubscribeChannel(channel.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
},
})

View file

@ -1,34 +0,0 @@
import { Link, useLocation } from 'react-router-dom'
import type { FeedVideoDto } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format'
interface Props {
video: FeedVideoDto
}
function CompactVideoCard({ video }: Props) {
const duration = formatDuration(video.duration_seconds)
const href = `/video/${video.youtube_video_id}`
const location = useLocation()
const from = location.pathname + location.search
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))
return (
<div className="compact-video-card">
<Link to={href} state={{ from }} onClick={rememberScroll} className="compact-thumbnail" aria-label={`Смотреть: ${video.title}`}>
{video.thumbnail_url ? (
<img src={video.thumbnail_url} alt="" loading="lazy" />
) : (
<div className="video-thumbnail-placeholder" />
)}
{duration && <span className="duration-badge">{duration}</span>}
</Link>
<Link to={href} state={{ from }} onClick={rememberScroll} className="compact-video-title" title={video.title}>
{video.title}
</Link>
<span className="compact-video-time">{formatRelativeTime(video.published_at)}</span>
</div>
)
}
export default CompactVideoCard

View file

@ -13,7 +13,7 @@ function Categories() {
const cancelDeleteRef = useRef<HTMLButtonElement>(null)
useEffect(() => { if (deletingId !== null) cancelDeleteRef.current?.focus() }, [deletingId])
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }); queryClient.invalidateQueries({ queryKey: ['channel-activity'] }) }
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) }
const createMutation = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewName(''); invalidate() } })
const updateMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), onSuccess: () => { setEditingId(null); invalidate() } })
const deleteMutation = useMutation({ mutationFn: deleteCategory, onSuccess: () => { setDeletingId(null); invalidate() } })

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { getSyncStatus, listCategories, listChannels } from '../api/client'
import { getSyncStatus, listCategories, listChannels, syncSubscriptions } from '../api/client'
import ChannelCard from '../components/ChannelCard'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
import Icon from '../components/Icon'
@ -17,13 +17,11 @@ function Channels() {
queryFn: () => listChannels({ subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized' }),
})
const syncStatusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.subscriptions.running ? 2000 : 60_000 })
const syncMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['sync-status'] }) } })
const finishedAt = syncStatusQuery.data?.subscriptions.finished_at
const previousFinished = useRef(finishedAt)
useEffect(() => {
if (finishedAt && previousFinished.current !== finishedAt) {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
}
if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['channels'] })
previousFinished.current = finishedAt
}, [finishedAt, queryClient])
const allChannels = allChannelsQuery.data ?? []
@ -36,14 +34,15 @@ function Channels() {
const running = syncStatusQuery.data?.subscriptions.running
return <section className="page channels-screen">
<div className="page-heading"><div><p className="eyebrow">Мои подписки</p><h1>Каналы</h1><p className="page-subtitle">Распределяй подписки по темам, чтобы лента была удобнее.</p></div></div>
<div className="page-heading"><div><p className="eyebrow">Мои подписки</p><h1>Каналы</h1><p className="page-subtitle">Распределяй подписки по темам, чтобы лента была удобнее.</p></div><button className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={running || syncMutation.isPending}><Icon name="refresh" size={17} className={running ? 'spin' : ''} />{running ? 'Обновляется' : 'Обновить'}</button></div>
{syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление подписок.</div>}
{syncStatusQuery.data?.subscriptions.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление подписок завершилось ошибкой.</div>}
<div className="channels-toolbar"><label className="input-search"><Icon name="search" size={19} /><input type="search" placeholder="Поиск каналов" aria-label="Поиск каналов" value={search} onChange={(event) => setSearch(event.target.value)} /></label><Link className="button-secondary manage-link" to="/settings/categories"><Icon name="folder" size={17} />Управлять категориями</Link></div>
<CategoryNav categories={categories} value={filter} onChange={setFilter} allCount={allChannels.length} uncategorizedCount={uncategorizedCount} />
{channelsQuery.isLoading && <div className="channel-loading" aria-label="Загрузка каналов">{Array.from({ length: 6 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
{channelsQuery.isError && <div className="empty-state" role="alert"><h2>Не удалось загрузить каналы</h2><button className="button-primary" onClick={() => channelsQuery.refetch()}>Повторить</button></div>}
{!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length > 0 && <ul className="channel-list">{filteredChannels.map((channel) => <ChannelCard key={channel.id} channel={channel} categories={categories} />)}</ul>}
{!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={running ? 'refresh' : 'users'} size={28} /></span><h2>{running ? 'Импортируем каналы' : search ? 'Каналы не найдены' : filter === 'uncategorized' ? 'Все каналы распределены' : 'Здесь пока нет каналов'}</h2><p>{running ? 'Список подписок появится здесь автоматически.' : search ? 'Попробуй другое название.' : filter === 'all' ? 'Нажми на индикатор синхронизации вверху и выбери «Обновить всё», чтобы импортировать свои каналы.' : 'Выбери другую категорию или назначь её каналам.'}</p></div>}
{!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={running ? 'refresh' : 'users'} size={28} /></span><h2>{running ? 'Импортируем каналы' : search ? 'Каналы не найдены' : filter === 'uncategorized' ? 'Все каналы распределены' : 'Здесь пока нет каналов'}</h2><p>{running ? 'Список подписок появится здесь автоматически.' : search ? 'Попробуй другое название.' : filter === 'all' ? 'Обнови подписки, чтобы импортировать свои каналы.' : 'Выбери другую категорию или назначь её каналам.'}</p></div>}
</section>
}

View file

@ -1,8 +1,7 @@
import { useEffect, useRef } from 'react'
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels } from '../api/client'
import ChannelActivityList from '../components/ChannelActivityList'
import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels, syncVideos } from '../api/client'
import Icon from '../components/Icon'
import VideoCard from '../components/VideoCard'
@ -18,10 +17,6 @@ function Feed() {
const categoryNumber = categoryId && /^\d+$/.test(categoryId) ? Number(categoryId) : undefined
const isLocal = location.pathname === '/local'
const isUncategorized = location.pathname === '/uncategorized'
const supportsViewToggle = !isLocal && location.pathname !== '/search'
const isChannelsView = supportsViewToggle && searchParams.get('view') === 'channels'
const viewQuery = isChannelsView ? 'view=channels' : ''
const withView = (path: string) => viewQuery ? `${path}?${viewQuery}` : path
const newOnly = categoryNumber !== undefined && searchParams.get('new') === '1'
const localCategoryRaw = isLocal ? searchParams.get('category') : null
const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined
@ -47,71 +42,66 @@ function Feed() {
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
enabled: (!categoryId || categoryNumber !== undefined) && !isChannelsView,
enabled: !categoryId || categoryNumber !== undefined,
})
const syncStatusQuery = useQuery({
queryKey: ['sync-status'], queryFn: getSyncStatus,
refetchInterval: (query) => query.state.data?.videos.running ? 2000 : 60_000,
})
const syncRunning = Boolean(syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running)
const finishedAt = syncStatusQuery.data?.videos.finished_at
const previousFinished = useRef(finishedAt)
useEffect(() => {
if (finishedAt && previousFinished.current !== finishedAt) {
queryClient.invalidateQueries({ queryKey: ['feed'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
}
if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['feed'] })
previousFinished.current = finishedAt
}, [finishedAt, queryClient])
const subscriptionsFinished = syncStatusQuery.data?.subscriptions.finished_at
const previousSubscriptionsFinished = useRef(subscriptionsFinished)
useEffect(() => {
if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
}
if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) queryClient.invalidateQueries({ queryKey: ['channels'] })
previousSubscriptionsFinished.current = subscriptionsFinished
}, [subscriptionsFinished, queryClient])
const syncMutation = useMutation({
mutationFn: syncVideos,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) },
})
const title = search ? `Поиск: ${search}` : isLocal ? 'На сервере' : isUncategorized ? 'Без категории' : categoryId ? category?.name ?? 'Категория' : 'Все видео'
const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : isChannelsView ? 'Каналы, отсортированные по свежести видео' : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
useEffect(() => {
if (isChannelsView || feedQuery.isLoading) return
if (feedQuery.isLoading) return
const key = `feed-scroll:${location.pathname + location.search}`
const saved = sessionStorage.getItem(key)
if (saved) {
sessionStorage.removeItem(key)
requestAnimationFrame(() => window.scrollTo(0, Number(saved)))
}
}, [isChannelsView, feedQuery.isLoading, location.pathname, location.search])
}, [feedQuery.isLoading, location.pathname, location.search])
const hasInvalidCategory = Boolean(categoryId && !categoryNumber)
return <section className="page">
<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>
{supportsViewToggle && <div className="view-toggle" role="group" aria-label="Режим просмотра">
<Link to={location.pathname} className={!isChannelsView ? 'active' : ''} aria-current={!isChannelsView ? 'page' : undefined}>Лента</Link>
<Link to={{ pathname: location.pathname, search: 'view=channels' }} className={isChannelsView ? 'active' : ''} aria-current={isChannelsView ? 'page' : undefined}>Каналы</Link>
</div>}
{!isLocal && !search && <button type="button" className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={syncMutation.isPending || syncStatusQuery.data?.videos.running}><Icon name="refresh" size={17} className={syncStatusQuery.data?.videos.running ? 'spin' : ''} />{syncStatusQuery.data?.videos.running ? 'Обновляется' : 'Обновить'}</button>}
</div>
<nav className="mobile-category-nav" aria-label="Категории">
<Link to={withView('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
<Link to={withView('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link>
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link>
<Link to="/uncategorized" className={isUncategorized ? 'active' : ''}>Без категории</Link>
<Link to="/local" 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={`/category/${item.id}`} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
</nav>
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
<Link to="/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>
{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>)}
</nav>}
{syncMutation.isError && <div className="notice error-notice" role="alert"><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>}
{hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? <div className="empty-state"><h2>Категория не найдена</h2><Link className="button-primary" to="/">Ко всем видео</Link></div> : isChannelsView ? <ChannelActivityList categoryId={categoryNumber} uncategorized={isUncategorized} syncRunning={syncRunning} /> : <>
{hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? <div className="empty-state"><h2>Категория не найдена</h2><Link className="button-primary" to="/">Ко всем видео</Link></div> : <>
{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.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"><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={syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncStatusQuery.data?.subscriptions.running && !syncStatusQuery.data?.videos.running && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>}
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</>}
</section>

View file

@ -1,301 +0,0 @@
import base64
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.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
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)
def _add_channel(db_session, youtube_channel_id, title, subscribed=True):
channel = Channel(youtube_channel_id=youtube_channel_id, title=title, subscribed=subscribed)
db_session.add(channel)
db_session.commit()
db_session.refresh(channel)
return channel
def _add_video(db_session, channel, youtube_video_id, published_at):
video = Video(
youtube_video_id=youtube_video_id,
channel_id=channel.id,
title=f"Video {youtube_video_id}",
published_at=published_at,
youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}",
)
db_session.add(video)
db_session.commit()
db_session.refresh(video)
return video
def _add_category(db_session, name="Linux"):
category = Category(name=name, slug=name.lower(), sort_order=0)
db_session.add(category)
db_session.commit()
db_session.refresh(category)
return category
def _assign_category(db_session, channel, category):
db_session.execute(channel_categories.insert().values(channel_id=channel.id, category_id=category.id))
db_session.commit()
def _get(client, **params):
return client.get("/api/channels/activity", params=params)
def test_activity_orders_by_latest_video_freshness(client, db_session):
channel_a = _add_channel(db_session, "chanA", "Channel A")
channel_b = _add_channel(db_session, "chanB", "Channel B")
channel_c = _add_channel(db_session, "chanC", "Channel C")
# Interleave videos so insertion order differs from freshness.
_add_video(db_session, channel_c, "cFresh", BASE + timedelta(hours=5))
_add_video(db_session, channel_b, "bFresh", BASE + timedelta(hours=20))
_add_video(db_session, channel_a, "aFresh", BASE + timedelta(hours=10))
_add_video(db_session, channel_c, "cOld", BASE + timedelta(hours=1))
_add_video(db_session, channel_b, "bOld", BASE + timedelta(hours=8))
_add_video(db_session, channel_a, "aOld", BASE + timedelta(hours=6))
resp = _get(client)
assert resp.status_code == 200
payload = resp.json()
assert [item["channel"]["id"] for item in payload["items"]] == [
channel_b.id,
channel_a.id,
channel_c.id,
]
assert payload["next_cursor"] is None
def test_activity_puts_channels_without_videos_last(client, db_session):
channel_with = _add_channel(db_session, "chanWith", "With videos")
channel_empty_low = _add_channel(db_session, "chanEmptyLow", "Empty low")
_add_video(db_session, channel_with, "w1", BASE + timedelta(hours=1))
channel_empty_high = _add_channel(db_session, "chanEmptyHigh", "Empty high")
resp = _get(client)
payload = resp.json()
ids = [item["channel"]["id"] for item in payload["items"]]
assert ids[0] == channel_with.id
# No-video channels follow all video channels, ordered by id desc.
assert ids[1] == channel_empty_high.id
assert ids[2] == channel_empty_low.id
# No-video channel rows carry no videos.
assert [len(item["videos"]) for item in payload["items"]] == [1, 0, 0]
def test_activity_returns_exactly_three_latest_videos_per_channel(client, db_session):
channel = _add_channel(db_session, "chanA", "Channel A")
for i in range(1, 5):
_add_video(db_session, channel, f"v{i}", BASE + timedelta(hours=i))
payload = _get(client).json()
item = payload["items"][0]
assert [v["youtube_video_id"] for v in item["videos"]] == ["v4", "v3", "v2"]
assert "v1" not in [v["youtube_video_id"] for v in item["videos"]]
def test_activity_filters_by_category(client, db_session):
category = _add_category(db_session)
channel_in = _add_channel(db_session, "chanIn", "In category")
channel_out = _add_channel(db_session, "chanOut", "Out of category")
_assign_category(db_session, channel_in, category)
_add_video(db_session, channel_in, "inVid", BASE + timedelta(hours=3))
_add_video(db_session, channel_out, "outVid", BASE + timedelta(hours=4))
payload = _get(client, category_id=category.id).json()
assert [item["channel"]["id"] for item in payload["items"]] == [channel_in.id]
payload_uncat = _get(client, uncategorized="true").json()
assert [item["channel"]["id"] for item in payload_uncat["items"]] == [channel_out.id]
def test_activity_includes_only_subscribed_channels(client, db_session):
subscribed = _add_channel(db_session, "chanSub", "Subscribed")
unsubscribed = _add_channel(db_session, "chanUnsub", "Unsubscribed", subscribed=False)
_add_video(db_session, subscribed, "sVid", BASE + timedelta(hours=1))
_add_video(db_session, unsubscribed, "uVid", BASE + timedelta(hours=10))
payload = _get(client).json()
assert [item["channel"]["id"] for item in payload["items"]] == [subscribed.id]
def test_activity_pagination_across_videos_to_no_videos_boundary(client, db_session):
# Three channels with videos (distinct freshness), two without.
c3 = _add_channel(db_session, "chanC3", "C3")
c2 = _add_channel(db_session, "chanC2", "C2")
c1 = _add_channel(db_session, "chanC1", "C1")
_add_video(db_session, c1, "c1Vid", BASE + timedelta(hours=1))
_add_video(db_session, c2, "c2Vid", BASE + timedelta(hours=2))
_add_video(db_session, c3, "c3Vid", BASE + timedelta(hours=3))
empty_high = _add_channel(db_session, "chanEHigh", "Empty high")
empty_low = _add_channel(db_session, "chanELow", "Empty low")
page1 = _get(client, limit=2).json()
assert [i["channel"]["id"] for i in page1["items"]] == [c3.id, c2.id]
assert page1["next_cursor"] is not None
page2 = _get(client, limit=2, cursor=page1["next_cursor"]).json()
assert [i["channel"]["id"] for i in page2["items"]] == [c1.id, empty_low.id]
assert page2["next_cursor"] is not None
page3 = _get(client, limit=2, cursor=page2["next_cursor"]).json()
assert [i["channel"]["id"] for i in page3["items"]] == [empty_high.id]
assert page3["next_cursor"] is None
all_ids = [
i["channel"]["id"] for page in (page1, page2, page3) for i in page["items"]
]
assert len(all_ids) == len(set(all_ids)) == 5
assert set(all_ids) == {c1.id, c2.id, c3.id, empty_high.id, empty_low.id}
def test_activity_pagination_boundary_exact_at_last_video_channel(client, db_session):
# The page boundary lands exactly on the last channel that has videos,
# so the next page must be the whole no-videos tail.
c2 = _add_channel(db_session, "chanC2", "C2")
c1 = _add_channel(db_session, "chanC1", "C1")
_add_video(db_session, c1, "c1Vid", BASE + timedelta(hours=1))
_add_video(db_session, c2, "c2Vid", BASE + timedelta(hours=2))
empty_high = _add_channel(db_session, "chanEHigh", "Empty high")
empty_low = _add_channel(db_session, "chanELow", "Empty low")
page1 = _get(client, limit=2).json()
assert [i["channel"]["id"] for i in page1["items"]] == [c2.id, c1.id]
assert page1["next_cursor"] is not None
page2 = _get(client, limit=2, cursor=page1["next_cursor"]).json()
assert [i["channel"]["id"] for i in page2["items"]] == [empty_low.id, empty_high.id]
assert page2["next_cursor"] is None
def test_activity_cursor_tiebreak_on_equal_latest_published_at(client, db_session):
channel_a = _add_channel(db_session, "chanA", "Channel A")
channel_b = _add_channel(db_session, "chanB", "Channel B")
tied = BASE + timedelta(hours=10)
_add_video(db_session, channel_a, "aTied", tied)
_add_video(db_session, channel_b, "bTied", tied)
page1 = _get(client, limit=1).json()
assert [i["channel"]["id"] for i in page1["items"]] == [channel_b.id]
assert page1["next_cursor"] is not None
page2 = _get(client, limit=1, cursor=page1["next_cursor"]).json()
assert [i["channel"]["id"] for i in page2["items"]] == [channel_a.id]
assert page2["next_cursor"] is None
def test_activity_item_shape(client, db_session):
channel = _add_channel(db_session, "chanA", "Channel A")
_add_video(db_session, channel, "shapeVid", BASE + timedelta(hours=1))
item = _get(client).json()["items"][0]
assert set(item.keys()) == {"channel", "videos"}
assert set(item["channel"].keys()) == {
"id",
"youtube_channel_id",
"title",
"description",
"thumbnail_url",
"uploads_playlist_id",
"subscriber_count",
"subscribed",
"last_synced_at",
"category_ids",
"new_videos_count",
}
assert item["channel"]["id"] == channel.id
assert item["channel"]["subscribed"] is True
assert len(item["videos"]) == 1
video = item["videos"][0]
assert set(video.keys()) == {
"youtube_video_id",
"title",
"description",
"channel",
"thumbnail_url",
"published_at",
"duration_seconds",
"youtube_url",
"categories",
"local",
}
assert set(video["channel"].keys()) == {"id", "youtube_channel_id", "title", "thumbnail_url"}
assert video["local"] == {
"available": False,
"status": "not_downloaded",
"progress_percent": None,
"media_url": None,
}
assert isinstance(video["categories"], list)
def test_activity_route_not_captured_by_channel_path_param(client, db_session):
channel = _add_channel(db_session, "chanA", "Channel A")
resp = _get(client)
assert resp.status_code == 200
assert resp.json()["items"] != []
single = client.get(f"/api/channels/{channel.id}")
assert single.status_code == 200
assert single.json()["id"] == channel.id
def test_activity_invalid_cursor_returns_400(client, db_session):
_add_channel(db_session, "chanA", "Channel A")
garbage = base64.urlsafe_b64encode(b"hello|notanumber").decode()
for bad in ("not-base64!", garbage):
resp = _get(client, cursor=bad)
assert resp.status_code == 400
assert resp.json()["detail"] == "Invalid cursor"
# An empty cursor is not a cursor at all (same as the feed endpoint).
assert _get(client, cursor="").status_code == 200
def test_activity_limit_out_of_bounds_returns_422(client, db_session):
_add_channel(db_session, "chanA", "Channel A")
assert _get(client, limit=0).status_code == 422
assert _get(client, limit=101).status_code == 422
assert _get(client, limit=100).status_code == 200
def test_activity_empty_when_no_channels(client):
payload = _get(client).json()
assert payload == {"items": [], "next_cursor": None}