diff --git a/README.md b/README.md index 2830d78..919c25b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ ### Что сделано - Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта). -- Режим «Каналы» на главной (`?view=channels`): переключатель «Лента | Каналы», список подписанных каналов по свежести последнего видео с тремя последними роликами в строке; фильтр категорий из сайдбара действует в обоих режимах. - Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`. - Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню. - Отписанные каналы скрываются из списка каналов. diff --git a/analytics/2026-09-17-channels-view-toggle.md b/analytics/2026-09-17-channels-view-toggle.md deleted file mode 100644 index 531c540..0000000 --- a/analytics/2026-09-17-channels-view-toggle.md +++ /dev/null @@ -1,88 +0,0 @@ -# Режим «Каналы» на главной: переключатель «Лента | Каналы» и эндпоинт активности каналов - -## Задача - -Добавить на главную страницу переключатель «Лента | Каналы». Режим «Лента» остаётся как сейчас. Режим «Каналы» — вместо сетки видео список подписанных каналов, отсортированный по свежести последнего видео; у каждого канала — три последних видео в одну широкую строку (компактные карточки). Фильтрация категориями из левого сайдбара действует и в режиме каналов. Режим задаётся URL-параметром `?view=channels`. Страница управления «Каналы» (`/channels`) не меняется. - -## Контекст - -- Главная уже является комбинированной страницей: `Feed.tsx` обрабатывает `/`, `/category/:id`, `/uncategorized`, `/local`, `/search`; фильтры (`category_id`, `uncategorized`, `new_only`, `downloaded`, `search`) передаются в `GET /api/feed` с курсорной пагинацией (`backend/app/api/feed.py`). -- `listChannels` (`GET /api/channels`) возвращает все подписанные каналы с `category_ids`, но без видео и без сортировки по активности (алфавитная); нарезать «3 последних видео» на клиенте из feed-запросов нельзя — нужен новый backend-эндпоинт. -- Синк видео выполняется в фоне постоянно (`sync_trigger` + APScheduler), поэтому offset-пагинация списка каналов будет «плыть» между страницами (дубли/пропуски при добавлении новых видео). Feed уже использует курсор `(published_at, id)` — для активности каналов берём тот же подход: курсор `(last_published_at, channel_id)`. -- Сериализация видео в ленте — `serialize_video` (`backend/app/services/video_presentation.py`) с `channel_categories_map` и `latest_jobs_map`; DTO `FeedVideoDto` уже типизован в `frontend/src/api/client.ts` — переиспользуем его для видео в строке канала. -- Ссылки категорий и «Все видео» строятся в `Sidebar` (`AppShell.tsx`) без query-параметров; чтобы режим сохранялся при переключении категории, ссылки должны переносить текущий `?view=channels`. Режим в URL гарантирует и сохранение при refresh/back. -- Каналы без видео должны уходить в конец списка (NULLS LAST) — это требует расширения курсора sentinel'ом для NULL-хвоста. -- Иконок для переключателя не требуется: это два текстовых звена в сегментированном контроле; новый набор иконок не вводим. - -## Затронутые подсистемы и файлы - -Backend: -- `backend/app/api/channels.py` — новый `GET /channels/activity` (объявить **после** `list_channels` и **до** `get_channel`: иначе `/channels/activity` перехватится маршрутом `/channels/{channel_id}` и вернёт 422, т.к. Starlette выбирает маршрут по паттерну, а валидация `int` падает после). -- `backend/app/services/video_presentation.py`, `backend/app/services/download_jobs.py` — только переиспользование (`channel_categories_map`, `latest_jobs_map`, `serialize_video`), без изменений. -- `backend/app/main.py` — без изменений (роутер channels уже подключён). -- Миграции не нужны (новых полей нет; сортировка по `max(videos.published_at)` вычисляется агрегатом). - -Frontend: -- `frontend/src/api/client.ts` — новый тип `ChannelActivityDto` (`ChannelDto` + `videos: FeedVideoDto[]`) и функция `getChannelActivity({ categoryId?, uncategorized?, cursor?, limit? })`. -- `frontend/src/pages/Feed.tsx` — чтение `view` из `searchParams`, переключатель в `page-heading` (справа, в освободившееся после переноса кнопки «Обновить» место) только на маршрутах главной, рендер `ChannelActivityList` вместо `video-grid`; mobile-category-nav сохраняет view; инвалидация кэша активности при завершении видео-синка. -- `frontend/src/components/ChannelActivityList.tsx` — **новый**: `useInfiniteQuery`, строка канала (аватар + название-ссылка на `/channels/{id}/videos` + 3 компактные карточки), скелетоны, пустое состояние, ошибка с «Повторить», «Показать ещё». -- `frontend/src/components/CompactVideoCard.tsx` — **новый**: миниатюра 16:9 + бейдж длительности + название (clamp 2 строки) + относительное время; клик → `/video/{youtube_video_id}` с `state={{ from }}` и сохранением скролла как в `VideoCard`. Без DownloadButton (только просмотр). -- `frontend/src/components/AppShell.tsx` — `Sidebar`: сохранять `?view=channels` в ссылках «Все видео» и категорий (через `useLocation`). -- `frontend/src/App.css` — стили переключателя (`.view-toggle`), `.channel-activity-list` / `.channel-activity-row` / `.channel-activity-videos` / `.compact-video-card`, адаптив. -- `frontend/src/components/VideoCard.tsx` — не трогаем (компактная карточка — отдельный лёгкий компонент, не перегружаем VideoCard). - -Тесты: -- `tests/test_channel_activity.py` — **новый**, паттерн фикстуры `client` как в `tests/test_channels.py` / `tests/test_feed.py`. - -## Критерии приёмки - -1. Переключатель «Лента | Каналы» виден на `/`, `/category/:id`, `/uncategorized`; на `/local` и `/search` его нет, `?view=channels` там игнорируется (остаётся лента). Активный режим визуально выделен. -2. Режим в URL: `?view=channels`; переключение сохраняет текущий маршрут; refresh сохраняет режим. Ссылки категорий и «Все видео» в сайдбаре и в mobile-category-nav переносят текущий `view` при переключении категории; обратный переключатель на «Ленту» сбрасывает параметры (`{pathname}` без query). -3. Список каналов отсортирован по дате последнего видео (desc); каналы без видео — в конце. Строка: аватар + название (ссылка на `/channels/{id}/videos`) + 3 последних видео канала в одну широкую строку. -4. Компактная карточка: миниатюра + длительность + название + относительное время; клик открывает страницу видео. Кнопок скачивания в строке нет. -5. Фильтр категории применяется в режиме каналов: `/category/:id?view=channels` — только каналы категории; `/uncategorized?view=channels` — только каналы без категорий; `/` — все подписанные. Отписанные каналы не попадают. -6. Пагинация: ~20 каналов на страницу (DEFAULT_LIMIT 20, max 100) + кнопка «Показать ещё»; граница «каналы с видео → каналы без видео» не теряет и не дублирует каналы. -7. Пустое состояние режима каналов (нет подписок) с подсказкой и ссылкой на `/channels`; при идущем синке — состояние «Импортируем каналы». Ошибка загрузки — с кнопкой «Повторить». -8. Страница `/channels` (управление) не изменена и работает как раньше. -9. Backend `GET /api/channels/activity` отвечает 200 (не 422 — проверка порядка маршрутов), `GET /api/channels/{id}` продолжает работать; невалидный курсор → 400. -10. `pytest` (включая новый `tests/test_channel_activity.py`), `npm run lint`, `npm run build` — зелёные; деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK. - -## План - -1. **Backend — `GET /api/channels/activity`** (`backend/app/api/channels.py`): - - Параметры: `category_id: int | None`, `uncategorized: bool = False`, `limit: int = Query(20, ge=1, le=100)`, `cursor: str | None`. - - Подзапрос последнего видео: `select(Video.channel_id, func.max(Video.published_at).label('last_published_at')).group_by(Video.channel_id).subquery()`, `db.query(Channel, sub.c.last_published_at).outerjoin(...)`. - - Фильтры: `Channel.subscribed.is_(True)` всегда; `uncategorized` → `~Channel.id.in_(select(channel_categories.c.channel_id))`; иначе `category_id` → `Channel.id.in_(select(...).where(category_id == ...))` (паттерн как в `list_channels`). - - Сортировка: `last_published_at.desc().nullslast(), Channel.id.desc()`. - - Курсор: base64 `"{iso}|{channel_id}"`, `iso` пустой для каналов без видео. Условие: для непустого `iso` — `(last_published_at < pub) | ((last_published_at == pub) & (Channel.id < cid))`; для пустого — `last_published_at.is_(None) & (Channel.id < cid)`. Забрать `limit + 1` строк, отрезать `next_cursor` как в feed. - - Видео для страницы: один запрос с `row_number() OVER (PARTITION BY video.channel_id ORDER BY video.published_at DESC, video.id DESC)`, фильтр `rn <= 3` по id каналов страницы. - - Сериализация: `_serialize(channel, category_map, new_videos_count)` (уже есть) + `videos: [serialize_video(v, channel, categories, job) ...]` через `channel_categories_map` и `latest_jobs_map` — DTO видео идентичен ленте. Ответ: `{"items": [...], "next_cursor": ...}`. - - Курсорные хелперы `_encode_cursor/_decode_cursor` из `feed.py` приватные и заточены под видео — в `channels.py` сделать локальные версии под `(datetime | None, channel_id)`; общий модуль не выделяем, чтобы не трогать `feed.py`. -2. **Frontend — API** (`client.ts`): `ChannelActivityDto`, `getChannelActivity`; кодирование query-параметров как в `getFeed`. -3. **Frontend — компоненты**: `CompactVideoCard.tsx`, `ChannelActivityList.tsx` (queryKey: `['channel-activity', location.pathname, categoryNumber ?? null, isUncategorized]`; `getNextPageParam` по `next_cursor`; скелетоны/пусто/ошибка/«Показать ещё» в паттернах Feed.tsx). -4. **Feed.tsx**: `const isChannelsView = searchParams.get('view') === 'channels'`; переключатель рендерится при `!isLocal && !search`; при `isChannelsView` — рендер `ChannelActivityList` вместо сетки; подписи/`title` оставить; в `useEffect` по `videos.finished_at` дополнительно `invalidateQueries(['channel-activity'])`; mobile-category-nav — ссылки с сохранением `view`. -5. **AppShell.tsx (Sidebar)**: `useLocation`; `to={{ pathname: '/', search: viewQuery }}` и аналогично для категорий (через `URLSearchParams`); badge-ссылки `?new=1` оставить без `view` (они ведут в ленту «только новые» — feed-специфичны). -6. **App.css**: `.view-toggle` (сегментированный контроль в стиле `.category-nav`/`.local-filter-nav`), строки каналов (грид `repeat(3, minmax(0, 1fr))` для видео), компактные карточки, media-правила ≤900/≤620px. -7. **Тесты** `tests/test_channel_activity.py` (фикстура client как в test_channels, сиды через Channel/Video/Category/channel_categories): - - порядок по свежести последнего видео (каналы вперемешку по датам); - - каналы без видео в конце, после всех «с видео»; - - ровно 3 последних видео на канал в правильном порядке; - - фильтр `category_id` и `uncategorized`; только `subscribed`; - - курсорная пагинация, включая переход через границу «с видео → без видео» (без дублей/потерь); - - shape: поля канала + `videos` ≤ 3, поля видео как в `test_feed_item_shape`; - - `GET /api/channels/activity` → 200 (не 422) и `GET /api/channels/{id}` → 200 (порядок маршрутов); invalid cursor → 400; `limit` вне границ → 422. -8. **Проверка**: `pytest`, `npm run lint`, `npm run build`; ручная проверка сценариев (переключение на всех трёх маршрутах; сохранение view при смене категории в сайдбаре и на мобильном меню; refresh; пагинация; пустое состояние). Деплой по правилу команды. - -## Риски и ограничения - -- **Порядок маршрутов**: `/channels/activity` обязано быть объявлено до `/channels/{channel_id}`; тест-кейс закрывает регрессию. -- **Курсор с NULL-хвостом**: sentinel «пустой iso» усложняет `_decode_cursor`; граничный тест обязателен. SQLite в тестах (Python 3.13, SQLite ≥ 3.45) поддерживает `NULLS LAST` и оконные функции — совместимо. -- **Feed.tsx растёт**: держим разметку режима каналов в `ChannelActivityList`, в `Feed.tsx` только ветвление; VideoCard не перегружаем. -- **Инвалидация кэша**: `ChannelCard` (страница `/channels`) при смене категорий каналов инвалидирует `['feed']`/`['channels']`/`['categories']`, но не `['channel-activity']` — добавить туда же, иначе строки режима каналов устаревают после изменения категорий. -- **`?new=1` и `view`**: `new_only` — feed-специфичный фильтр; badge в сайдбаре всегда ведёт в ленту. Переключатель «Каналы» с URL `?new=1` сбрасывает `new` (строим `{pathname}?view=channels`), эндпоинт активности его не принимает. В обратную сторону «Лента» даёт `{pathname}` без параметров. -- **Производительность**: 3 запроса на страницу (каналы, видео через оконную функцию, категории+джобы) — приемлемо для single-user; при необходимости окно видео можно ограничить `limit`'ом страницы. -- **Серийная форма видео** в строке включает `local` (из `latest_jobs_map`) — фронт его в компактных карточках не показывает; DTO единый с лентой намеренно (переиспользование типа и потенциальный mobile-клиент). - -## Журнал изменений - -- 2026-09-18: документ создан перед реализацией. Зафиксированы решения: переключатель на `/`, `/category/:id`, `/uncategorized` (на `/local` и `/search` — нет); режим в URL `?view=channels`; сайдбар сохраняет view; эндпоинт `GET /api/channels/activity`; курсорная пагинация `(last_published_at, channel_id)` с NULL-хвостом; компактные карточки без DownloadButton (миниатюра + длительность + название + относительное время). diff --git a/analytics/2026-09-17-sync-all-button.md b/analytics/2026-09-17-sync-all-button.md deleted file mode 100644 index 811c960..0000000 --- a/analytics/2026-09-17-sync-all-button.md +++ /dev/null @@ -1,59 +0,0 @@ -# Перенос ручного обновления синков в кнопку «Обновить всё» (попап SyncIndicator) - -## Задача - -Собрать ручное обновление синков в единую кнопку «Обновить всё» в попапе индикатора синхронизации в шапке (`SyncIndicator` в `AppShell`). Убрать кнопки «Обновить» из заголовков страниц «Лента» (`Feed.tsx`) и «Каналы» (`Channels.tsx`). Backend не менять. - -## Контекст - -- Кнопка в ленте вызывает `POST /api/sync/videos` — глобальный синк всех каналов, а не текущей категории; вводит в заблуждение. Глобальное действие семантически принадлежит глобальному индикатору «Обновлено X назад» в шапке. -- Порядок «подписки → видео» важен: `sync_subscriptions` заполняет `uploads_playlist_id` новых каналов, а `sync_videos` выбирает только каналы с непустым `uploads_playlist_id` (`backend/app/services/sync.py:169-173`) — новые каналы сразу получают видео. -- Автотриггер: каждый аутентифицированный запрос (`require_session` → `maybe_trigger_videos_sync`) может запустить фоновый синк видео (`backend/app/services/sync_trigger.py`). Важно: сам `POST /api/sync/subscriptions` тоже проходит через `require_session`, поэтому сразу после клика «Обновить всё» видео-синк может уже стартовать в фоне, и следующий `POST /api/sync/videos` вернёт 409 — это штатный сценарий, не ошибка. -- Поведение backend при уже идущем синке: `sync.py` бросает `SyncInProgress`, API отдаёт **409** (`backend/app/api/sync.py`), detail: `"Subscriptions sync already in progress"` / `"Videos sync already in progress"`. Фронтовый `request()` кидает `Error(detail)` (`frontend/src/api/client.ts:27`). -- Поллинг статуса уже есть: query `['sync-status']` с `refetchInterval` 2 с при `running`, иначе 60 с (`AppShell.tsx:13`). `Feed.tsx` и `Channels.tsx` инвалидируют `['feed']` / `['channels']` в `useEffect` при смене `finished_at` — эта механика не зависит от того, откуда запущен синк, и продолжит работать после переноса кнопки. -- Синк подписок может быть занят и без пользователя: он запускается по расписанию (`backend/app/services/scheduler.py:15`). -- Backend не трогаем: `POST /api/sync/subscriptions` и `POST /api/sync/videos` остаются — пригодятся API-клиентам. - -## Затронутые подсистемы и файлы - -- `frontend/src/components/AppShell.tsx` — `SyncIndicator`: кнопка «Обновить всё» в попапе, последовательный запуск подписки → видео, толерантность к 409, disabled/спиннер, показ ошибки в попапе, инвалидация `['sync-status']`. -- `frontend/src/pages/Feed.tsx` — удалить `syncMutation`, кнопку (строка 84) и notice ошибки мутации (строка 97); сохранить notice по `sync-status` «Последнее обновление видео завершилось ошибкой…» (строка 98) и оба `useEffect`-инвалидации. -- `frontend/src/pages/Channels.tsx` — удалить `syncMutation` (строка 20), кнопку в заголовке (строка 37) и notice ошибки мутации (строка 38); сохранить notice о `failed` (строка 39); заменить подсказку пустого состояния (строка 45) на указание на индикатор в шапке. -- `frontend/src/api/client.ts` — `syncSubscriptions`/`syncVideos` начнёт использовать `AppShell`; сигнатуры не меняются. -- `frontend/src/App.css` — стиль кнопки внутри `.sync-popover`; удалить неиспользуемые правила `.refresh-button` (media query, строки 248–249), если после чистки не останется использований. -- Backend — не изменяется. - -## Критерии приёмки - -1. Единственная кнопка ручного обновления — «Обновить всё» в попапе `SyncIndicator` (шапка). -2. Запуск строго последовательный: сначала `POST /api/sync/subscriptions`, затем `POST /api/sync/videos`. -3. 409 при запуске видео-синка (в т.ч. из-за автотриггера) — не ошибка: повторный запуск пропускается, состояние показывает поллинг; попап не показывает ошибку в этом случае. -4. Кнопка disabled со спиннером, пока идёт мутация или любой из синков `running` (по `sync-status`); прочие ошибки (400/502/503/сеть) показываются в попапе. -5. `Feed.tsx` и `Channels.tsx` очищены: нет кнопок «Обновить», нет мутаций/вызовов `syncVideos`/`syncSubscriptions`, нет notice ошибок мутаций; notice «…завершилось ошибкой» (по `sync-status`) и `useEffect`-инвалидации кэша по `finished_at` сохранены и работают. -6. Пустое состояние «Каналы» ссылается на индикатор в шапке (текст с «Обновить всё»). -7. `npm run lint` и `npm run build` чистые; `git diff` не затрагивает `backend/`; `pytest` зелёный. -8. Деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK. - -## План - -1. `AppShell.tsx` (SyncIndicator): - - `useMutation` с `mutationFn`: `await syncSubscriptions()`, затем `await syncVideos()`; при ошибке видео, содержащей `already in progress` (409), «проглотить» и не считать ошибкой; 409 подписок — аналогично не ошибка. - - Кнопка «Обновить всё» в попапе: `disabled` при `isPending` или `data?.videos.running || data?.subscriptions.running`, спиннер при pending/running. - - `onSettled`: `invalidateQueries(['sync-status'])`; блок ошибки мутации в попапе (`role="status"` у попапа при наличии кнопки стоит пересмотреть: интерактивный элемент внутри status-региона — вынести кнопку или убрать role). -2. `Feed.tsx`: удалить `syncMutation`, кнопку, notice мутации и импорты `useMutation`/`syncVideos`; оставить `syncStatusQuery`, оба `useEffect`, notice о `failed`. -3. `Channels.tsx`: то же + новый текст подсказки пустого состояния со ссылкой на индикатор в шапке. -4. `App.css`: стили кнопки в попапе; вычистить `.refresh-button`. -5. Проверка: `npm run lint`, `npm run build`, `pytest`; ручная проверка сценариев (клик → подписки затем видео; повторный клик при идущем видео-синке не даёт ошибки; статусы обновляются поллингом; notice о failed остаётся на страницах). -6. Деплой по правилу команды. - -## Риски и ограничения - -- Распознавание 409 по тексту `Error.message` (`already in progress`) — хрупко, если backend изменит detail; приемлемо, т.к. backend не меняется. Альтернатива — типизованные ошибки в `client.ts`, но без изменения API. -- 409 подписок (фоновый часовой синк): трактовать симметрично (пропустить, поллинг покажет статус). Если при этом продолжить и запускать видео-синк, он может не увидеть каналы, чьи плейлисты ещё импортируются — приемлемо, следующий синк подхватит. -- После удаления кнопки из `page-heading` правая часть заголовков страниц опустеет — проверить, что вёрстка не ломается (внутренний `div` с заголовком остаётся). -- Попап закрывается по клику вне `ref`; кнопка внутри попапа не должна его закрывать — существующий обработчик это уже учитывает. -- Мобильная вёрстка: попап имеет адаптивное правило (`App.css:278`), кнопка должна быть удобной на узких экранах. - -## Журнал изменений - -- 2026-09-18: документ создан перед реализацией (первичный спек, backend не меняется). diff --git a/backend/app/api/channels.py b/backend/app/api/channels.py index 73c19d9..f3a87dd 100644 --- a/backend/app/api/channels.py +++ b/backend/app/api/channels.py @@ -1,8 +1,7 @@ -import base64 import logging from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -15,19 +14,13 @@ from app.models.channel import Channel from app.models.channel_category import channel_categories from app.models.video import Video from app.services import sync -from app.services.download_jobs import latest_jobs_map from app.services.google_oauth import OAuthNotConnected -from app.services.video_presentation import channel_categories_map, serialize_video from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded logger = logging.getLogger(__name__) router = APIRouter(dependencies=[Depends(require_session)]) -ACTIVITY_DEFAULT_LIMIT = 20 -ACTIVITY_MAX_LIMIT = 100 -ACTIVITY_VIDEOS_PER_CHANNEL = 3 - class ChannelCategoriesUpdate(BaseModel): category_ids: list[int] @@ -108,131 +101,6 @@ def list_channels( return [_serialize(c, category_map.get(c.id, []), new_videos_map.get(c.id, 0)) for c in channels] -def _encode_activity_cursor(last_published_at: datetime | None, channel_id: int) -> str: - raw = f"{last_published_at.isoformat() if last_published_at else ''}|{channel_id}" - return base64.urlsafe_b64encode(raw.encode()).decode() - - -def _decode_activity_cursor(cursor: str) -> tuple[datetime | None, int]: - try: - raw = base64.urlsafe_b64decode(cursor.encode()).decode() - iso_raw, channel_id_raw = raw.rsplit("|", 1) - published_at = datetime.fromisoformat(iso_raw) if iso_raw else None - if published_at is not None and published_at.tzinfo is None: - published_at = published_at.replace(tzinfo=timezone.utc) - return published_at, int(channel_id_raw) - except Exception: - raise HTTPException(status_code=400, detail="Invalid cursor") - - -# NOTE: this route must stay declared BEFORE /channels/{channel_id}, otherwise -# "activity" is captured by the path parameter and validation fails with 422. -@router.get("/channels/activity") -def channel_activity( - category_id: int | None = None, - uncategorized: bool = False, - limit: int = Query(ACTIVITY_DEFAULT_LIMIT, ge=1, le=ACTIVITY_MAX_LIMIT), - cursor: str | None = None, - db: Session = Depends(get_db), -) -> dict: - """Subscribed channels ordered by the freshness of their latest video - (channels without videos go last), with up to 3 recent videos each. - Cursor is (last_published_at, channel_id); an empty iso marks the - no-videos tail so pagination stays lossless across that boundary.""" - latest_video = ( - select(Video.channel_id, func.max(Video.published_at).label("last_published_at")) - .group_by(Video.channel_id) - .subquery() - ) - query = db.query(Channel, latest_video.c.last_published_at).outerjoin( - latest_video, latest_video.c.channel_id == Channel.id - ) - query = query.filter(Channel.subscribed.is_(True)) - - if uncategorized: - categorized_ids = select(channel_categories.c.channel_id) - query = query.filter(~Channel.id.in_(categorized_ids)) - elif category_id is not None: - channel_ids_in_category = select(channel_categories.c.channel_id).where( - channel_categories.c.category_id == category_id - ) - query = query.filter(Channel.id.in_(channel_ids_in_category)) - - if cursor: - cursor_published_at, cursor_channel_id = _decode_activity_cursor(cursor) - if cursor_published_at is not None: - # Channels without videos sort last (NULLS LAST), so they are - # also "after" this cursor and must pass the filter; the ORDER BY - # keeps them behind every channel that still has videos. - query = query.filter( - (latest_video.c.last_published_at < cursor_published_at) - | ( - (latest_video.c.last_published_at == cursor_published_at) - & (Channel.id < cursor_channel_id) - ) - | latest_video.c.last_published_at.is_(None) - ) - else: - query = query.filter( - latest_video.c.last_published_at.is_(None) & (Channel.id < cursor_channel_id) - ) - - rows = ( - query.order_by(latest_video.c.last_published_at.desc().nullslast(), Channel.id.desc()) - .limit(limit + 1) - .all() - ) - - next_cursor = None - if len(rows) > limit: - last_channel, last_published_at = rows[limit - 1] - next_cursor = _encode_activity_cursor(last_published_at, last_channel.id) - rows = rows[:limit] - - channels_by_id = {channel.id: channel for channel, _ in rows} - channel_ids = list(channels_by_id) - videos_by_channel: dict[int, list[Video]] = {channel_id: [] for channel_id in channel_ids} - - if channel_ids: - # Top-N videos per channel in a single windowed query (no N+1). - row_number = func.row_number().over( - partition_by=Video.channel_id, - order_by=(Video.published_at.desc(), Video.id.desc()), - ).label("rn") - windowed = ( - db.query(Video.id, row_number) - .filter(Video.channel_id.in_(channel_ids)) - .subquery() - ) - recent_ids = select(windowed.c.id).where(windowed.c.rn <= ACTIVITY_VIDEOS_PER_CHANNEL) - videos = db.query(Video).filter(Video.id.in_(recent_ids)).all() - for video in videos: - videos_by_channel[video.channel_id].append(video) - for channel_videos in videos_by_channel.values(): - channel_videos.sort(key=lambda video: (video.published_at, video.id), reverse=True) - - categories_map = channel_categories_map(db, channel_ids) - jobs_map = latest_jobs_map(db, [video.id for videos in videos_by_channel.values() for video in videos]) - new_videos_map = _new_videos_counts(db, channel_ids) - - items = [] - for channel, _ in rows: - categories = categories_map.get(channel.id, []) - items.append( - { - "channel": _serialize( - channel, [category["id"] for category in categories], new_videos_map.get(channel.id, 0) - ), - "videos": [ - serialize_video(video, channel, categories, jobs_map.get(video.id)) - for video in videos_by_channel.get(channel.id, []) - ], - } - ) - - return {"items": items, "next_cursor": next_cursor} - - @router.get("/channels/{channel_id}") def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict: channel = db.get(Channel, channel_id) diff --git a/frontend/src/App.css b/frontend/src/App.css index 088013b..b2cbc02 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -20,8 +20,6 @@ .sync-popover { position: absolute; top: calc(100% + 12px); right: 0; width: 280px; padding: 17px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); box-shadow: 0 16px 50px rgba(0,0,0,.5); font-size: 13px; color: var(--muted); } .sync-popover strong { display: block; color: var(--text); margin-bottom: 12px; } .sync-popover p + p { margin-top: 9px; } -.sync-popover .sync-all-button { width: 100%; min-height: 40px; margin-top: 14px; } -.sync-popover .sync-all-button + .error { margin-top: 9px; } .mobile-menu, .drawer-header, .drawer-backdrop { display: none; } .app-sidebar { position: fixed; z-index: 30; top: 66px; left: 0; bottom: 0; width: 244px; overflow-y: auto; border-right: 1px solid var(--border); background: var(--bg); } .sidebar-nav { display: flex; flex-direction: column; min-height: 100%; padding: 20px 12px; } @@ -97,27 +95,6 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt .download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); } .download-action-error { color: var(--error); font-size: 12px; } .load-more { display: flex; margin: 34px auto 0; } -.view-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); } -.view-toggle a { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 14px; border-radius: 7px; color: var(--muted); font-size: 13px; font-weight: 650; text-decoration: none; white-space: nowrap; transition: background .16s, color .16s; } -.view-toggle a:hover { color: var(--text); background: var(--hover); } -.view-toggle a.active { color: var(--accent); background: var(--accent-soft); } -.channel-activity-list { list-style: none; display: grid; gap: 25px; padding: 0; margin: 0; } -.channel-activity-row { display: grid; grid-template-columns: 228px minmax(0, 1fr); gap: 18px; align-items: start; min-width: 0; padding-bottom: 25px; border-bottom: 1px solid var(--border); } -.channel-activity-identity { display: flex; align-items: center; gap: 12px; min-width: 0; padding-top: 2px; color: var(--text); text-decoration: none; } -.channel-activity-identity img, .channel-activity-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; object-fit: cover; } -.channel-activity-avatar { display: grid; place-items: center; background: #344557; color: var(--text); font-size: 17px; font-weight: 700; } -.channel-activity-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 650; } -.channel-activity-identity:hover .channel-activity-name { color: var(--accent); } -.channel-activity-videos { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; min-width: 0; } -.channel-activity-empty-videos { margin: 0; align-self: center; color: var(--subtle); font-size: 12px; } -.compact-video-card { min-width: 0; display: flex; flex-direction: column; } -.compact-thumbnail { position: relative; display: block; overflow: hidden; width: 100%; aspect-ratio: 16 / 9; border-radius: 9px; background: var(--surface-raised); } -.compact-thumbnail img, .compact-thumbnail .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; } -.compact-video-title { margin-top: 7px; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--text); text-decoration: none; font-size: 13px; font-weight: 600; line-height: 1.42; } -.compact-video-title:hover { color: var(--accent); } -.compact-video-time { margin-top: 4px; color: var(--muted); font-size: 11px; } -.skeleton-avatar { width: 42px; height: 42px; flex: none; border-radius: 50%; background: linear-gradient(90deg, #222 25%, #303030 50%, #222 75%); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; } -.channel-activity-skeleton .skeleton-line { margin: 0; } .empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); } .empty-state h2 { margin: 0 0 9px; font-size: 20px; } .empty-state p { font-size: 14px; line-height: 1.55; margin-bottom: 20px; } @@ -254,8 +231,6 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt .mobile-category-nav { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -5px -28px 25px; padding: 0 28px 5px; } .mobile-category-nav a { flex: none; min-height: 35px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 0 14px; background: var(--surface); color: var(--muted); font-size: 12px; text-decoration: none; } .mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); } - .channel-activity-row { grid-template-columns: 1fr; gap: 12px; padding-bottom: 21px; } - .channel-activity-identity { padding-top: 0; } } @media (max-width: 620px) { .topbar { height: 112px; display: grid; grid-template-columns: 44px 1fr auto; grid-template-rows: 55px 49px; column-gap: 7px; row-gap: 0; padding: 0 15px 8px; } @@ -270,9 +245,9 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt .page { padding: 24px 16px 58px; } .page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; } .page-heading h1 { font-size: 27px; } - .view-toggle a { min-height: 38px; padding: 0 12px; } + .refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; } + .refresh-button svg { flex: none; } .mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; } - .channel-activity-videos { grid-template-columns: repeat(2, minmax(0, 1fr)); } .video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; } .video-thumbnail { border-radius: 9px; } .video-info { padding: 10px 2px 0; } @@ -306,7 +281,6 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt @media (pointer: coarse) { .video-actions .button-link, .video-actions .button-secondary, .download-badge, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; } .sidebar-badge { display: inline-flex; align-items: center; } - .view-toggle a { min-height: 44px; } .reorder-buttons .icon-button { width: 40px; height: 40px; } .popover-create input, .popover-create button { height: 44px; } .popover-create button { width: 44px; } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5a8012d..8480ae7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -81,23 +81,6 @@ export function listChannels( return request(`/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(`/api/channels/${channelId}/categories`, { method: 'PUT', diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index bae735a..b063d5c 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,35 +1,17 @@ import { useEffect, useRef, useState } from 'react' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom' -import { getSyncStatus, listCategories, syncSubscriptions, syncVideos } from '../api/client' +import { getSyncStatus, listCategories } from '../api/client' import { formatRelativeTime } from '../utils/format' import Icon from './Icon' function SyncIndicator() { const [open, setOpen] = useState(false) const ref = useRef(null) - const queryClient = useQueryClient() const { data } = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.videos.running || query.state.data?.subscriptions.running ? 2000 : 60_000, }) - const syncAllMutation = useMutation({ - mutationFn: async () => { - const ignoreInProgress = (error: unknown) => error instanceof Error && error.message.includes('already in progress') - try { - await syncSubscriptions() - } catch (error) { - if (!ignoreInProgress(error)) throw error - } - try { - await syncVideos() - } catch (error) { - if (!ignoreInProgress(error)) throw error - } - }, - onMutate: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }) }, - onSettled: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }) }, - }) useEffect(() => { if (!open) return const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) } @@ -37,7 +19,6 @@ function SyncIndicator() { return () => document.removeEventListener('mousedown', close) }, [open]) const running = data?.videos.running || data?.subscriptions.running - const busy = syncAllMutation.isPending || running const failed = data?.videos.status === 'failed' || data?.subscriptions.status === 'failed' const recent = [data?.videos.finished_at, data?.subscriptions.finished_at].filter((value): value is string => Boolean(value)).sort().at(-1) const label = running ? 'Обновление...' : failed ? 'Ошибка синхронизации' : recent ? `Обновлено ${formatRelativeTime(recent)}` : 'Синхронизация' @@ -46,16 +27,11 @@ function SyncIndicator() { {label} - {open &&
+ {open &&
Синхронизация

Подписки: {data?.subscriptions.running ? 'обновляются' : data?.subscriptions.finished_at ? formatRelativeTime(data.subscriptions.finished_at) : 'ещё не обновлялись'}

Видео: {data?.videos.running ? 'обновляются' : data?.videos.finished_at ? formatRelativeTime(data.videos.finished_at) : 'ещё не обновлялись'}

{failed &&

Последнее обновление завершилось ошибкой. Подробности в настройках.

} - - {syncAllMutation.isError &&

Не удалось запустить обновление. Попробуйте позже.

}
}
} @@ -63,12 +39,8 @@ function SyncIndicator() { function Sidebar({ close }: { close: () => void }) { const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categories = categoriesQuery.data ?? [] - const location = useLocation() - // Keep the current view mode ("Лента | Каналы") when switching between - // "Все видео" and category links; badges (?new=1) are feed-specific. - const viewQuery = new URLSearchParams(location.search).get('view') === 'channels' ? '?view=channels' : '' const links = [ - { to: `/${viewQuery}`, icon: 'home' as const, text: 'Все видео', end: true }, + { to: '/', icon: 'home' as const, text: 'Все видео', end: true }, { to: '/local', icon: 'server' as const, text: 'На сервере' }, ] return