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
@@ -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;
/// <summary>
/// Выгрузка для внешних IPTV-плееров: M3U со списком каналов и XMLTV с программой передач.
///
/// Плеер не умеет ни Bearer, ни httpOnly-cookie — он умеет только открыть URL. Поэтому пропуск
/// зашит в сами ссылки файла долгим stream-токеном: **файл и есть доступ к эфиру**, и делиться им
/// нельзя. Срок — <c>Storage:IptvTokenDays</c>; блокировка зрителя обрывает доступ на ближайшей
/// перезагрузке плейлиста канала, а смена <c>Jwt:SigningKey</c> обнуляет все выданные разом.
/// </summary>
public static class IptvEndpoints
{
/// <summary>Сколько суток программы класть в XMLTV: горизонт ленты и так неделя.</summary>
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<IResult> 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<IResult> 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"
);
}
/// <summary>
/// Абсолютный адрес сервиса. Плееру нужны именно абсолютные ссылки — относительные ему не от
/// чего считать. Схема и хост берутся из запроса: TLS внешний, и их подставляет
/// <c>ForwardedHeaders</c>.
/// </summary>
private static string Origin(HttpRequest request) => $"{request.Scheme}://{request.Host}";
private static string RenderPlaylist(
IReadOnlyList<IptvChannelDto> 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();
}
/// <summary>
/// Значение атрибута M3U. Экранирования в формате нет вовсе, поэтому кавычки и переводы строк
/// не заменяются, а вырезаются: иначе название канала рвало бы строку файла.
/// </summary>
private static string Attribute(string value) =>
value.Replace("\"", string.Empty).ReplaceLineEndings(" ").Trim();
private static string RenderGuide(IReadOnlyList<IptvChannelDto> 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();
}
/// <summary>Время XMLTV: <c>YYYYMMDDHHMMSS +0000</c>. Отдаём в UTC — плеер сдвинет сам.</summary>
private static string XmltvTime(DateTimeOffset moment) =>
moment.ToUniversalTime().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)
+ " +0000";
}
@@ -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)
/// <summary>
/// Кто смотрит: cookie — для браузера, токен в ссылке — для внешнего IPTV-плеера, который
/// cookie не носит. Токен один и тот же по формату, различается только сроком выдачи.
/// </summary>
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"
);
}
+1
View File
@@ -134,6 +134,7 @@ app.MapBumperEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapIptvEndpoints();
app.MapMaintenanceEndpoints();
app.MapStorageEndpoints();
app.MapSettingsEndpoints();
@@ -0,0 +1,10 @@
using LiteCqrs;
namespace TeleWave.Application.Streaming.GetIptvGuide;
/// <summary>
/// Телегид сразу по всем включённым каналам — под XMLTV, который IPTV-плеер тянет одним файлом.
/// Публичный EPG отдаётся по каналу за раз: там это экран зрителя, здесь — выгрузка целиком.
/// </summary>
public sealed record GetIptvGuideQuery(DateTimeOffset FromUtc, DateTimeOffset ToUtc)
: IQuery<IReadOnlyList<IptvChannelDto>>;
@@ -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<GetIptvGuideQuery, IReadOnlyList<IptvChannelDto>>
{
public async Task<IReadOnlyList<IptvChannelDto>> 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<IptvChannelDto>(channels.Count);
foreach (var channel in channels)
{
var programmes = new List<IptvProgrammeDto>();
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;
}
}
@@ -0,0 +1,23 @@
namespace TeleWave.Application.Streaming.GetIptvGuide;
/// <summary>Канал с его программой: то, из чего собирается пара «плейлист + XMLTV».</summary>
public sealed record IptvChannelDto(
string Slug,
string Name,
int? Number,
Guid? LogoImageId,
IReadOnlyList<IptvProgrammeDto> Programmes
);
/// <summary>
/// Одна передача. Подряд идущие серии одного шоу склеены в блок — как и на экране зрителя: в гиде
/// нужна программа, а не список файлов, и врезки с заставками сюда не попадают вовсе.
/// </summary>
public sealed record IptvProgrammeDto(
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string Title,
string? SubTitle,
string? Description,
Guid? ImageId
);
@@ -14,4 +14,12 @@ public sealed class StreamingOptions
/// после блокировки/логаута; фронт перевыпускает cookie через /watch, пока идёт просмотр. Блокировка
/// пользователя при этом отражается почти сразу — при очередной перезагрузке live-плейлиста.</summary>
public int StreamTokenMinutes { get; init; } = 30;
/// <summary>
/// Срок жизни токена в IPTV-плейлисте, сутки. Внешний плеер не умеет ни JWT, ни cookie, поэтому
/// пропуск зашит прямо в ссылки файла — и живёт он ровно столько, сколько тут указано. Отозвать
/// отдельный файл нечем: помогает блокировка зрителя (её сверяет плейлист канала) и смена
/// <c>Jwt:SigningKey</c>, которая обнуляет разом все выданные.
/// </summary>
public int IptvTokenDays { get; init; } = 30;
}
@@ -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);
/// <summary>
/// Долгий токен для IPTV-плейлиста. Отдельного вида у него нет намеренно: он попадает в те же
/// ссылки на плейлист и сегменты, что и cookie, и проверяется тем же <see cref="Validate"/>.
/// Отличается только сроком — внешний плеер перевыпустить пропуск не может.
/// </summary>
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);