(() => single.AddEpisode(Guid.NewGuid()));
+ Assert.Single(single.Episodes);
+ }
+
[Fact]
public void ApplyMetadata_SetsProviderYearPoster_KeepsDescriptionIfBlank()
{
diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx
index 9d1ab13..e417a5c 100644
--- a/frontend/src/features/admin/channels/ChannelDetail.tsx
+++ b/frontend/src/features/admin/channels/ChannelDetail.tsx
@@ -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 && (
+ {t('admin.shows.candidatesTruncated')}
+ )}
{channel.ads.map((ad) => (
-
diff --git a/frontend/src/features/admin/media/api.ts b/frontend/src/features/admin/media/api.ts
index d88200c..ed80ce3 100644
--- a/frontend/src/features/admin/media/api.ts
+++ b/frontend/src/features/admin/media/api.ts
@@ -23,6 +23,33 @@ export function listMedia(params: ListMediaParams) {
return apiRequest>(`/admin/media?${query.toString()}`)
}
+/**
+ * Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
+ * пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
+ * если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
+ * что список полон).
+ */
+export async function listAllMedia(
+ params: Omit & { 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(`/admin/media/${id}`, { method: 'DELETE' })
}
diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx
index 5da0a62..a02a3db 100644
--- a/frontend/src/features/admin/shows/ShowDetail.tsx
+++ b/frontend/src/features/admin/shows/ShowDetail.tsx
@@ -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 }) {
)}
+ {ready?.truncated && (
+ {t('admin.shows.candidatesTruncated')}
+ )}
)}
diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx
index 11d544e..3c4ba45 100644
--- a/frontend/src/features/streaming/AirPage.tsx
+++ b/frontend/src/features/streaming/AirPage.tsx
@@ -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: () =>
diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts
index 412339d..0c40a48 100644
--- a/frontend/src/shared/lib/i18n.ts
+++ b/frontend/src/shared/lib/i18n.ts
@@ -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',