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() {