From 0dc133695198234c7e1156da17a80a525741f736 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Fri, 18 Sep 2026 09:39:18 +0000 Subject: [PATCH] Move manual sync to a single 'Update all' button in the header popover The feed/channels 'Update' buttons triggered a global sync of all channels, which was misleading inside a specific category or page context. Now one 'Update all' button in the sync indicator popover runs subscriptions then videos sequentially, tolerates an already-running sync (409), and pages keep their finished_at-based cache invalidation. --- analytics/2026-09-17-sync-all-button.md | 59 +++++++++++++++++++++++++ frontend/src/App.css | 4 +- frontend/src/components/AppShell.tsx | 30 +++++++++++-- frontend/src/pages/Channels.tsx | 10 ++--- frontend/src/pages/Feed.tsx | 10 +---- 5 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 analytics/2026-09-17-sync-all-button.md diff --git a/analytics/2026-09-17-sync-all-button.md b/analytics/2026-09-17-sync-all-button.md new file mode 100644 index 0000000..811c960 --- /dev/null +++ b/analytics/2026-09-17-sync-all-button.md @@ -0,0 +1,59 @@ +# Перенос ручного обновления синков в кнопку «Обновить всё» (попап SyncIndicator) + +## Задача + +Собрать ручное обновление синков в единую кнопку «Обновить всё» в попапе индикатора синхронизации в шапке (`SyncIndicator` в `AppShell`). Убрать кнопки «Обновить» из заголовков страниц «Лента» (`Feed.tsx`) и «Каналы» (`Channels.tsx`). Backend не менять. + +## Контекст + +- Кнопка в ленте вызывает `POST /api/sync/videos` — глобальный синк всех каналов, а не текущей категории; вводит в заблуждение. Глобальное действие семантически принадлежит глобальному индикатору «Обновлено X назад» в шапке. +- Порядок «подписки → видео» важен: `sync_subscriptions` заполняет `uploads_playlist_id` новых каналов, а `sync_videos` выбирает только каналы с непустым `uploads_playlist_id` (`backend/app/services/sync.py:169-173`) — новые каналы сразу получают видео. +- Автотриггер: каждый аутентифицированный запрос (`require_session` → `maybe_trigger_videos_sync`) может запустить фоновый синк видео (`backend/app/services/sync_trigger.py`). Важно: сам `POST /api/sync/subscriptions` тоже проходит через `require_session`, поэтому сразу после клика «Обновить всё» видео-синк может уже стартовать в фоне, и следующий `POST /api/sync/videos` вернёт 409 — это штатный сценарий, не ошибка. +- Поведение backend при уже идущем синке: `sync.py` бросает `SyncInProgress`, API отдаёт **409** (`backend/app/api/sync.py`), detail: `"Subscriptions sync already in progress"` / `"Videos sync already in progress"`. Фронтовый `request()` кидает `Error(detail)` (`frontend/src/api/client.ts:27`). +- Поллинг статуса уже есть: query `['sync-status']` с `refetchInterval` 2 с при `running`, иначе 60 с (`AppShell.tsx:13`). `Feed.tsx` и `Channels.tsx` инвалидируют `['feed']` / `['channels']` в `useEffect` при смене `finished_at` — эта механика не зависит от того, откуда запущен синк, и продолжит работать после переноса кнопки. +- Синк подписок может быть занят и без пользователя: он запускается по расписанию (`backend/app/services/scheduler.py:15`). +- Backend не трогаем: `POST /api/sync/subscriptions` и `POST /api/sync/videos` остаются — пригодятся API-клиентам. + +## Затронутые подсистемы и файлы + +- `frontend/src/components/AppShell.tsx` — `SyncIndicator`: кнопка «Обновить всё» в попапе, последовательный запуск подписки → видео, толерантность к 409, disabled/спиннер, показ ошибки в попапе, инвалидация `['sync-status']`. +- `frontend/src/pages/Feed.tsx` — удалить `syncMutation`, кнопку (строка 84) и notice ошибки мутации (строка 97); сохранить notice по `sync-status` «Последнее обновление видео завершилось ошибкой…» (строка 98) и оба `useEffect`-инвалидации. +- `frontend/src/pages/Channels.tsx` — удалить `syncMutation` (строка 20), кнопку в заголовке (строка 37) и notice ошибки мутации (строка 38); сохранить notice о `failed` (строка 39); заменить подсказку пустого состояния (строка 45) на указание на индикатор в шапке. +- `frontend/src/api/client.ts` — `syncSubscriptions`/`syncVideos` начнёт использовать `AppShell`; сигнатуры не меняются. +- `frontend/src/App.css` — стиль кнопки внутри `.sync-popover`; удалить неиспользуемые правила `.refresh-button` (media query, строки 248–249), если после чистки не останется использований. +- Backend — не изменяется. + +## Критерии приёмки + +1. Единственная кнопка ручного обновления — «Обновить всё» в попапе `SyncIndicator` (шапка). +2. Запуск строго последовательный: сначала `POST /api/sync/subscriptions`, затем `POST /api/sync/videos`. +3. 409 при запуске видео-синка (в т.ч. из-за автотриггера) — не ошибка: повторный запуск пропускается, состояние показывает поллинг; попап не показывает ошибку в этом случае. +4. Кнопка disabled со спиннером, пока идёт мутация или любой из синков `running` (по `sync-status`); прочие ошибки (400/502/503/сеть) показываются в попапе. +5. `Feed.tsx` и `Channels.tsx` очищены: нет кнопок «Обновить», нет мутаций/вызовов `syncVideos`/`syncSubscriptions`, нет notice ошибок мутаций; notice «…завершилось ошибкой» (по `sync-status`) и `useEffect`-инвалидации кэша по `finished_at` сохранены и работают. +6. Пустое состояние «Каналы» ссылается на индикатор в шапке (текст с «Обновить всё»). +7. `npm run lint` и `npm run build` чистые; `git diff` не затрагивает `backend/`; `pytest` зелёный. +8. Деплой по правилу команды: `docker compose up -d --build`, `curl http://localhost:8080/api/health` → OK. + +## План + +1. `AppShell.tsx` (SyncIndicator): + - `useMutation` с `mutationFn`: `await syncSubscriptions()`, затем `await syncVideos()`; при ошибке видео, содержащей `already in progress` (409), «проглотить» и не считать ошибкой; 409 подписок — аналогично не ошибка. + - Кнопка «Обновить всё» в попапе: `disabled` при `isPending` или `data?.videos.running || data?.subscriptions.running`, спиннер при pending/running. + - `onSettled`: `invalidateQueries(['sync-status'])`; блок ошибки мутации в попапе (`role="status"` у попапа при наличии кнопки стоит пересмотреть: интерактивный элемент внутри status-региона — вынести кнопку или убрать role). +2. `Feed.tsx`: удалить `syncMutation`, кнопку, notice мутации и импорты `useMutation`/`syncVideos`; оставить `syncStatusQuery`, оба `useEffect`, notice о `failed`. +3. `Channels.tsx`: то же + новый текст подсказки пустого состояния со ссылкой на индикатор в шапке. +4. `App.css`: стили кнопки в попапе; вычистить `.refresh-button`. +5. Проверка: `npm run lint`, `npm run build`, `pytest`; ручная проверка сценариев (клик → подписки затем видео; повторный клик при идущем видео-синке не даёт ошибки; статусы обновляются поллингом; notice о failed остаётся на страницах). +6. Деплой по правилу команды. + +## Риски и ограничения + +- Распознавание 409 по тексту `Error.message` (`already in progress`) — хрупко, если backend изменит detail; приемлемо, т.к. backend не меняется. Альтернатива — типизованные ошибки в `client.ts`, но без изменения API. +- 409 подписок (фоновый часовой синк): трактовать симметрично (пропустить, поллинг покажет статус). Если при этом продолжить и запускать видео-синк, он может не увидеть каналы, чьи плейлисты ещё импортируются — приемлемо, следующий синк подхватит. +- После удаления кнопки из `page-heading` правая часть заголовков страниц опустеет — проверить, что вёрстка не ломается (внутренний `div` с заголовком остаётся). +- Попап закрывается по клику вне `ref`; кнопка внутри попапа не должна его закрывать — существующий обработчик это уже учитывает. +- Мобильная вёрстка: попап имеет адаптивное правило (`App.css:278`), кнопка должна быть удобной на узких экранах. + +## Журнал изменений + +- 2026-09-18: документ создан перед реализацией (первичный спек, backend не меняется). diff --git a/frontend/src/App.css b/frontend/src/App.css index b2cbc02..e272a70 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -20,6 +20,8 @@ .sync-popover { position: absolute; top: calc(100% + 12px); right: 0; width: 280px; padding: 17px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); box-shadow: 0 16px 50px rgba(0,0,0,.5); font-size: 13px; color: var(--muted); } .sync-popover strong { display: block; color: var(--text); margin-bottom: 12px; } .sync-popover p + p { margin-top: 9px; } +.sync-popover .sync-all-button { width: 100%; min-height: 40px; margin-top: 14px; } +.sync-popover .sync-all-button + .error { margin-top: 9px; } .mobile-menu, .drawer-header, .drawer-backdrop { display: none; } .app-sidebar { position: fixed; z-index: 30; top: 66px; left: 0; bottom: 0; width: 244px; overflow-y: auto; border-right: 1px solid var(--border); background: var(--bg); } .sidebar-nav { display: flex; flex-direction: column; min-height: 100%; padding: 20px 12px; } @@ -245,8 +247,6 @@ button, .button-primary, .button-secondary, .button-quiet, .button-danger, .butt .page { padding: 24px 16px 58px; } .page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; } .page-heading h1 { font-size: 27px; } - .refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; } - .refresh-button svg { flex: none; } .mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; } .video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; } .video-thumbnail { border-radius: 9px; } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index b063d5c..e15b462 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,17 +1,35 @@ import { useEffect, useRef, useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom' -import { getSyncStatus, listCategories } from '../api/client' +import { getSyncStatus, listCategories, syncSubscriptions, syncVideos } from '../api/client' import { formatRelativeTime } from '../utils/format' import Icon from './Icon' function SyncIndicator() { const [open, setOpen] = useState(false) const ref = useRef(null) + const queryClient = useQueryClient() const { data } = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.videos.running || query.state.data?.subscriptions.running ? 2000 : 60_000, }) + const syncAllMutation = useMutation({ + mutationFn: async () => { + const ignoreInProgress = (error: unknown) => error instanceof Error && error.message.includes('already in progress') + try { + await syncSubscriptions() + } catch (error) { + if (!ignoreInProgress(error)) throw error + } + try { + await syncVideos() + } catch (error) { + if (!ignoreInProgress(error)) throw error + } + }, + onMutate: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }) }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }) }, + }) useEffect(() => { if (!open) return const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) } @@ -19,6 +37,7 @@ function SyncIndicator() { return () => document.removeEventListener('mousedown', close) }, [open]) const running = data?.videos.running || data?.subscriptions.running + const busy = syncAllMutation.isPending || running const failed = data?.videos.status === 'failed' || data?.subscriptions.status === 'failed' const recent = [data?.videos.finished_at, data?.subscriptions.finished_at].filter((value): value is string => Boolean(value)).sort().at(-1) const label = running ? 'Обновление...' : failed ? 'Ошибка синхронизации' : recent ? `Обновлено ${formatRelativeTime(recent)}` : 'Синхронизация' @@ -27,11 +46,16 @@ function SyncIndicator() { {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 &&

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

}
}
} diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index aca8cb4..6719230 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { getSyncStatus, listCategories, listChannels, syncSubscriptions } from '../api/client' +import { getSyncStatus, listCategories, listChannels } from '../api/client' import ChannelCard from '../components/ChannelCard' import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' import Icon from '../components/Icon' @@ -17,7 +17,6 @@ function Channels() { queryFn: () => listChannels({ subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized' }), }) const syncStatusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.subscriptions.running ? 2000 : 60_000 }) - const syncMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['sync-status'] }) } }) const finishedAt = syncStatusQuery.data?.subscriptions.finished_at const previousFinished = useRef(finishedAt) useEffect(() => { @@ -34,15 +33,14 @@ function Channels() { const running = syncStatusQuery.data?.subscriptions.running return
-

Мои подписки

Каналы

Распределяй подписки по темам, чтобы лента была удобнее.

- {syncMutation.isError &&
Не удалось запустить обновление подписок.
} +

Мои подписки

Каналы

Распределяй подписки по темам, чтобы лента была удобнее.

{syncStatusQuery.data?.subscriptions.status === 'failed' &&
Последнее обновление подписок завершилось ошибкой.
}
Управлять категориями
{channelsQuery.isLoading &&
{Array.from({ length: 6 }, (_, index) =>
)}
} {channelsQuery.isError &&

Не удалось загрузить каналы

} {!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length > 0 &&
    {filteredChannels.map((channel) => )}
} - {!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length === 0 &&

{running ? 'Импортируем каналы' : search ? 'Каналы не найдены' : filter === 'uncategorized' ? 'Все каналы распределены' : 'Здесь пока нет каналов'}

{running ? 'Список подписок появится здесь автоматически.' : search ? 'Попробуй другое название.' : filter === 'all' ? 'Обнови подписки, чтобы импортировать свои каналы.' : 'Выбери другую категорию или назначь её каналам.'}

} + {!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length === 0 &&

{running ? 'Импортируем каналы' : search ? 'Каналы не найдены' : filter === 'uncategorized' ? 'Все каналы распределены' : 'Здесь пока нет каналов'}

{running ? 'Список подписок появится здесь автоматически.' : search ? 'Попробуй другое название.' : filter === 'all' ? 'Нажми на индикатор синхронизации вверху и выбери «Обновить всё», чтобы импортировать свои каналы.' : 'Выбери другую категорию или назначь её каналам.'}

}
} diff --git a/frontend/src/pages/Feed.tsx b/frontend/src/pages/Feed.tsx index e7171e6..3c0a4f8 100644 --- a/frontend/src/pages/Feed.tsx +++ b/frontend/src/pages/Feed.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react' -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom' -import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels, syncVideos } from '../api/client' +import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels } from '../api/client' import Icon from '../components/Icon' import VideoCard from '../components/VideoCard' @@ -60,10 +60,6 @@ function Feed() { if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) queryClient.invalidateQueries({ queryKey: ['channels'] }) previousSubscriptionsFinished.current = subscriptionsFinished }, [subscriptionsFinished, queryClient]) - const syncMutation = useMutation({ - mutationFn: syncVideos, - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) }, - }) const title = search ? `Поиск: ${search}` : isLocal ? 'На сервере' : isUncategorized ? 'Без категории' : categoryId ? category?.name ?? 'Категория' : 'Все видео' const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте` const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? [] @@ -81,7 +77,6 @@ function Feed() { return

Моя лента

{title}

{subtitle}{newOnly && <> · Показать все}

- {!isLocal && !search && }
} - {syncMutation.isError &&
Не удалось запустить обновление видео.
} {syncStatusQuery.data?.videos.status === 'failed' &&
Последнее обновление видео завершилось ошибкой. Попробуйте позже.
} {location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 &&
Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.Настроить категории
} {hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ?

Категория не найдена

Ко всем видео
: <>