Update configuration and enhance media processing: add stream token TTL and timeout settings in .env.example, improve error handling in media endpoints, and refactor command handlers for asynchronous operations. Update documentation to reflect current application state and features.
build / backend (push) Successful in 2m19s
build / frontend (push) Successful in 53s
tests / backend-tests (push) Successful in 2m27s

This commit is contained in:
Leonid Pershin
2026-07-25 23:14:55 +03:00
parent a261e261f0
commit 6c18a9da79
42 changed files with 387 additions and 98 deletions
@@ -6,7 +6,7 @@ import { HttpError } from '@/shared/api/client'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api'
import { listAllMedia } from '@/features/admin/media/api'
import { listShows } from '@/features/admin/shows/api'
import { deleteOverride, getChannel, getSchedule, regenerateSchedule, removeChannelAd } from './api'
import { AddAdForm } from './components/AddAdForm'
@@ -30,8 +30,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
})
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'],
queryFn: () => listMedia({ page: 1, pageSize: 100, statuses: ['Ready'] }),
queryKey: ['admin', 'media', 'ready', 'all'],
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
})
const { data: schedule } = useQuery({
queryKey: ['admin', 'channels', channelId, 'schedule'],
@@ -141,6 +141,9 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
onAdded={invalidate}
onError={onError}
/>
{ready?.truncated && (
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
)}
<ul className="flex flex-col divide-y divide-border">
{channel.ads.map((ad) => (
<li key={ad.id} className="flex items-center justify-between py-2 text-sm">
+27
View File
@@ -23,6 +23,33 @@ export function listMedia(params: ListMediaParams) {
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
}
/**
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
* если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
* что список полон).
*/
export async function listAllMedia(
params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number },
): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> {
const pageSize = 200
const cap = params.cap ?? 5000
const items: MediaAssetDto[] = []
let total = 0
for (let page = 1; ; page++) {
const res = await listMedia({
page,
pageSize,
statuses: params.statuses,
search: params.search,
})
total = res.total
items.push(...res.items)
if (res.items.length === 0 || items.length >= total || items.length >= cap) break
}
return { items, total, truncated: items.length < total }
}
export function deleteMedia(id: string) {
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
}
@@ -10,7 +10,7 @@ import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Pager } from '@/shared/ui/pager'
import { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api'
import { listAllMedia } from '@/features/admin/media/api'
import {
type ParsedEpisode,
compareParsed,
@@ -40,8 +40,8 @@ export function ShowDetail({ showId }: { showId: string }) {
queryFn: () => getShow(showId),
})
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'],
queryFn: () => listMedia({ page: 1, pageSize: 500, statuses: ['Ready'] }),
queryKey: ['admin', 'media', 'ready', 'all'],
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] })
@@ -212,6 +212,9 @@ export function ShowDetail({ showId }: { showId: string }) {
</ul>
)}
</div>
{ready?.truncated && (
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
)}
<Pager page={candPageSafe} totalPages={candTotalPages} onChange={setCandPage} />
</div>
)}
@@ -57,6 +57,19 @@ export function AirPage() {
}
}, [selected, attempt])
// Stream-cookie короткоживущий (TTL на сервере ~30 мин) — периодически перевыпускаем, пока смотрим,
// иначе плейлист/сегменты начнут отдавать 401 посреди эфира. Тихо: ошибку словит перезагрузка плейлиста.
useEffect(() => {
if (!selected || playerError) return
const id = window.setInterval(
() => {
void watchChannel(selected).catch(() => undefined)
},
20 * 60_000,
)
return () => window.clearInterval(id)
}, [selected, playerError])
const { data: epg } = useQuery({
queryKey: ['air', 'epg', selected],
queryFn: () =>
+4
View File
@@ -194,6 +194,8 @@ const resources = {
addSelected: 'Добавить выбранные',
noMatches: 'Нет подходящих файлов',
addedCount: 'Добавлено серий: {{count}}',
candidatesTruncated:
'Показаны не все файлы (слишком много готовых ассетов) — уточните фильтр, чтобы увидеть остальные.',
},
channels: {
title: 'Каналы',
@@ -554,6 +556,8 @@ const resources = {
addSelected: 'Add selected',
noMatches: 'No matching files',
addedCount: 'Episodes added: {{count}}',
candidatesTruncated:
'Not all files are shown (too many ready assets) — narrow the filter to see the rest.',
},
channels: {
title: 'Channels',