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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user