Enhance configuration and routing for streaming features: update .env.example and appsettings.json to include new scheduler and streaming options, add streaming endpoint mapping in Program.cs, and integrate streaming services in DependencyInjection. Introduce segment path resolution in MediaPathResolver for asset management.

This commit is contained in:
Leonid Pershin
2026-07-24 16:07:37 +03:00
parent 4fa9dae37f
commit 1bbfd15907
18 changed files with 686 additions and 0 deletions
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetLivePlaylist;
public sealed record GetLivePlaylistQuery(string Slug, DateTimeOffset Now)
: IQuery<Result<LivePlaylistDto>>;
@@ -0,0 +1,91 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast.Live;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Streaming.GetLivePlaylist;
public sealed class GetLivePlaylistQueryHandler(
IAppDbContext dbContext,
IOptions<StreamingOptions> options
) : IQueryHandler<GetLivePlaylistQuery, Result<LivePlaylistDto>>
{
private readonly StreamingOptions _options = options.Value;
public async Task<Result<LivePlaylistDto>> Handle(
GetLivePlaylistQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.AsNoTracking()
.Where(c => c.Slug == query.Slug && c.IsEnabled)
.Select(c => new { c.Id, c.EpochUtc, c.FillerAssetId })
.FirstOrDefaultAsync(cancellationToken);
if (channel is null)
return Result.Failure<LivePlaylistDto>(ChannelErrors.NotFound);
var seg = _options.SegmentSeconds;
var window = _options.LiveWindowSegments;
// Загружаем записи, пересекающиеся с окном (с небольшим запасом назад).
var windowStartTime = query.Now.AddSeconds(-(window + 2) * seg);
var rawEntries = await dbContext.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channel.Id
&& e.StartsAtUtc < query.Now
&& e.EndsAtUtc > windowStartTime
)
.OrderBy(e => e.StartsAtUtc)
.Select(e => new
{
e.StartsAtUtc,
e.EndsAtUtc,
e.MediaAssetId,
})
.ToListAsync(cancellationToken);
var assetIds = rawEntries.Select(e => e.MediaAssetId).ToList();
if (channel.FillerAssetId is { } fillerId)
assetIds.Add(fillerId);
var segmentCounts = await dbContext.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id)
&& a.Status == MediaAssetStatus.Ready
&& a.SegmentCount != null
)
.Select(a => new { a.Id, a.SegmentCount })
.ToDictionaryAsync(a => a.Id, a => a.SegmentCount!.Value, cancellationToken);
var entries = rawEntries
.Where(e => segmentCounts.ContainsKey(e.MediaAssetId))
.Select(e => new LiveEntry(
e.StartsAtUtc,
e.EndsAtUtc,
e.MediaAssetId,
segmentCounts[e.MediaAssetId]
))
.ToList();
LiveFiller? filler = null;
if (channel.FillerAssetId is { } fid && segmentCounts.TryGetValue(fid, out var fillerSegments))
filler = new LiveFiller(fid, fillerSegments);
var playlist = LiveWindowCalculator.Build(
new LiveInput(query.Now, channel.EpochUtc, seg, window, entries, filler)
);
var dto = new LivePlaylistDto(
playlist.MediaSequence,
playlist.TargetDuration,
playlist.Segments
.Select(s => new LiveSegmentDto(s.AssetId, s.LocalIndex, s.Discontinuity))
.ToList()
);
return Result.Success(dto);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetPublicEpg;
public sealed record GetPublicEpgQuery(string Slug, DateTimeOffset FromUtc, DateTimeOffset ToUtc)
: IQuery<Result<IReadOnlyList<ScheduleEntryDto>>>;
@@ -0,0 +1,54 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetPublicEpg;
public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetPublicEpgQuery, Result<IReadOnlyList<ScheduleEntryDto>>>
{
public async Task<Result<IReadOnlyList<ScheduleEntryDto>>> Handle(
GetPublicEpgQuery query,
CancellationToken cancellationToken
)
{
var channelId = await dbContext.Channels.AsNoTracking()
.Where(c => c.Slug == query.Slug && c.IsEnabled)
.Select(c => (Guid?)c.Id)
.FirstOrDefaultAsync(cancellationToken);
if (channelId is null)
return Result.Failure<IReadOnlyList<ScheduleEntryDto>>(ChannelErrors.NotFound);
var entries = await dbContext.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.StartsAtUtc < query.ToUtc
&& e.EndsAtUtc > query.FromUtc
)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync(cancellationToken);
var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList();
var showNames = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var dtos = entries
.Select(e => new ScheduleEntryDto(
e.Id,
e.Kind,
e.MediaAssetId,
e.StartsAtUtc,
e.EndsAtUtc,
e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex
))
.ToList();
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed record ListPublicChannelsQuery : IQuery<IReadOnlyList<PublicChannelDto>>;
@@ -0,0 +1,21 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
{
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
ListPublicChannelsQuery query,
CancellationToken cancellationToken
)
{
return await dbContext.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
.OrderBy(c => c.Name)
.Select(c => new PublicChannelDto(c.Id, c.Slug, c.Name))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,11 @@
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(Guid Id, string Slug, string Name);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylistDto(
long MediaSequence,
int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments
);
@@ -0,0 +1,12 @@
namespace TeleWave.Application.Streaming;
/// <summary>Параметры раздачи эфира. Биндится к секции «Storage» (общая с обработкой медиа).</summary>
public sealed class StreamingOptions
{
public const string SectionName = "Storage";
public int SegmentSeconds { get; init; } = 2;
/// <summary>Сколько сегментов держать в скользящем окне live-плейлиста.</summary>
public int LiveWindowSegments { get; init; } = 10;
}