-
-
- {syncMutation.isError &&
{(syncMutation.error as Error).message}
}
- {syncStatusQuery.data?.videos.status === 'failed' && (
-
Последняя синхронизация видео завершилась ошибкой: {syncStatusQuery.data.videos.error}
- )}
-
-
-
-
-
- {feedQuery.isLoading &&
Загрузка...
}
- {feedQuery.isError &&
Не удалось загрузить ленту
}
-
-
- {items.map((video) => (
-
- ))}
-
-
- {!feedQuery.isLoading && items.length === 0 && (
-
Видео пока нет. Нажми "Обновить сейчас" или проверь, есть ли каналы в этой категории.
- )}
-
- {feedQuery.hasNextPage && (
-
- )}
-
-
+ return
+
+
Моя лента
{title}
{subtitle}
+ {!isLocal && !search &&
}
- )
+
+ {isLocal && }
+ {syncMutation.isError && Не удалось запустить обновление видео.
}
+ {syncStatusQuery.data?.videos.status === 'failed' && Последнее обновление видео завершилось ошибкой. Попробуйте позже.
}
+ {location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 && Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.Настроить категории
}
+ {hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? Категория не найдена
Ко всем видео : <>
+ {feedQuery.isLoading && {Array.from({ length: 8 }, (_, index) => )}
}
+ {feedQuery.isError && Не удалось загрузить видео
Проверь соединение и попробуй ещё раз.
}
+ {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && }
+ {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && {syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}
{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}
{!search && !isLocal && !syncStatusQuery.data?.subscriptions.running && !syncStatusQuery.data?.videos.running &&
Перейти к каналам}
}
+ {feedQuery.hasNextPage && }
+ >}
+
}
export default Feed
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
new file mode 100644
index 0000000..18f1d65
--- /dev/null
+++ b/frontend/src/pages/Settings.tsx
@@ -0,0 +1,29 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Link, useNavigate } from 'react-router-dom'
+import { getHealth, getSyncStatus, logout, startGoogleAuth, syncSubscriptions, syncVideos } from '../api/client'
+import { formatRelativeTime } from '../utils/format'
+import Icon from '../components/Icon'
+
+function Settings({ email }: { email: string | null }) {
+ const queryClient = useQueryClient()
+ const navigate = useNavigate()
+ const statusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: 30_000 })
+ const healthQuery = useQuery({ queryKey: ['health'], queryFn: getHealth, staleTime: 0 })
+ const subscriptionsMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) })
+ const videosMutation = useMutation({ mutationFn: syncVideos, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) })
+ const logoutMutation = useMutation({ mutationFn: logout, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['auth-status'] }); navigate('/') } })
+ const subscriptions = statusQuery.data?.subscriptions
+ const videos = statusQuery.data?.videos
+ return
+ Аккаунт и сервисы
Настройки
Подключения и обновление вашей ленты.
+
+
YouTube
Подписки и новые видео
Аккаунт{email ?? 'Неизвестен'}
Статус Подключён
Подписки обновлены{subscriptions?.finished_at ? formatRelativeTime(subscriptions.finished_at) : 'Ещё не обновлялись'}
{subscriptionsMutation.isError && Не удалось запустить обновление.
}
+
Локальные видео
Сохранённые копии
Соединение{healthQuery.isLoading ? 'Проверяем…' : healthQuery.data?.metube === 'ok' ? 'Доступно' : 'Недоступно'}
Скачанные видео доступны во вкладке «На сервере».
Открыть видео
+
Синхронизация
Видео в вашей ленте
Видео обновлены{videos?.finished_at ? formatRelativeTime(videos.finished_at) : 'Ещё не обновлялись'}
Состояние{videos?.running ? 'Обновляются' : videos?.status === 'failed' ? 'Ошибка' : videos?.status === 'completed' ? 'Успешно' : 'Ещё не запускалась'}
{videosMutation.isError && Не удалось запустить обновление.
}
+
Категории
Организация каналов
Переименовывай категории и меняй их порядок.
Управлять категориями
+
+
+
+}
+
+export default Settings
diff --git a/frontend/src/pages/VideoPage.tsx b/frontend/src/pages/VideoPage.tsx
index de78430..1e440e8 100644
--- a/frontend/src/pages/VideoPage.tsx
+++ b/frontend/src/pages/VideoPage.tsx
@@ -1,73 +1,31 @@
import { useQuery } from '@tanstack/react-query'
-import { Link, useParams } from 'react-router-dom'
+import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import { getVideo } from '../api/client'
import { formatDuration, formatRelativeTime } from '../utils/format'
import DownloadButton from '../components/DownloadButton'
+import Icon from '../components/Icon'
import Player from '../components/Player'
function VideoPage() {
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
+ const location = useLocation()
+ const navigate = useNavigate()
+ const from = (location.state as { from?: string } | null)?.from
+ const videoQuery = useQuery({ queryKey: ['video', youtubeVideoId], queryFn: () => getVideo(youtubeVideoId!), enabled: !!youtubeVideoId })
+ const back = () => { if (from && window.history.length > 1) navigate(-1); else navigate(from ?? '/') }
- const videoQuery = useQuery({
- queryKey: ['video', youtubeVideoId],
- queryFn: () => getVideo(youtubeVideoId!),
- enabled: !!youtubeVideoId,
- })
-
- if (videoQuery.isLoading) {
- return (
-
- )
- }
-
- if (videoQuery.isError || !videoQuery.data) {
- return (
-
-
- ← Лента
-
-
Видео не найдено
-
- )
- }
+ if (videoQuery.isLoading) return
+ if (videoQuery.isError || !videoQuery.data) return
Видео не найдено
Возможно, оно больше не доступно в ленте.
const video = videoQuery.data
const duration = formatDuration(video.duration_seconds)
-
- return (
-
-
- ← Лента
-
-
-
-
-
{video.title}
-
-
- {video.channel.title}
- {' · '}
- {formatRelativeTime(video.published_at)}
- {duration && ` · ${duration}`}
- {' · '}
-
-
-
- {video.categories.length > 0 && (
-
- {video.categories.map((c) => (
-
- {c.name}
-
- ))}
-
- )}
-
- {video.description &&
{video.description}
}
-
- )
+ return
+
+
+ {video.title}
{video.channel.title}·{formatRelativeTime(video.published_at)}{duration && <>·{duration}>}
+ {video.categories.length > 0 && {video.categories.map((category) => {category.name})}
}
+ {video.description && Описание
{video.description}
}
+
}
export default VideoPage
diff --git a/tests/test_feed.py b/tests/test_feed.py
index bace6fc..c6ab3cb 100644
--- a/tests/test_feed.py
+++ b/tests/test_feed.py
@@ -11,6 +11,7 @@ from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.models.download_job import DownloadJob
from app.models.video import Video
+from app.api.feed import get_feed
@pytest.fixture
@@ -67,6 +68,19 @@ def test_feed_orders_by_published_at_desc(client, db_session):
assert resp["next_cursor"] is None
+def test_feed_searches_video_and_channel_titles(db_session):
+ _seed(db_session)
+
+ by_video = get_feed(search="video 4", limit=30, db=db_session)
+ assert [item["youtube_video_id"] for item in by_video["items"]] == ["vid4"]
+
+ by_channel = get_feed(search="channel b", limit=30, db=db_session)
+ assert [item["youtube_video_id"] for item in by_channel["items"]] == ["vid3", "vid1"]
+
+ literal_wildcard = get_feed(search="%", limit=30, db=db_session)
+ assert literal_wildcard["items"] == []
+
+
def test_feed_pagination_cursor(client, db_session):
_seed(db_session)