Add IPTV playlist functionality and enhance streaming endpoints
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:
@@ -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 запрос на перезагрузку) сверить,
|
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
|
||||||
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
||||||
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
||||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
|
// Для IPTV это единственная точка, где блокировка вообще может сработать: у внешнего плеера
|
||||||
|
// пропуск долгий и перевыпуску не подлежит.
|
||||||
|
if (Viewer(request, tokens) is not { } userId)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
||||||
if (profile is null || profile.IsBlocked)
|
if (profile is null || profile.IsBlocked)
|
||||||
@@ -132,8 +134,15 @@ public static class StreamingEndpoints
|
|||||||
if (result.Value.Segments.Count == 0)
|
if (result.Value.Segments.Count == 0)
|
||||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
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";
|
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(
|
private static IResult Segment(
|
||||||
@@ -145,7 +154,7 @@ public static class StreamingEndpoints
|
|||||||
MediaPathResolver paths
|
MediaPathResolver paths
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
|
if (Viewer(request, tokens) is null)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
if (!SegmentFiles.IsSegmentName(file))
|
if (!SegmentFiles.IsSegmentName(file))
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
@@ -157,7 +166,15 @@ public static class StreamingEndpoints
|
|||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
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 extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -179,7 +196,7 @@ public static class StreamingEndpoints
|
|||||||
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
|
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
|
||||||
sb.Append(
|
sb.Append(
|
||||||
CultureInfo.InvariantCulture,
|
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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ app.MapBumperEndpoints();
|
|||||||
app.MapJunctionEndpoints();
|
app.MapJunctionEndpoints();
|
||||||
app.MapChannelEndpoints();
|
app.MapChannelEndpoints();
|
||||||
app.MapStreamingEndpoints();
|
app.MapStreamingEndpoints();
|
||||||
|
app.MapIptvEndpoints();
|
||||||
app.MapMaintenanceEndpoints();
|
app.MapMaintenanceEndpoints();
|
||||||
app.MapStorageEndpoints();
|
app.MapStorageEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
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, пока идёт просмотр. Блокировка
|
/// после блокировки/логаута; фронт перевыпускает cookie через /watch, пока идёт просмотр. Блокировка
|
||||||
/// пользователя при этом отражается почти сразу — при очередной перезагрузке live-плейлиста.</summary>
|
/// пользователя при этом отражается почти сразу — при очередной перезагрузке live-плейлиста.</summary>
|
||||||
public int StreamTokenMinutes { get; init; } = 30;
|
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(
|
private readonly TimeSpan _ttl = TimeSpan.FromMinutes(
|
||||||
Math.Max(1, streamingOptions.Value.StreamTokenMinutes)
|
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);
|
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 payload = $"{userId:N}.{expiresAt.ToUnixTimeSeconds()}";
|
||||||
var token = $"{Base64Url(Encoding.UTF8.GetBytes(payload))}.{Base64Url(Sign(payload))}";
|
var token = $"{Base64Url(Encoding.UTF8.GetBytes(payload))}.{Base64Url(Sign(payload))}";
|
||||||
return (token, expiresAt);
|
return (token, expiresAt);
|
||||||
|
|||||||
@@ -1033,6 +1033,22 @@ seed = hash(channelId, date, slotId, occurrenceInDay)
|
|||||||
Регулируется по силе, по умолчанию выключен: попадает точно в цель для канала «как в 96-м», но
|
Регулируется по силе, по умолчанию выключен: попадает точно в цель для канала «как в 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. Откат правок
|
### 6.6.1. Откат правок
|
||||||
|
|
||||||
У плашки «правила изменены» две кнопки: применить и **сбросить**. Слои и слоты правятся на месте,
|
У плашки «правила изменены» две кнопки: применить и **сбросить**. Слои и слоты правятся на месте,
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 { qk } from '@/shared/api/query-keys'
|
||||||
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
import type { PublicEpgEntryDto } from '@/shared/api/types'
|
||||||
import { cn } from '@/shared/lib/cn'
|
import { cn } from '@/shared/lib/cn'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { ChannelPlayer } from './ChannelPlayer'
|
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) {
|
function formatTime(iso: string) {
|
||||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
@@ -51,6 +59,21 @@ export function AirPage() {
|
|||||||
setAttempt((a) => a + 1)
|
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({
|
const { data: channels, isLoading } = useQuery({
|
||||||
queryKey: qk.air.channels,
|
queryKey: qk.air.channels,
|
||||||
queryFn: listChannels,
|
queryFn: listChannels,
|
||||||
@@ -166,9 +189,21 @@ export function AirPage() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
|
||||||
{numbersEnabled && (
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<span className="text-xs text-muted-foreground">{t('air.numbersHint')}</span>
|
{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>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||||
|
|||||||
@@ -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'
|
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
|
||||||
|
|
||||||
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
|
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
|
||||||
@@ -20,6 +20,23 @@ export function watchChannel(slug: string) {
|
|||||||
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
|
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) {
|
export function getEpg(slug: string, from?: Date, to?: Date) {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
if (from) query.set('from', from.toISOString())
|
if (from) query.set('from', from.toISOString())
|
||||||
|
|||||||
@@ -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> {
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export const en = {
|
|||||||
offlineHint: 'No schedule or content yet. Check back later.',
|
offlineHint: 'No schedule or content yet. Check back later.',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
numbersHint: '↑ / ↓ — switch channels by number',
|
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: {
|
settings: {
|
||||||
title: 'Account settings',
|
title: 'Account settings',
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ export const ru = {
|
|||||||
offline: 'Канал сейчас не в эфире',
|
offline: 'Канал сейчас не в эфире',
|
||||||
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
offlineHint: 'Нет расписания или контента. Загляните позже.',
|
||||||
numbersHint: '↑ / ↓ — переключение каналов по номерам',
|
numbersHint: '↑ / ↓ — переключение каналов по номерам',
|
||||||
|
iptv: 'Плейлист IPTV',
|
||||||
|
iptvHint:
|
||||||
|
'M3U со всеми каналами и программой передач — для VLC, TiviMate и подобных. Доступ к эфиру зашит в сам файл: делиться им нельзя.',
|
||||||
|
iptvDone: 'Плейлист скачан. Файл открывает доступ к эфиру — не передавайте его никому.',
|
||||||
|
iptvFailed: 'Не удалось собрать плейлист',
|
||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
Reference in New Issue
Block a user