Add IPTV playlist functionality and enhance streaming endpoints
ci / build-backend (push) Successful in 1m47s
ci / build-frontend (push) Successful in 52s
ci / tests (push) Successful in 1m36s
ci / sonar (push) Successful in 4m26s

Implemented a new endpoint for downloading the IPTV playlist, allowing users to access a comprehensive M3U file containing all channels and program guides. Updated the StreamingEndpoints to support token validation for both cookie and query parameters, ensuring secure access for external IPTV players. Enhanced the AirPage component to include a download button for the IPTV playlist, with appropriate user feedback on success or failure. Updated localization strings to reflect new features and instructions for users.
This commit is contained in:
Leonid Pershin
2026-07-29 18:14:01 +03:00
parent 775f287c29
commit c4a94443f7
14 changed files with 535 additions and 13 deletions
+40 -5
View File
@@ -1,14 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio, RotateCw } from 'lucide-react'
import { Download, Radio, RotateCw } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { PublicEpgEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store'
import { ChannelPlayer } from './ChannelPlayer'
import { getEpg, getViewerFeatures, imageUrl, listChannels, watchChannel } from './api'
import {
downloadIptvPlaylist,
getEpg,
getViewerFeatures,
imageUrl,
listChannels,
watchChannel,
} from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -51,6 +59,21 @@ export function AirPage() {
setAttempt((a) => a + 1)
}
const [downloading, setDownloading] = useState(false)
const downloadPlaylist = async () => {
setDownloading(true)
try {
await downloadIptvPlaylist()
// Предупреждение показываем в момент скачивания, а не подписью под кнопкой: висеть на экране
// ему незачем, а прочитать его надо ровно тогда, когда файл уже на руках.
toast.success(t('air.iptvDone'))
} catch {
toast.error(t('air.iptvFailed'))
} finally {
setDownloading(false)
}
}
const { data: channels, isLoading } = useQuery({
queryKey: qk.air.channels,
queryFn: listChannels,
@@ -166,9 +189,21 @@ export function AirPage() {
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
{numbersEnabled && (
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
)}
<div className="flex flex-wrap items-center gap-3">
{numbersEnabled && (
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
)}
<Button
size="sm"
variant="outline"
disabled={downloading}
title={t('air.iptvHint')}
onClick={downloadPlaylist}
>
<Download className="h-4 w-4" />
{t('air.iptv')}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
+18 -1
View File
@@ -1,4 +1,4 @@
import { apiRequest } from '@/shared/api/client'
import { apiDownload, apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
@@ -20,6 +20,23 @@ export function watchChannel(slug: string) {
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
}
/**
* Скачивает M3U для внешнего плеера. Ссылка на файл не годится: эндпоинт закрыт Bearer'ом, а
* `<a href>` заголовок не отправит — поэтому тянем через fetch и сохраняем как blob.
*/
export async function downloadIptvPlaylist() {
const { blob, fileName } = await apiDownload('/iptv/playlist.m3u', 'telewave.m3u')
const url = URL.createObjectURL(blob)
try {
const link = document.createElement('a')
link.href = url
link.download = fileName
link.click()
} finally {
URL.revokeObjectURL(url)
}
}
export function getEpg(slug: string, from?: Date, to?: Date) {
const query = new URLSearchParams()
if (from) query.set('from', from.toISOString())
+24
View File
@@ -67,6 +67,30 @@ async function parseError(response: Response): Promise<HttpError> {
}
}
/**
* Скачивание файла с той же авторизацией, что и обычные запросы. Отдельно от {@link apiRequest}:
* тот разбирает ответ как JSON, а здесь нужен сам поток и имя файла из Content-Disposition.
*/
export async function apiDownload(
path: string,
fallbackName: string,
skipRefresh = false,
): Promise<{ blob: Blob; fileName: string }> {
const headers: Record<string, string> = {}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
const response = await fetch(`/api${path}`, { headers, credentials: 'include' })
if (response.status === 401 && !skipRefresh && (await refreshAccessToken()))
return apiDownload(path, fallbackName, true)
if (!response.ok) throw await parseError(response)
const disposition = response.headers.get('Content-Disposition') ?? ''
const match = /filename="?([^";]+)"?/.exec(disposition)
return { blob: await response.blob(), fileName: match?.[1] ?? fallbackName }
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
+5
View File
@@ -66,6 +66,11 @@ export const en = {
offlineHint: 'No schedule or content yet. Check back later.',
retry: 'Retry',
numbersHint: '↑ / ↓ — switch channels by number',
iptv: 'IPTV playlist',
iptvHint:
'An M3U with every channel and the programme guide — for VLC, TiviMate and the like. Access to the air is baked into the file itself: do not share it.',
iptvDone: 'Playlist downloaded. The file grants access to the air — do not pass it on.',
iptvFailed: 'Could not build the playlist',
},
settings: {
title: 'Account settings',
+5
View File
@@ -65,6 +65,11 @@ export const ru = {
offline: 'Канал сейчас не в эфире',
offlineHint: 'Нет расписания или контента. Загляните позже.',
numbersHint: '↑ / ↓ — переключение каналов по номерам',
iptv: 'Плейлист IPTV',
iptvHint:
'M3U со всеми каналами и программой передач — для VLC, TiviMate и подобных. Доступ к эфиру зашит в сам файл: делиться им нельзя.',
iptvDone: 'Плейлист скачан. Файл открывает доступ к эфиру — не передавайте его никому.',
iptvFailed: 'Не удалось собрать плейлист',
retry: 'Повторить',
},
settings: {