Add media and channel management routes: introduce new routes for admin channels, media, and shows in the routing structure. Update navigation in the admin layout to include links for these new sections. Enhance type definitions for media assets and shows in the API types. Integrate HLS.js for improved streaming support.

This commit is contained in:
Leonid Pershin
2026-07-24 16:18:54 +03:00
parent 1bbfd15907
commit a2685fb602
24 changed files with 2050 additions and 42 deletions
@@ -0,0 +1,660 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronLeft, RefreshCw } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import type {
AdInsertion,
BlockMode,
ChannelShowDto,
OverrideMode,
ScheduleEntryDto,
} from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api'
import { listShows } from '@/features/admin/shows/api'
import {
addChannelAd,
addChannelShow,
createOverride,
deleteOverride,
getChannel,
getSchedule,
regenerateSchedule,
removeChannelAd,
removeChannelShow,
updateChannelSettings,
updateChannelShow,
} from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleString([], {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId],
queryFn: () => getChannel(channelId),
})
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'],
queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }),
})
const { data: schedule } = useQuery({
queryKey: ['admin', 'channels', channelId, 'schedule'],
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)),
})
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] })
}
const invalidateSchedule = () =>
queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId, 'schedule'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const regenerateMutation = useMutation({
mutationFn: () => regenerateSchedule(channelId),
onSuccess: () => {
toast.success(t('admin.channels.regenerated'))
void invalidateSchedule()
},
onError,
})
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
const availableShows = shows?.filter((s) => !channel.shows.some((cs) => cs.showId === s.id)) ?? []
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button asChild size="sm" variant="ghost">
<Link to="/admin/channels">
<ChevronLeft className="h-4 w-4" />
{t('admin.channels.title')}
</Link>
</Button>
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
<Badge variant="muted">{channel.slug}</Badge>
</div>
<Button
size="sm"
variant="outline"
disabled={regenerateMutation.isPending}
onClick={() => regenerateMutation.mutate()}
>
<RefreshCw className="h-4 w-4" />
{t('admin.channels.regenerate')}
</Button>
</div>
<SettingsCard channel={channel} readyAssets={ready?.items ?? []} onSaved={invalidate} onError={onError} />
{/* Шоу канала */}
<Card>
<CardHeader>
<CardTitle>{t('admin.channels.shows')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<AddShowForm
channelId={channelId}
options={availableShows.map((s) => ({ id: s.id, name: s.name }))}
onAdded={invalidate}
onError={onError}
/>
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="py-2 font-medium">{t('admin.channels.show')}</th>
<th className="py-2 font-medium">{t('admin.channels.weight')}</th>
<th className="py-2 font-medium">{t('admin.channels.block')}</th>
<th className="py-2 font-medium">{t('admin.channels.on')}</th>
<th className="py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{channel.shows.map((row) => (
<ChannelShowRow
key={row.id}
channelId={channelId}
row={row}
onChanged={invalidate}
onError={onError}
/>
))}
{channel.shows.length === 0 && (
<tr>
<td className="py-3 text-muted-foreground" colSpan={5}>
{t('admin.channels.noShows')}
</td>
</tr>
)}
</tbody>
</table>
</CardContent>
</Card>
{/* Реклама */}
<Card>
<CardHeader>
<CardTitle>{t('admin.channels.ads')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<AddAdForm
channelId={channelId}
options={(ready?.items ?? [])
.filter((a) => !channel.ads.some((ad) => ad.mediaAssetId === a.id))
.map((a) => ({ id: a.id, name: a.originalFileName }))}
onAdded={invalidate}
onError={onError}
/>
<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">
<span>{ad.assetName ?? '—'}</span>
<RemoveButton
onClick={() =>
removeChannelAd(channelId, ad.id).then(invalidate).catch(onError)
}
/>
</li>
))}
{channel.ads.length === 0 && (
<li className="py-2 text-muted-foreground">{t('admin.channels.noAds')}</li>
)}
</ul>
</CardContent>
</Card>
{/* Override'ы / марафоны */}
<Card>
<CardHeader>
<CardTitle>{t('admin.channels.overrides')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<OverrideForm
channelId={channelId}
options={channel.shows.map((cs) => ({ id: cs.showId, name: cs.showName }))}
onCreated={invalidate}
onError={onError}
/>
<ul className="flex flex-col divide-y divide-border">
{channel.overrides.map((o) => (
<li key={o.id} className="flex items-center justify-between py-2 text-sm">
<span>
<Badge variant="muted">{t(`admin.channels.modes.${o.mode}`)}</Badge>{' '}
{formatTime(o.startsAtUtc)} {formatTime(o.endsAtUtc)} ·{' '}
{o.shows.map((s) => s.showName).join(', ')}
</span>
<RemoveButton
onClick={() => deleteOverride(channelId, o.id).then(invalidate).catch(onError)}
/>
</li>
))}
{channel.overrides.length === 0 && (
<li className="py-2 text-muted-foreground">{t('admin.channels.noOverrides')}</li>
)}
</ul>
</CardContent>
</Card>
{/* Предпросмотр расписания */}
<Card>
<CardHeader>
<CardTitle>{t('admin.channels.schedule')}</CardTitle>
</CardHeader>
<CardContent>
<SchedulePreview entries={schedule ?? []} />
</CardContent>
</Card>
</div>
)
}
function SettingsCard({
channel,
readyAssets,
onSaved,
onError,
}: {
channel: import('@/shared/api/types').ChannelDto
readyAssets: { id: string; originalFileName: string }[]
onSaved: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [name, setName] = useState(channel.name)
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
const [adInsertion, setAdInsertion] = useState<AdInsertion>(channel.adInsertion)
const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak)
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
useEffect(() => {
setName(channel.name)
setIsEnabled(channel.isEnabled)
setAdInsertion(channel.adInsertion)
setAdsPerBreak(channel.adsPerBreak)
setFillerAssetId(channel.fillerAssetId ?? '')
}, [channel])
const save = useMutation({
mutationFn: () =>
updateChannelSettings(channel.id, {
name: name.trim(),
isEnabled,
adInsertion,
adsPerBreak,
fillerAssetId: fillerAssetId || null,
}),
onSuccess: () => {
toast.success(t('settings.saved'))
onSaved()
},
onError,
})
return (
<Card>
<CardHeader>
<CardTitle>{t('admin.channels.settings')}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.name')}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.adPolicy')}</Label>
<Select value={adInsertion} onValueChange={(v) => setAdInsertion(v as AdInsertion)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BetweenBlocks">{t('admin.channels.betweenBlocks')}</SelectItem>
<SelectItem value="BetweenEpisodes">{t('admin.channels.betweenEpisodes')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.adsPerBreak')}</Label>
<Input
type="number"
min={0}
max={10}
value={adsPerBreak}
onChange={(e) => setAdsPerBreak(Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.filler')}</Label>
<Select value={fillerAssetId || 'none'} onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
{readyAssets.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.originalFileName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
/>
{t('admin.channels.enabledLabel')}
</label>
<div className="flex items-end justify-end sm:col-span-2">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</CardContent>
</Card>
)
}
function AddShowForm({
channelId,
options,
onAdded,
onError,
}: {
channelId: string
options: { id: string; name: string }[]
onAdded: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [showId, setShowId] = useState('')
const [weight, setWeight] = useState(1)
const [blockMode, setBlockMode] = useState<BlockMode>('Count')
const [blockValue, setBlockValue] = useState(1)
const add = useMutation({
mutationFn: () => addChannelShow(channelId, { showId, weight, blockMode, blockValue }),
onSuccess: () => {
setShowId('')
onAdded()
},
onError,
})
return (
<div className="flex flex-wrap items-end gap-2">
<Select value={showId} onValueChange={setShowId}>
<SelectTrigger className="w-48">
<SelectValue placeholder={t('admin.channels.pickShow')} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
</SelectContent>
</Select>
<NumberField
label={blockMode === 'Count' ? t('admin.channels.episodes') : t('admin.channels.minutes')}
value={blockValue}
onChange={setBlockValue}
min={1}
/>
<Button size="sm" disabled={!showId || add.isPending} onClick={() => add.mutate()}>
{t('common.create')}
</Button>
</div>
)
}
function ChannelShowRow({
channelId,
row,
onChanged,
onError,
}: {
channelId: string
row: ChannelShowDto
onChanged: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [weight, setWeight] = useState(row.weight)
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
const [blockValue, setBlockValue] = useState(row.blockValue)
const [isEnabled, setIsEnabled] = useState(row.isEnabled)
const save = useMutation({
mutationFn: () =>
updateChannelShow(channelId, row.id, { weight, blockMode, blockValue, isEnabled }),
onSuccess: onChanged,
onError,
})
return (
<tr className="border-b border-border last:border-0">
<td className="py-2">{row.showName}</td>
<td className="py-2">
<Input
type="number"
min={1}
value={weight}
onChange={(e) => setWeight(Number(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
<SelectTrigger className="h-8 w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
min={1}
value={blockValue}
onChange={(e) => setBlockValue(Number(e.target.value))}
className="h-8 w-16"
/>
</div>
</td>
<td className="py-2">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
/>
</td>
<td className="py-2">
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
<RemoveButton
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
/>
</div>
</td>
</tr>
)
}
function AddAdForm({
channelId,
options,
onAdded,
onError,
}: {
channelId: string
options: { id: string; name: string }[]
onAdded: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [assetId, setAssetId] = useState('')
const add = useMutation({
mutationFn: () => addChannelAd(channelId, assetId),
onSuccess: () => {
setAssetId('')
onAdded()
},
onError,
})
return (
<div className="flex flex-wrap items-end gap-2">
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger className="max-w-md">
<SelectValue placeholder={t('admin.channels.pickAd')} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
{t('common.create')}
</Button>
</div>
)
}
function OverrideForm({
channelId,
options,
onCreated,
onError,
}: {
channelId: string
options: { id: string; name: string }[]
onCreated: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [mode, setMode] = useState<OverrideMode>('Exclusive')
const [showId, setShowId] = useState('')
const [weight, setWeight] = useState(1)
const [start, setStart] = useState('')
const [end, setEnd] = useState('')
const create = useMutation({
mutationFn: () =>
createOverride(channelId, {
mode,
startsAtUtc: new Date(start).toISOString(),
endsAtUtc: new Date(end).toISOString(),
shows: [{ showId, weight }],
}),
onSuccess: () => {
setShowId('')
setStart('')
setEnd('')
onCreated()
},
onError,
})
const valid = showId && start && end && new Date(end) > new Date(start)
return (
<div className="flex flex-wrap items-end gap-2">
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Exclusive">{t('admin.channels.modes.Exclusive')}</SelectItem>
<SelectItem value="Boost">{t('admin.channels.modes.Boost')}</SelectItem>
</SelectContent>
</Select>
<Select value={showId} onValueChange={setShowId}>
<SelectTrigger className="w-44">
<SelectValue placeholder={t('admin.channels.pickShow')} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
{mode === 'Boost' && (
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
)}
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.from')}</Label>
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-52" />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.to')}</Label>
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-52" />
</div>
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
{t('common.create')}
</Button>
</div>
)
}
function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
const { t } = useTranslation()
if (entries.length === 0)
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
return (
<ul className="flex flex-col divide-y divide-border text-sm">
{entries.slice(0, 40).map((e) => (
<li key={e.id} className="flex items-center gap-3 py-1.5">
<span className="w-28 shrink-0 text-muted-foreground">{formatTime(e.startsAtUtc)}</span>
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : (
<span>
{e.showName ?? '—'}
{e.episodeIndex != null && (
<span className="text-muted-foreground"> · {t('air.episode')} {e.episodeIndex + 1}</span>
)}
</span>
)}
</li>
))}
</ul>
)
}
function NumberField({
label,
value,
onChange,
min,
}: {
label: string
value: number
onChange: (v: number) => void
min?: number
}) {
return (
<div className="flex flex-col gap-1.5">
<Label>{label}</Label>
<Input
type="number"
min={min}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-24"
/>
</div>
)
}
function RemoveButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation()
return (
<Button size="sm" variant="destructive" onClick={onClick}>
{t('common.delete')}
</Button>
)
}
@@ -0,0 +1,109 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { createChannel, listChannels } from './api'
function slugify(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
export function ChannelsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const { data, isLoading } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'channels'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({
mutationFn: () => createChannel({ name: name.trim(), slug: slug || slugify(name) }),
onSuccess: () => {
setName('')
setSlug('')
invalidate()
},
onError,
})
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.channels.title')}</h2>
<div className="flex flex-wrap items-end gap-2">
<Input
className="max-w-xs"
placeholder={t('admin.channels.name')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
className="max-w-xs"
placeholder={t('admin.channels.slug')}
value={slug}
onChange={(e) => setSlug(slugify(e.target.value))}
/>
<Button
size="sm"
disabled={!name.trim() || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{t('common.create')}
</Button>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.channels.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.channels.slug')}</th>
<th className="px-4 py-2 font-medium">{t('admin.channels.state')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={3}>
{t('common.loading')}
</td>
</tr>
)}
{data?.map((channel) => (
<tr key={channel.id} className="border-b border-border last:border-0">
<td className="px-4 py-2">
<Link
to="/admin/channels/$channelId"
params={{ channelId: channel.id }}
className="text-primary hover:underline"
>
{channel.name}
</Link>
</td>
<td className="px-4 py-2 text-muted-foreground">{channel.slug}</td>
<td className="px-4 py-2">
{channel.isEnabled ? (
<Badge>{t('admin.channels.enabled')}</Badge>
) : (
<Badge variant="muted">{t('admin.channels.disabled')}</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,92 @@
import { apiRequest } from '@/shared/api/client'
import type {
AdInsertion,
BlockMode,
ChannelDto,
ChannelSummaryDto,
CreatedIdResponse,
OverrideMode,
ScheduleEntryDto,
} from '@/shared/api/types'
export function listChannels() {
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
}
export function getChannel(id: string) {
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
}
export function createChannel(body: { name: string; slug: string }) {
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
}
export type ChannelSettingsBody = {
name: string
isEnabled: boolean
adInsertion: AdInsertion
adsPerBreak: number
fillerAssetId: string | null
}
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
}
export type ChannelShowBody = {
showId: string
weight: number
blockMode: BlockMode
blockValue: number
}
export function addChannelShow(id: string, body: ChannelShowBody) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/shows`, { method: 'POST', body })
}
export function updateChannelShow(
id: string,
channelShowId: string,
body: { weight: number; blockMode: BlockMode; blockValue: number; isEnabled: boolean },
) {
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body })
}
export function removeChannelShow(id: string, channelShowId: string) {
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'DELETE' })
}
export function addChannelAd(id: string, mediaAssetId: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/ads`, {
method: 'POST',
body: { mediaAssetId },
})
}
export function removeChannelAd(id: string, channelAdId: string) {
return apiRequest<void>(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' })
}
export type OverrideBody = {
mode: OverrideMode
startsAtUtc: string
endsAtUtc: string
shows: { showId: string; weight: number }[]
}
export function createOverride(id: string, body: OverrideBody) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/overrides`, { method: 'POST', body })
}
export function deleteOverride(id: string, overrideId: string) {
return apiRequest<void>(`/admin/channels/${id}/overrides/${overrideId}`, { method: 'DELETE' })
}
export function regenerateSchedule(id: string) {
return apiRequest<void>(`/admin/channels/${id}/regenerate`, { method: 'POST' })
}
export function getSchedule(id: string, from: Date, to: Date) {
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
}
@@ -0,0 +1,157 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Upload } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, listMedia, uploadMedia } from './api'
const PAGE_SIZE = 20
export function formatDuration(seconds: number | null): string {
if (seconds == null) return '—'
const total = Math.round(seconds)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
const pad = (n: number) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
}
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
Ready: 'default',
Processing: 'muted',
Pending: 'muted',
Failed: 'destructive',
}
export function MediaPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const [progress, setProgress] = useState<number | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media'],
queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE }),
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
refetchInterval: (query) =>
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
? 4000
: false,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
const handleFile = async (file: File) => {
setProgress(0)
try {
await uploadMedia(file, setProgress)
toast.success(t('admin.media.uploaded'))
invalidate()
} catch (error) {
onError(error)
} finally {
setProgress(null)
if (fileInput.current) fileInput.current.value = ''
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
<div className="flex items-center gap-3">
{progress != null && (
<span className="text-sm text-muted-foreground">{progress}%</span>
)}
<input
ref={fileInput}
type="file"
accept="video/*,.mkv,.avi,.ts"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void handleFile(file)
}}
/>
<Button
size="sm"
disabled={progress != null}
onClick={() => fileInput.current?.click()}
>
<Upload className="h-4 w-4" />
{t('admin.media.upload')}
</Button>
</div>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.media.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.duration')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.resolution')}</th>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('common.loading')}
</td>
</tr>
)}
{data?.items.map((asset) => (
<MediaRow
key={asset.id}
asset={asset}
onDelete={() => deleteMutation.mutate(asset.id)}
/>
))}
{data && data.items.length === 0 && !isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('admin.media.empty')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => void }) {
const { t } = useTranslation()
return (
<tr className="border-b border-border last:border-0">
<td className="px-4 py-2">{asset.originalFileName}</td>
<td className="px-4 py-2">
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
{t(`admin.media.statuses.${asset.status}`)}
</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{formatDuration(asset.durationSeconds)}</td>
<td className="px-4 py-2 text-muted-foreground">
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
</td>
<td className="px-4 py-2">
<Button size="sm" variant="destructive" onClick={onDelete}>
{t('common.delete')}
</Button>
</td>
</tr>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
CreatedIdResponse,
MediaAssetDto,
MediaAssetStatus,
PagedList,
} from '@/shared/api/types'
export type ListMediaParams = {
page: number
pageSize: number
status?: MediaAssetStatus
search?: string
}
export function listMedia(params: ListMediaParams) {
const query = new URLSearchParams({
page: String(params.page),
pageSize: String(params.pageSize),
})
if (params.status) query.set('status', params.status)
if (params.search) query.set('search', params.search)
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
}
export function deleteMedia(id: string) {
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
}
/**
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
*/
export function uploadMedia(
file: File,
onProgress?: (percent: number) => void,
): Promise<CreatedIdResponse> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open('POST', `/api/admin/media?${query.toString()}`)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress)
onProgress(Math.round((event.loaded / event.total) * 100))
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
} else {
let detail = `HTTP ${xhr.status}`
try {
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = problem.detail ?? problem.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}
@@ -0,0 +1,136 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api'
import { formatDuration } from '@/features/admin/media/MediaPanel'
import { addEpisode, getShow, removeEpisode } from './api'
export function ShowDetail({ showId }: { showId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [assetId, setAssetId] = useState('')
const { data: show, isLoading } = useQuery({
queryKey: ['admin', 'shows', showId],
queryFn: () => getShow(showId),
})
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'],
queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const addMutation = useMutation({
mutationFn: () => addEpisode(showId, assetId),
onSuccess: () => {
setAssetId('')
invalidate()
},
onError,
})
const removeMutation = useMutation({
mutationFn: (episodeId: string) => removeEpisode(showId, episodeId),
onSuccess: invalidate,
onError,
})
if (isLoading || !show) return <p className="text-muted-foreground">{t('common.loading')}</p>
const canAdd = show.kind !== 'Single' || show.episodes.length === 0
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Button asChild size="sm" variant="ghost">
<Link to="/admin/shows">
<ChevronLeft className="h-4 w-4" />
{t('admin.shows.title')}
</Link>
</Button>
</div>
<div className="flex items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{show.name}</h2>
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</div>
{canAdd && (
<div className="flex flex-wrap items-end gap-2">
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger className="max-w-md">
<SelectValue placeholder={t('admin.shows.pickAsset')} />
</SelectTrigger>
<SelectContent>
{ready?.items.map((asset) => (
<SelectItem key={asset.id} value={asset.id}>
{asset.originalFileName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assetId || addMutation.isPending} onClick={() => addMutation.mutate()}>
{t('admin.shows.addEpisode')}
</Button>
</div>
)}
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">#</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.episode')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.duration')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{show.episodes.map((episode, index) => (
<tr key={episode.id} className="border-b border-border last:border-0">
<td className="px-4 py-2 text-muted-foreground">{index + 1}</td>
<td className="px-4 py-2">{episode.assetName ?? '—'}</td>
<td className="px-4 py-2 text-muted-foreground">
{formatDuration(episode.durationSeconds)}
</td>
<td className="px-4 py-2">
{episode.assetStatus && (
<Badge variant={episode.assetStatus === 'Ready' ? 'default' : 'muted'}>
{t(`admin.media.statuses.${episode.assetStatus}`)}
</Badge>
)}
</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => removeMutation.mutate(episode.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
))}
{show.episodes.length === 0 && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('admin.shows.noEpisodes')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,113 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import type { ShowKind } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { createShow, deleteShow, listShows } from './api'
export function ShowsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [kind, setKind] = useState<ShowKind>('Series')
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({
mutationFn: () => createShow({ name: name.trim(), kind }),
onSuccess: () => {
setName('')
invalidate()
},
onError,
})
const deleteMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError })
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.shows.title')}</h2>
<div className="flex flex-wrap items-end gap-2">
<Input
className="max-w-xs"
placeholder={t('admin.shows.name')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Select value={kind} onValueChange={(v) => setKind(v as ShowKind)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Series">{t('admin.shows.kinds.Series')}</SelectItem>
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
disabled={!name.trim() || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{t('common.create')}
</Button>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.shows.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.kind')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.episodes')}</th>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={4}>
{t('common.loading')}
</td>
</tr>
)}
{data?.map((show) => (
<tr key={show.id} className="border-b border-border last:border-0">
<td className="px-4 py-2">
<Link
to="/admin/shows/$showId"
params={{ showId: show.id }}
className="text-primary hover:underline"
>
{show.name}
</Link>
</td>
<td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => deleteMutation.mutate(show.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { apiRequest } from '@/shared/api/client'
import type { CreatedIdResponse, ShowDto, ShowKind, ShowSummaryDto } from '@/shared/api/types'
export function listShows() {
return apiRequest<ShowSummaryDto[]>('/admin/shows')
}
export function getShow(id: string) {
return apiRequest<ShowDto>(`/admin/shows/${id}`)
}
export function createShow(body: { name: string; kind: ShowKind; description?: string }) {
return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body })
}
export function deleteShow(id: string) {
return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' })
}
export function addEpisode(showId: string, mediaAssetId: string) {
return apiRequest<CreatedIdResponse>(`/admin/shows/${showId}/episodes`, {
method: 'POST',
body: { mediaAssetId },
})
}
export function removeEpisode(showId: string, episodeId: string) {
return apiRequest<void>(`/admin/shows/${showId}/episodes/${episodeId}`, { method: 'DELETE' })
}
+145
View File
@@ -0,0 +1,145 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio } from 'lucide-react'
import type { ScheduleEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { ChannelPlayer } from './ChannelPlayer'
import { getEpg, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function AirPage() {
const { t } = useTranslation()
const [selected, setSelected] = useState<string | null>(null)
const [watchReady, setWatchReady] = useState(false)
const { data: channels, isLoading } = useQuery({
queryKey: ['air', 'channels'],
queryFn: listChannels,
})
useEffect(() => {
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
}, [channels, selected])
useEffect(() => {
if (!selected) return
setWatchReady(false)
let cancelled = false
void watchChannel(selected)
.then(() => {
if (!cancelled) setWatchReady(true)
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [selected])
const { data: epg } = useQuery({
queryKey: ['air', 'epg', selected],
queryFn: () =>
getEpg(
selected!,
new Date(Date.now() - 30 * 60_000),
new Date(Date.now() + 3 * 60 * 60_000),
),
enabled: !!selected,
refetchInterval: 60_000,
})
const { current, upcoming } = useMemo(() => splitEpg(epg ?? []), [epg])
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
if (!channels || channels.length === 0)
return (
<div className="flex flex-col gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<p className="text-muted-foreground">{t('air.noChannels')}</p>
</div>
)
return (
<div className="flex flex-col gap-4">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => setSelected(channel.slug)}
className={cn(
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
selected === channel.slug && 'border-primary text-primary',
)}
>
<Radio className="h-4 w-4" />
{channel.name}
</button>
))}
</aside>
<div className="flex flex-col gap-4">
{selected && watchReady ? (
<ChannelPlayer slug={selected} />
) : (
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
)}
<div className="flex flex-col gap-3">
{current && (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge>
<span className="font-medium">{programLabel(current, t)}</span>
</div>
<span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
</span>
</div>
)}
{upcoming.length > 0 && (
<div className="crt-panel rounded-md">
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
{t('air.next')}
</div>
<ul className="divide-y divide-border">
{upcoming.slice(0, 6).map((entry) => (
<li key={entry.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="w-12 shrink-0 text-muted-foreground">
{formatTime(entry.startsAtUtc)}
</span>
<span>{programLabel(entry, t)}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</div>
)
}
function splitEpg(entries: ScheduleEntryDto[]) {
const now = Date.now()
const current = entries.find(
(e) => new Date(e.startsAtUtc).getTime() <= now && new Date(e.endsAtUtc).getTime() > now,
)
const upcoming = entries.filter((e) => new Date(e.startsAtUtc).getTime() > now)
return { current, upcoming }
}
function programLabel(entry: ScheduleEntryDto, t: (key: string) => string) {
if (entry.kind === 'Ad') return t('air.ad')
const name = entry.showName ?? '—'
return entry.episodeIndex != null ? `${name} · ${t('air.episode')} ${entry.episodeIndex + 1}` : name
}
@@ -0,0 +1,55 @@
import Hls from 'hls.js'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
/**
* HLS-плеер канала. Cookie tw_stream (см. watchChannel) уже выдана к моменту монтирования, поэтому
* запросы плейлиста/сегментов авторизуются автоматически. hls.js для всех браузеров, нативный HLS —
* фолбэк для Safari/iOS.
*/
export function ChannelPlayer({ slug }: { slug: string }) {
const videoRef = useRef<HTMLVideoElement>(null)
const { t } = useTranslation()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const video = videoRef.current
if (!video) return
setError(null)
const src = `/api/channels/${slug}/live.m3u8`
let hls: Hls | null = null
if (Hls.isSupported()) {
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined))
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) setError(t('air.playbackError'))
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src
video.addEventListener('loadedmetadata', () => void video.play().catch(() => undefined))
video.addEventListener('error', () => setError(t('air.playbackError')))
} else {
setError(t('air.unsupported'))
}
return () => {
hls?.destroy()
}
}, [slug, t])
return (
<div className="flex flex-col gap-2">
<video
ref={videoRef}
controls
playsInline
muted
className="aspect-video w-full rounded-md border border-border bg-black"
/>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, ScheduleEntryDto } from '@/shared/api/types'
export function listChannels() {
return apiRequest<PublicChannelDto[]>('/channels')
}
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
export function watchChannel(slug: string) {
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
}
export function getEpg(slug: string, from?: Date, to?: Date) {
const query = new URLSearchParams()
if (from) query.set('from', from.toISOString())
if (to) query.set('to', to.toISOString())
const qs = query.toString()
return apiRequest<ScheduleEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
}