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