Compare commits
2 commits
5fa5a391d7
...
d4950f6979
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4950f6979 | ||
|
|
0dc1336951 |
14 changed files with 815 additions and 35 deletions
|
|
@ -8,6 +8,7 @@
|
|||
### Что сделано
|
||||
|
||||
- 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, поиск по названиям видео и каналов, мобильное меню.
|
||||
- Отписанные каналы скрываются из списка каналов.
|
||||
|
|
|
|||
88
analytics/2026-09-17-channels-view-toggle.md
Normal file
88
analytics/2026-09-17-channels-view-toggle.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Режим «Каналы» на главной: переключатель «Лента | Каналы» и эндпоинт активности каналов
|
||||
|
||||
## Задача
|
||||
|
||||
Добавить на главную страницу переключатель «Лента | Каналы». Режим «Лента» остаётся как сейчас. Режим «Каналы» — вместо сетки видео список подписанных каналов, отсортированный по свежести последнего видео; у каждого канала — три последних видео в одну широкую строку (компактные карточки). Фильтрация категориями из левого сайдбара действует и в режиме каналов. Режим задаётся 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 (миниатюра + длительность + название + относительное время).
|
||||
59
analytics/2026-09-17-sync-all-button.md
Normal file
59
analytics/2026-09-17-sync-all-button.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Перенос ручного обновления синков в кнопку «Обновить всё» (попап 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 не меняется).
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import base64
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
|
@ -14,13 +15,19 @@ 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]
|
||||
|
|
@ -101,6 +108,131 @@ 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)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@
|
|||
.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; }
|
||||
|
|
@ -95,6 +97,27 @@ 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; }
|
||||
|
|
@ -231,6 +254,8 @@ 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; }
|
||||
|
|
@ -245,9 +270,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; }
|
||||
.refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
|
||||
.refresh-button svg { flex: none; }
|
||||
.view-toggle a { min-height: 38px; padding: 0 12px; }
|
||||
.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; }
|
||||
|
|
@ -281,6 +306,7 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -81,6 +81,23 @@ 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',
|
||||
|
|
|
|||
|
|
@ -1,17 +1,35 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { getSyncStatus, listCategories } from '../api/client'
|
||||
import { getSyncStatus, listCategories, syncSubscriptions, syncVideos } 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) }
|
||||
|
|
@ -19,6 +37,7 @@ 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)}` : 'Синхронизация'
|
||||
|
|
@ -27,11 +46,16 @@ function SyncIndicator() {
|
|||
<Icon name={failed ? 'alert' : running ? 'refresh' : 'check'} size={17} className={running ? 'spin' : ''} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
{open && <div className="sync-popover" role="status">
|
||||
{open && <div className="sync-popover">
|
||||
<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>
|
||||
}
|
||||
|
|
@ -39,8 +63,12 @@ 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: '/', icon: 'home' as const, text: 'Все видео', end: true },
|
||||
{ to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true },
|
||||
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
|
||||
]
|
||||
return <nav className="sidebar-nav" aria-label="Основная навигация">
|
||||
|
|
@ -50,7 +78,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}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
|
||||
<NavLink to={`/category/${category.id}${viewQuery}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
|
||||
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
|
||||
</div>)}
|
||||
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}
|
||||
|
|
|
|||
81
frontend/src/components/ChannelActivityList.tsx
Normal file
81
frontend/src/components/ChannelActivityList.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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
|
||||
|
|
@ -46,6 +46,7 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
mutationFn: () => unsubscribeChannel(channel.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
34
frontend/src/components/CompactVideoCard.tsx
Normal file
34
frontend/src/components/CompactVideoCard.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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
|
||||
|
|
@ -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'] }) }
|
||||
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }); queryClient.invalidateQueries({ queryKey: ['channel-activity'] }) }
|
||||
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() } })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getSyncStatus, listCategories, listChannels, syncSubscriptions } from '../api/client'
|
||||
import { getSyncStatus, listCategories, listChannels } from '../api/client'
|
||||
import ChannelCard from '../components/ChannelCard'
|
||||
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
|
||||
import Icon from '../components/Icon'
|
||||
|
|
@ -17,11 +17,13 @@ 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'] })
|
||||
if (finishedAt && previousFinished.current !== finishedAt) {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
|
||||
}
|
||||
previousFinished.current = finishedAt
|
||||
}, [finishedAt, queryClient])
|
||||
const allChannels = allChannelsQuery.data ?? []
|
||||
|
|
@ -34,15 +36,14 @@ 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><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>}
|
||||
<div className="page-heading"><div><p className="eyebrow">Мои подписки</p><h1>Каналы</h1><p className="page-subtitle">Распределяй подписки по темам, чтобы лента была удобнее.</p></div></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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels, syncVideos } from '../api/client'
|
||||
import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels } from '../api/client'
|
||||
import ChannelActivityList from '../components/ChannelActivityList'
|
||||
import Icon from '../components/Icon'
|
||||
import VideoCard from '../components/VideoCard'
|
||||
|
||||
|
|
@ -17,6 +18,10 @@ 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
|
||||
|
|
@ -42,66 +47,71 @@ function Feed() {
|
|||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
||||
enabled: !categoryId || categoryNumber !== undefined,
|
||||
enabled: (!categoryId || categoryNumber !== undefined) && !isChannelsView,
|
||||
})
|
||||
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'] })
|
||||
if (finishedAt && previousFinished.current !== finishedAt) {
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
|
||||
}
|
||||
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'] })
|
||||
if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['channel-activity'] })
|
||||
}
|
||||
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} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
|
||||
const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : isChannelsView ? 'Каналы, отсортированные по свежести видео' : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
|
||||
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
||||
useEffect(() => {
|
||||
if (feedQuery.isLoading) return
|
||||
if (isChannelsView || 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)))
|
||||
}
|
||||
}, [feedQuery.isLoading, location.pathname, location.search])
|
||||
}, [isChannelsView, 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>
|
||||
{!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>}
|
||||
{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>}
|
||||
</div>
|
||||
<nav className="mobile-category-nav" aria-label="Категории">
|
||||
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
||||
<Link to="/uncategorized" className={isUncategorized ? 'active' : ''}>Без категории</Link>
|
||||
<Link to={withView('/')} className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
||||
<Link to={withView('/uncategorized')} className={isUncategorized ? 'active' : ''}>Без категории</Link>
|
||||
<Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link>
|
||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/category/${item.id}`} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
|
||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={withView(`/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> : <>
|
||||
{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} /> : <>
|
||||
{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={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.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.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
|
||||
</>}
|
||||
</section>
|
||||
|
|
|
|||
301
tests/test_channel_activity.py
Normal file
301
tests/test_channel_activity.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue