diff --git a/backend/src/TeleWave.Api/Endpoints/IptvEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/IptvEndpoints.cs new file mode 100644 index 0000000..0dca789 --- /dev/null +++ b/backend/src/TeleWave.Api/Endpoints/IptvEndpoints.cs @@ -0,0 +1,207 @@ +using System.Globalization; +using System.Text; +using System.Xml; +using LiteCqrs; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Streaming.GetIptvGuide; +using TeleWave.Infrastructure.Streaming; + +namespace TeleWave.Api.Endpoints; + +/// +/// Выгрузка для внешних IPTV-плееров: M3U со списком каналов и XMLTV с программой передач. +/// +/// Плеер не умеет ни Bearer, ни httpOnly-cookie — он умеет только открыть URL. Поэтому пропуск +/// зашит в сами ссылки файла долгим stream-токеном: **файл и есть доступ к эфиру**, и делиться им +/// нельзя. Срок — Storage:IptvTokenDays; блокировка зрителя обрывает доступ на ближайшей +/// перезагрузке плейлиста канала, а смена Jwt:SigningKey обнуляет все выданные разом. +/// +public static class IptvEndpoints +{ + /// Сколько суток программы класть в XMLTV: горизонт ленты и так неделя. + private static readonly TimeSpan GuideWindow = TimeSpan.FromDays(7); + + public static IEndpointRouteBuilder MapIptvEndpoints(this IEndpointRouteBuilder app) + { + var iptv = app.MapGroup("/api/iptv").WithTags("IPTV"); + + // Файл забирает браузер под обычной авторизацией — токен внутрь кладёт уже сервер. + iptv.MapGet("/playlist.m3u", Playlist).RequireAuthorization(); + // XMLTV тянет сам плеер, у него из пропусков только токен из ссылки. + iptv.MapGet("/epg.xml", Guide); + + return app; + } + + private static async Task Playlist( + ICurrentUser currentUser, + StreamTokenService tokens, + HttpRequest request, + ISender sender, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } userId) + return Results.Unauthorized(); + + // Окно нулевое: плейлисту нужен только список каналов, программу плеер возьмёт из XMLTV. + var now = DateTimeOffset.UtcNow; + var channels = await sender.Send(new GetIptvGuideQuery(now, now), cancellationToken); + + var (token, _) = tokens.IssueForIptv(userId); + var origin = Origin(request); + var body = RenderPlaylist(channels, origin, token); + + return Results.File( + Encoding.UTF8.GetBytes(body), + "audio/x-mpegurl", + fileDownloadName: "telewave.m3u" + ); + } + + private static async Task Guide( + HttpRequest request, + StreamTokenService tokens, + IIdentityService identity, + ISender sender, + CancellationToken cancellationToken + ) + { + if (tokens.Validate(request.Query["token"]) is not { } userId) + return Results.Unauthorized(); + // Та же сверка, что и у плейлиста канала: заблокированный зритель не должен продолжать + // тянуть гид по выданному раньше файлу. + var profile = await identity.GetProfileAsync(userId, cancellationToken); + if (profile is null || profile.IsBlocked) + return Results.Unauthorized(); + + var now = DateTimeOffset.UtcNow; + var channels = await sender.Send( + new GetIptvGuideQuery(now, now.Add(GuideWindow)), + cancellationToken + ); + + return Results.File( + Encoding.UTF8.GetBytes(RenderGuide(channels, Origin(request))), + "application/xml", + fileDownloadName: "telewave-epg.xml" + ); + } + + /// + /// Абсолютный адрес сервиса. Плееру нужны именно абсолютные ссылки — относительные ему не от + /// чего считать. Схема и хост берутся из запроса: TLS внешний, и их подставляет + /// ForwardedHeaders. + /// + private static string Origin(HttpRequest request) => $"{request.Scheme}://{request.Host}"; + + private static string RenderPlaylist( + IReadOnlyList channels, + string origin, + string token + ) + { + var sb = new StringBuilder(); + sb.Append( + CultureInfo.InvariantCulture, + $"#EXTM3U x-tvg-url=\"{origin}/api/iptv/epg.xml?token={token}\"\n" + ); + + foreach (var channel in channels) + { + var logo = channel.LogoImageId is { } imageId + ? $" tvg-logo=\"{origin}/api/images/{imageId}\"" + : string.Empty; + var number = channel.Number is { } value + ? $" tvg-chno=\"{value.ToString(CultureInfo.InvariantCulture)}\"" + : string.Empty; + var name = Attribute(channel.Name); + + sb.Append( + CultureInfo.InvariantCulture, + $"#EXTINF:-1 tvg-id=\"{Attribute(channel.Slug)}\" tvg-name=\"{name}\"{number}{logo} group-title=\"TeleWave\",{name}\n" + ); + sb.Append( + CultureInfo.InvariantCulture, + $"{origin}/api/channels/{channel.Slug}/live.m3u8?token={token}\n" + ); + } + + return sb.ToString(); + } + + /// + /// Значение атрибута M3U. Экранирования в формате нет вовсе, поэтому кавычки и переводы строк + /// не заменяются, а вырезаются: иначе название канала рвало бы строку файла. + /// + private static string Attribute(string value) => + value.Replace("\"", string.Empty).ReplaceLineEndings(" ").Trim(); + + private static string RenderGuide(IReadOnlyList channels, string origin) + { + var output = new StringBuilder(); + // Через XmlWriter, а не склейкой строк: в названия и описания приходит текст из библиотеки, + // и экранировать его руками — верный способ однажды отдать битый XML. + using var writer = XmlWriter.Create( + output, + new XmlWriterSettings { Indent = true, Encoding = Encoding.UTF8 } + ); + + writer.WriteStartDocument(); + writer.WriteStartElement("tv"); + writer.WriteAttributeString("generator-info-name", "TeleWave"); + + foreach (var channel in channels) + { + writer.WriteStartElement("channel"); + writer.WriteAttributeString("id", channel.Slug); + writer.WriteElementString("display-name", channel.Name); + if (channel.Number is { } number) + writer.WriteElementString( + "display-name", + number.ToString(CultureInfo.InvariantCulture) + ); + if (channel.LogoImageId is { } logoId) + { + writer.WriteStartElement("icon"); + writer.WriteAttributeString("src", $"{origin}/api/images/{logoId}"); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + + foreach (var channel in channels) + foreach (var programme in channel.Programmes) + { + writer.WriteStartElement("programme"); + writer.WriteAttributeString("start", XmltvTime(programme.StartsAtUtc)); + writer.WriteAttributeString("stop", XmltvTime(programme.EndsAtUtc)); + writer.WriteAttributeString("channel", channel.Slug); + + writer.WriteElementString("title", programme.Title); + if (!string.IsNullOrWhiteSpace(programme.SubTitle)) + writer.WriteElementString("sub-title", programme.SubTitle); + if (!string.IsNullOrWhiteSpace(programme.Description)) + writer.WriteElementString("desc", programme.Description); + if (programme.ImageId is { } imageId) + { + writer.WriteStartElement("icon"); + writer.WriteAttributeString("src", $"{origin}/api/images/{imageId}"); + writer.WriteEndElement(); + } + + writer.WriteEndElement(); + } + + writer.WriteEndElement(); + writer.WriteEndDocument(); + writer.Flush(); + + return output.ToString(); + } + + /// Время XMLTV: YYYYMMDDHHMMSS +0000. Отдаём в UTC — плеер сдвинет сам. + private static string XmltvTime(DateTimeOffset moment) => + moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture) + + " +0000"; +} diff --git a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs index 8a13440..fa9fedd 100644 --- a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs @@ -117,7 +117,9 @@ public static class StreamingEndpoints // Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить, // что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу, // не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто). - if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId) + // Для IPTV это единственная точка, где блокировка вообще может сработать: у внешнего плеера + // пропуск долгий и перевыпуску не подлежит. + if (Viewer(request, tokens) is not { } userId) return Results.Unauthorized(); var profile = await identity.GetProfileAsync(userId, cancellationToken); if (profile is null || profile.IsBlocked) @@ -132,8 +134,15 @@ public static class StreamingEndpoints if (result.Value.Segments.Count == 0) return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + // Токен из ссылки тянется дальше в адреса сегментов: плеер разрешает их относительно + // плейлиста и query при этом теряет, а cookie у него нет — без этого сегменты дадут 401. + var token = request.Query["token"].ToString(); + var suffix = string.IsNullOrEmpty(token) + ? string.Empty + : $"?token={Uri.EscapeDataString(token)}"; + response.Headers.CacheControl = "no-cache"; - return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl"); + return Results.Text(Render(result.Value, suffix), "application/vnd.apple.mpegurl"); } private static IResult Segment( @@ -145,7 +154,7 @@ public static class StreamingEndpoints MediaPathResolver paths ) { - if (tokens.Validate(request.Cookies[StreamCookieName]) is null) + if (Viewer(request, tokens) is null) return Results.Unauthorized(); if (!SegmentFiles.IsSegmentName(file)) return Results.NotFound(); @@ -157,7 +166,15 @@ public static class StreamingEndpoints return Results.File(path, "video/mp2t", enableRangeProcessing: true); } - private static string Render(LivePlaylistDto playlist) + /// + /// Кто смотрит: cookie — для браузера, токен в ссылке — для внешнего IPTV-плеера, который + /// cookie не носит. Токен один и тот же по формату, различается только сроком выдачи. + /// + private static Guid? Viewer(HttpRequest request, StreamTokenService tokens) => + tokens.Validate(request.Cookies[StreamCookieName]) + ?? tokens.Validate(request.Query["token"]); + + private static string Render(LivePlaylistDto playlist, string segmentSuffix) { var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture); var sb = new StringBuilder(); @@ -179,7 +196,7 @@ public static class StreamingEndpoints sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n"); sb.Append( CultureInfo.InvariantCulture, - $"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n" + $"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts{segmentSuffix}\n" ); } diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs index bfc62aa..103f515 100644 --- a/backend/src/TeleWave.Api/Program.cs +++ b/backend/src/TeleWave.Api/Program.cs @@ -134,6 +134,7 @@ app.MapBumperEndpoints(); app.MapJunctionEndpoints(); app.MapChannelEndpoints(); app.MapStreamingEndpoints(); +app.MapIptvEndpoints(); app.MapMaintenanceEndpoints(); app.MapStorageEndpoints(); app.MapSettingsEndpoints(); diff --git a/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQuery.cs b/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQuery.cs new file mode 100644 index 0000000..2294ed8 --- /dev/null +++ b/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQuery.cs @@ -0,0 +1,10 @@ +using LiteCqrs; + +namespace TeleWave.Application.Streaming.GetIptvGuide; + +/// +/// Телегид сразу по всем включённым каналам — под XMLTV, который IPTV-плеер тянет одним файлом. +/// Публичный EPG отдаётся по каналу за раз: там это экран зрителя, здесь — выгрузка целиком. +/// +public sealed record GetIptvGuideQuery(DateTimeOffset FromUtc, DateTimeOffset ToUtc) + : IQuery>; diff --git a/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQueryHandler.cs new file mode 100644 index 0000000..8bf7761 --- /dev/null +++ b/backend/src/TeleWave.Application/Streaming/GetIptvGuide/GetIptvGuideQueryHandler.cs @@ -0,0 +1,141 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Domain.Broadcast; + +namespace TeleWave.Application.Streaming.GetIptvGuide; + +public sealed class GetIptvGuideQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + GetIptvGuideQuery query, + CancellationToken cancellationToken + ) + { + var channels = await dbContext + .Channels.AsNoTracking() + .Where(c => c.IsEnabled) + .OrderBy(c => c.Number == null) + .ThenBy(c => c.Number) + .ThenBy(c => c.Name) + .Select(c => new + { + c.Id, + c.Slug, + c.Name, + c.Number, + c.LogoImageId, + }) + .ToListAsync(cancellationToken); + if (channels.Count == 0) + return []; + + // Записи всех каналов одним запросом: каналов десятки, окно — сутки-неделя, и ходить + // за каждым отдельно значило бы разослать столько же одинаковых запросов. + var channelIds = channels.Select(c => c.Id).ToList(); + var entries = await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => + channelIds.Contains(e.ChannelId) + && e.Kind == ScheduleEntryKind.Program + && e.ShowId != null + && e.StartsAtUtc < query.ToUtc + && e.EndsAtUtc > query.FromUtc + ) + .OrderBy(e => e.StartsAtUtc) + .Select(e => new + { + e.ChannelId, + e.StartsAtUtc, + e.EndsAtUtc, + ShowId = e.ShowId!.Value, + e.MediaAssetId, + }) + .ToListAsync(cancellationToken); + + var showIds = entries.Select(e => e.ShowId).Distinct().ToList(); + var shows = await dbContext + .Shows.AsNoTracking() + .Where(s => showIds.Contains(s.Id)) + .Select(s => new + { + s.Id, + s.Name, + s.Description, + s.PosterImageId, + }) + .ToDictionaryAsync(s => s.Id, cancellationToken); + + // Серии — за названием и описанием конкретного выпуска; ключ тот же, что в публичном EPG. + var assetIds = entries.Select(e => e.MediaAssetId).Distinct().ToList(); + var episodes = await dbContext + .Shows.AsNoTracking() + .SelectMany(s => s.Episodes) + .Where(e => showIds.Contains(e.ShowId) && assetIds.Contains(e.MediaAssetId)) + .Select(e => new + { + e.ShowId, + e.MediaAssetId, + e.Title, + e.Overview, + e.StillImageId, + }) + .ToListAsync(cancellationToken); + var episodeByKey = episodes + .GroupBy(e => (e.ShowId, e.MediaAssetId)) + .ToDictionary(g => g.Key, g => g.First()); + + var byChannel = entries.ToLookup(e => e.ChannelId); + var result = new List(channels.Count); + + foreach (var channel in channels) + { + var programmes = new List(); + Guid? lastShowId = null; + + foreach (var entry in byChannel[channel.Id].OrderBy(e => e.StartsAtUtc)) + { + var show = shows.GetValueOrDefault(entry.ShowId); + var episode = episodeByKey.GetValueOrDefault((entry.ShowId, entry.MediaAssetId)); + + // Серии одного шоу встык — это один блок программы. Склеиваем по id, а не по + // названию: у разных шоу оно может совпасть. Разрыв во времени блок рвёт — между + // выходами шла реклама, и объявлять это одной передачей нельзя. + if ( + lastShowId == entry.ShowId + && programmes.Count > 0 + && programmes[^1].EndsAtUtc == entry.StartsAtUtc + ) + { + programmes[^1] = programmes[^1] with { EndsAtUtc = entry.EndsAtUtc }; + continue; + } + + lastShowId = entry.ShowId; + programmes.Add( + new IptvProgrammeDto( + entry.StartsAtUtc, + entry.EndsAtUtc, + show?.Name ?? channel.Name, + episode?.Title, + episode?.Overview ?? show?.Description, + episode?.StillImageId ?? show?.PosterImageId + ) + ); + } + + result.Add( + new IptvChannelDto( + channel.Slug, + channel.Name, + channel.Number, + channel.LogoImageId, + programmes + ) + ); + } + + return result; + } +} diff --git a/backend/src/TeleWave.Application/Streaming/GetIptvGuide/IptvDtos.cs b/backend/src/TeleWave.Application/Streaming/GetIptvGuide/IptvDtos.cs new file mode 100644 index 0000000..2e9c146 --- /dev/null +++ b/backend/src/TeleWave.Application/Streaming/GetIptvGuide/IptvDtos.cs @@ -0,0 +1,23 @@ +namespace TeleWave.Application.Streaming.GetIptvGuide; + +/// Канал с его программой: то, из чего собирается пара «плейлист + XMLTV». +public sealed record IptvChannelDto( + string Slug, + string Name, + int? Number, + Guid? LogoImageId, + IReadOnlyList Programmes +); + +/// +/// Одна передача. Подряд идущие серии одного шоу склеены в блок — как и на экране зрителя: в гиде +/// нужна программа, а не список файлов, и врезки с заставками сюда не попадают вовсе. +/// +public sealed record IptvProgrammeDto( + DateTimeOffset StartsAtUtc, + DateTimeOffset EndsAtUtc, + string Title, + string? SubTitle, + string? Description, + Guid? ImageId +); diff --git a/backend/src/TeleWave.Application/Streaming/StreamingOptions.cs b/backend/src/TeleWave.Application/Streaming/StreamingOptions.cs index 1e3d979..494b6a3 100644 --- a/backend/src/TeleWave.Application/Streaming/StreamingOptions.cs +++ b/backend/src/TeleWave.Application/Streaming/StreamingOptions.cs @@ -14,4 +14,12 @@ public sealed class StreamingOptions /// после блокировки/логаута; фронт перевыпускает cookie через /watch, пока идёт просмотр. Блокировка /// пользователя при этом отражается почти сразу — при очередной перезагрузке live-плейлиста. public int StreamTokenMinutes { get; init; } = 30; + + /// + /// Срок жизни токена в IPTV-плейлисте, сутки. Внешний плеер не умеет ни JWT, ни cookie, поэтому + /// пропуск зашит прямо в ссылки файла — и живёт он ровно столько, сколько тут указано. Отозвать + /// отдельный файл нечем: помогает блокировка зрителя (её сверяет плейлист канала) и смена + /// Jwt:SigningKey, которая обнуляет разом все выданные. + /// + public int IptvTokenDays { get; init; } = 30; } diff --git a/backend/src/TeleWave.Infrastructure/Streaming/StreamTokenService.cs b/backend/src/TeleWave.Infrastructure/Streaming/StreamTokenService.cs index 80af543..d29047d 100644 --- a/backend/src/TeleWave.Infrastructure/Streaming/StreamTokenService.cs +++ b/backend/src/TeleWave.Infrastructure/Streaming/StreamTokenService.cs @@ -21,11 +21,24 @@ public sealed class StreamTokenService( private readonly TimeSpan _ttl = TimeSpan.FromMinutes( Math.Max(1, streamingOptions.Value.StreamTokenMinutes) ); + private readonly TimeSpan _iptvTtl = TimeSpan.FromDays( + Math.Max(1, streamingOptions.Value.IptvTokenDays) + ); private readonly byte[] _key = Encoding.UTF8.GetBytes(jwtOptions.Value.SigningKey); - public (string Token, DateTimeOffset ExpiresAt) Issue(Guid userId) + public (string Token, DateTimeOffset ExpiresAt) Issue(Guid userId) => Issue(userId, _ttl); + + /// + /// Долгий токен для IPTV-плейлиста. Отдельного вида у него нет намеренно: он попадает в те же + /// ссылки на плейлист и сегменты, что и cookie, и проверяется тем же . + /// Отличается только сроком — внешний плеер перевыпустить пропуск не может. + /// + public (string Token, DateTimeOffset ExpiresAt) IssueForIptv(Guid userId) => + Issue(userId, _iptvTtl); + + private (string Token, DateTimeOffset ExpiresAt) Issue(Guid userId, TimeSpan ttl) { - var expiresAt = DateTimeOffset.UtcNow.Add(_ttl); + var expiresAt = DateTimeOffset.UtcNow.Add(ttl); var payload = $"{userId:N}.{expiresAt.ToUnixTimeSeconds()}"; var token = $"{Base64Url(Encoding.UTF8.GetBytes(payload))}.{Base64Url(Sign(payload))}"; return (token, expiresAt); diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 1e63d8a..ea14d95 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -1033,6 +1033,22 @@ seed = hash(channelId, date, slotId, occurrenceInDay) Регулируется по силе, по умолчанию выключен: попадает точно в цель для канала «как в 96-м», но переборщить очень легко. +**Выгрузка для внешних IPTV-плееров.** Кнопка на экране эфира отдаёт `telewave.m3u` — список всех +включённых каналов и ссылка на XMLTV с программой передач (`/api/iptv/epg.xml`, горизонт 7 суток, +подряд идущие серии одного шоу склеены в одну передачу, врезки и заставки в гид не попадают). + +Плеер не умеет ни Bearer, ни httpOnly-cookie — только открыть URL. Поэтому пропуск зашит прямо +в ссылки файла долгим stream-токеном (`Storage:IptvTokenDays`, по умолчанию 30 суток), и его же +принимают плейлист канала и сегменты — рядом с cookie, тем же форматом и той же проверкой подписи. +Токен тянется дальше в адреса сегментов внутри `live.m3u8`: плеер разрешает их относительно +плейлиста и query при этом теряет. + +Из этого следует прямо: **файл и есть доступ к эфиру**, и делиться им нельзя — о чём говорится +в момент скачивания. Отозвать отдельный файл нечем: помогает блокировка зрителя (её сверяет +плейлист канала на каждой перезагрузке — для IPTV это единственная точка, где она вообще +срабатывает) и смена `Jwt:SigningKey`, которая обнуляет все выданные токены разом. Реестра +выданных файлов не заводим, пока не появится запрос на отзыв поштучно. + ### 6.6.1. Откат правок У плашки «правила изменены» две кнопки: применить и **сбросить**. Слои и слоты правятся на месте, diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx index 8ee0e61..fa07579 100644 --- a/frontend/src/features/streaming/AirPage.tsx +++ b/frontend/src/features/streaming/AirPage.tsx @@ -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() {

{t('nav.dashboard')}

- {numbersEnabled && ( - {t('air.numbersHint')} - )} +
+ {numbersEnabled && ( + {t('air.numbersHint')} + )} + +
diff --git a/frontend/src/features/streaming/api.ts b/frontend/src/features/streaming/api.ts index a5e06b7..451f4a8 100644 --- a/frontend/src/features/streaming/api.ts +++ b/frontend/src/features/streaming/api.ts @@ -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(`/channels/${slug}/watch`, { method: 'POST' }) } +/** + * Скачивает M3U для внешнего плеера. Ссылка на файл не годится: эндпоинт закрыт Bearer'ом, а + * `` заголовок не отправит — поэтому тянем через 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()) diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index 9d5b5a6..bef3503 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -67,6 +67,30 @@ async function parseError(response: Response): Promise { } } +/** + * Скачивание файла с той же авторизацией, что и обычные запросы. Отдельно от {@link apiRequest}: + * тот разбирает ответ как JSON, а здесь нужен сам поток и имя файла из Content-Disposition. + */ +export async function apiDownload( + path: string, + fallbackName: string, + skipRefresh = false, +): Promise<{ blob: Blob; fileName: string }> { + const headers: Record = {} + 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(path: string, options: RequestOptions = {}): Promise { const headers: Record = {} if (accessToken) headers.Authorization = `Bearer ${accessToken}` diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index 1ff8750..7cc6428 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -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', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 7978291..ff72024 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -65,6 +65,11 @@ export const ru = { offline: 'Канал сейчас не в эфире', offlineHint: 'Нет расписания или контента. Загляните позже.', numbersHint: '↑ / ↓ — переключение каналов по номерам', + iptv: 'Плейлист IPTV', + iptvHint: + 'M3U со всеми каналами и программой передач — для VLC, TiviMate и подобных. Доступ к эфиру зашит в сам файл: делиться им нельзя.', + iptvDone: 'Плейлист скачан. Файл открывает доступ к эфиру — не передавайте его никому.', + iptvFailed: 'Не удалось собрать плейлист', retry: 'Повторить', }, settings: {