myYouTube/frontend/src/api/client.ts
vrubelroman e10df8dcbd Channel features: subscribers, new-videos badges, activity-driven sync
- Store subscriber count from YouTube statistics and show it on channel page
- Sync 50 videos per channel with playlistItems pagination support
- Show per-channel and per-category new-videos counters (2-day window)
- Replace hourly videos sync with activity trigger (2h idle) and
  incremental backfill (hard cap 200 per channel)
- Clicking the sidebar new-videos count filters the category feed to
  recent videos only (new_only)
- Update agent-team docs: deploy after green checks
2026-09-17 21:07:33 +00:00

242 lines
7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const AUTH_ERROR_MESSAGES: Record<string, string> = {
google_error: 'Google вернул ошибку авторизации',
invalid_state: 'Сессия авторизации устарела, попробуйте снова',
exchange_failed: 'Не удалось обменять код авторизации',
userinfo_failed: 'Не удалось получить данные аккаунта Google',
account_not_allowed: 'Этот Google-аккаунт не разрешён для входа',
}
export function authErrorMessage(code: string): string {
return AUTH_ERROR_MESSAGES[code] ?? 'Неизвестная ошибка авторизации'
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: 'include',
headers: options.body != null ? { 'Content-Type': 'application/json', ...(options.headers ?? {}) } : options.headers,
...options,
})
if (!res.ok) {
let detail = `${res.status}`
try {
const body = await res.json()
detail = body.detail ?? detail
} catch {
// ignore
}
throw new Error(detail)
}
return res.json()
}
export interface AuthStatus {
authenticated: boolean
connected: boolean
email: string | null
}
export function getAuthStatus() {
return request<AuthStatus>('/api/auth/status')
}
export function getHealth() {
return request<{ status: string; database: string; metube: string }>('/api/health')
}
export function startGoogleAuth() {
window.location.href = '/api/auth/google/start'
}
export function logout() {
return request<{ ok: boolean }>('/api/auth/logout', { method: 'POST' })
}
export interface ChannelDto {
id: number
youtube_channel_id: string
title: string
description: string | null
thumbnail_url: string | null
uploads_playlist_id: string | null
subscriber_count: number | null
subscribed: boolean
last_synced_at: string | null
category_ids: number[]
new_videos_count: number
}
export function getChannel(channelId: number) {
return request<ChannelDto>(`/api/channels/${channelId}`)
}
export function listChannels(
params: { search?: string; categoryId?: number; uncategorized?: boolean; subscribed?: boolean } = {},
) {
const qs = new URLSearchParams()
if (params.search) qs.set('search', params.search)
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.subscribed != null) qs.set('subscribed', String(params.subscribed))
const suffix = qs.toString() ? `?${qs.toString()}` : ''
return request<ChannelDto[]>(`/api/channels${suffix}`)
}
export function setChannelCategories(channelId: number, categoryIds: number[]) {
return request<ChannelDto>(`/api/channels/${channelId}/categories`, {
method: 'PUT',
body: JSON.stringify({ category_ids: categoryIds }),
})
}
export function unsubscribeChannel(channelId: number) {
return request<ChannelDto>(`/api/channels/${channelId}/unsubscribe`, { method: 'POST' })
}
export interface CategoryDto {
id: number
name: string
slug: string
sort_order: number
channel_count: number
new_videos_count: number
}
export function listCategories() {
return request<CategoryDto[]>('/api/categories')
}
export function createCategory(name: string) {
return request<CategoryDto>('/api/categories', {
method: 'POST',
body: JSON.stringify({ name }),
})
}
export function updateCategory(id: number, name: string) {
return request<CategoryDto>(`/api/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name }),
})
}
export function deleteCategory(id: number) {
return fetch(`/api/categories/${id}`, { method: 'DELETE', credentials: 'include' }).then((res) => {
if (!res.ok) throw new Error(`${res.status}`)
})
}
export function reorderCategories(categoryIds: number[]) {
return request<CategoryDto[]>('/api/categories/reorder', {
method: 'POST',
body: JSON.stringify({ category_ids: categoryIds }),
})
}
export interface SyncStatus {
status: string
started_at?: string
finished_at?: string | null
error?: string | null
running: boolean
[key: string]: unknown
}
export function getSyncStatus() {
return request<{ subscriptions: SyncStatus; videos: SyncStatus }>('/api/sync/status')
}
export function syncSubscriptions() {
return request<SyncStatus>('/api/sync/subscriptions', { method: 'POST' })
}
export function syncVideos() {
return request<SyncStatus>('/api/sync/videos', { method: 'POST' })
}
export interface FeedVideoDto {
youtube_video_id: string
title: string
description: string | null
channel: {
id: number
youtube_channel_id: string
title: string
thumbnail_url: string | null
}
thumbnail_url: string | null
published_at: string
duration_seconds: number | null
youtube_url: string
categories: { id: number; name: string }[]
local: {
available: boolean
status: string
progress_percent: number | null
media_url: string | null
}
}
export function getVideo(youtubeVideoId: string) {
return request<FeedVideoDto>(`/api/videos/${youtubeVideoId}`)
}
export interface DownloadJobDto {
status: string
progress_percent: number | null
media_url: string | null
error_message: string | null
requested_at?: string
started_at?: string | null
completed_at?: string | null
}
export function downloadVideo(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download`, { method: 'POST' })
}
export function deleteDownload(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download`, { method: 'DELETE' })
}
export function getDownloadStatus(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download-status`)
}
export function recheckLocal(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/recheck-local`, { method: 'POST' })
}
export function getFeed(
params: {
categoryId?: number
uncategorized?: boolean
channelId?: number
downloaded?: boolean
search?: string
cursor?: string
limit?: number
newOnly?: boolean
} = {},
) {
const qs = new URLSearchParams()
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
if (params.uncategorized) qs.set('uncategorized', 'true')
if (params.channelId != null) qs.set('channel_id', String(params.channelId))
if (params.downloaded) qs.set('downloaded', 'true')
if (params.search) qs.set('search', params.search)
if (params.newOnly) qs.set('new_only', '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: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`)
}
export interface SavedCounts {
all: number
uncategorized: number
categories: Record<string, number>
}
export function getSavedCounts() {
return request<SavedCounts>('/api/feed/saved-counts')
}