Add channels view toggle on the main page

New GET /api/channels/activity returns subscribed channels sorted by
their latest video (no-video channels last) with the 3 newest videos
per channel and cursor pagination. The feed page gains a 'Лента |
Каналы' toggle (?view=channels) with compact video cards per channel
row; category filters and the sidebar preserve the view.
This commit is contained in:
vrubelroman 2026-09-18 12:13:32 +00:00
parent 0dc1336951
commit d4950f6979
13 changed files with 721 additions and 16 deletions

View file

@ -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, поиск по названиям видео и каналов, мобильное меню.
- Отписанные каналы скрываются из списка каналов.

View 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 (миниатюра + длительность + название + относительное время).

View file

@ -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)

View file

@ -97,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; }
@ -233,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; }
@ -247,7 +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; }
.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; }

View file

@ -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',

View file

@ -63,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="Основная навигация">
@ -74,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>}

View 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

View file

@ -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'] })
},
})

View 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

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'] }) }
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() } })

View file

@ -20,7 +20,10 @@ function Channels() {
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 ?? []

View file

@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { useInfiniteQuery, 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 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,47 +47,58 @@ 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 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>
{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>
@ -91,11 +107,11 @@ function Feed() {
</nav>}
{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>

View 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}