feat(core): track direct stream sessions

refs #99
This commit is contained in:
2026-07-04 19:57:50 +02:00
parent 3e8cfa6288
commit 9cd107bc8d
9 changed files with 375 additions and 7 deletions
@@ -0,0 +1,74 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Streaming;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Streaming;
[TestFixture]
public class DirectStreamSessionTrackerTests
{
[Test]
public void Should_Track_Concurrent_Viewers_Per_Channel()
{
var tracker = new DirectStreamSessionTracker();
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
using IDisposable session2 = tracker.Register("1", StreamingMode.HttpLiveStreamingDirect);
using IDisposable session3 = tracker.Register("2", StreamingMode.TransportStream);
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(2);
tracker.GetViewerCount("2").ShouldBe(1);
tracker.GetActiveSessions().Count.ShouldBe(3);
}
[Test]
public void Should_Remove_Only_Disposed_Session()
{
var tracker = new DirectStreamSessionTracker();
IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
IDisposable session2 = tracker.Register("1", StreamingMode.TransportStream);
session1.Dispose();
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(1);
session2.Dispose();
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public void Should_Dispose_Registration_Only_Once()
{
var tracker = new DirectStreamSessionTracker();
IDisposable session = tracker.Register("1", StreamingMode.TransportStream);
session.Dispose();
session.Dispose();
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public void Should_Filter_Active_Sessions_By_Channel()
{
var tracker = new DirectStreamSessionTracker();
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
using IDisposable session2 = tracker.Register("2", StreamingMode.HttpLiveStreamingDirect);
IReadOnlyCollection<DirectStreamSession> sessions = tracker.GetActiveSessions("2");
sessions.Count.ShouldBe(1);
sessions.Single().ChannelNumber.ShouldBe("2");
sessions.Single().StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingDirect);
}
}
@@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Streaming;
namespace ErsatzTV.Core.Interfaces.Streaming;
public interface IDirectStreamSessionTracker
{
IDisposable Register(string channelNumber, StreamingMode streamingMode);
bool IsActive(string channelNumber);
int GetViewerCount(string channelNumber);
IReadOnlyCollection<DirectStreamSession> GetActiveSessions();
IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber);
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Streaming;
public record DirectStreamSession(
Guid Id,
string ChannelNumber,
StreamingMode StreamingMode,
DateTimeOffset StartedAt);
@@ -0,0 +1,64 @@
using System.Collections.Concurrent;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Streaming;
namespace ErsatzTV.Core.Streaming;
public class DirectStreamSessionTracker : IDirectStreamSessionTracker
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, DirectStreamSession>> _sessions = new();
public IDisposable Register(string channelNumber, StreamingMode streamingMode)
{
var session = new DirectStreamSession(Guid.NewGuid(), channelNumber, streamingMode, DateTimeOffset.Now);
ConcurrentDictionary<Guid, DirectStreamSession> channelSessions =
_sessions.GetOrAdd(channelNumber, _ => new ConcurrentDictionary<Guid, DirectStreamSession>());
channelSessions.TryAdd(session.Id, session);
return new Registration(this, session);
}
public bool IsActive(string channelNumber) => GetViewerCount(channelNumber) > 0;
public int GetViewerCount(string channelNumber) =>
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
? channelSessions.Count
: 0;
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions() =>
_sessions.Values.SelectMany(s => s.Values).ToList();
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber) =>
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
? channelSessions.Values.ToList()
: [];
private void Remove(DirectStreamSession session)
{
if (!_sessions.TryGetValue(session.ChannelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions))
{
return;
}
channelSessions.TryRemove(session.Id, out _);
if (channelSessions.IsEmpty)
{
_sessions.TryRemove(session.ChannelNumber, out _);
}
}
private sealed class Registration(DirectStreamSessionTracker tracker, DirectStreamSession session) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
tracker.Remove(session);
}
}
}
}
@@ -0,0 +1,163 @@
using ErsatzTV.Controllers;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Streaming;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class TrackedFileStreamResultTests
{
[Test]
public async Task Should_Track_Session_Only_While_Result_Executes()
{
var tracker = new DirectStreamSessionTracker();
var stream = new BlockingReadStream();
TrackedFileStreamResult result = new(
stream,
"video/mp2t",
tracker,
"1",
StreamingMode.TransportStream);
ActionContext context = GetActionContext();
tracker.IsActive("1").ShouldBeFalse();
Task execute = result.ExecuteResultAsync(context);
await stream.WaitForRead();
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(1);
stream.Complete();
await execute;
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public async Task Should_Remove_Session_When_Response_Stream_Fails()
{
var tracker = new DirectStreamSessionTracker();
TrackedFileStreamResult result = new(
new ThrowingReadStream(new IOException("stream failed")),
"video/mp2t",
tracker,
"1",
StreamingMode.HttpLiveStreamingDirect);
Func<Task> execute = () => result.ExecuteResultAsync(GetActionContext());
await execute.ShouldThrowAsync<IOException>();
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public async Task Should_Remove_Session_When_Response_Stream_Is_Aborted()
{
var tracker = new DirectStreamSessionTracker();
TrackedFileStreamResult result = new(
new ThrowingReadStream(new OperationCanceledException("client aborted")),
"video/mp2t",
tracker,
"1",
StreamingMode.HttpLiveStreamingDirect);
await result.ExecuteResultAsync(GetActionContext());
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
private static ActionContext GetActionContext()
{
var httpContext = new DefaultHttpContext();
httpContext.RequestServices = new ServiceCollection()
.AddLogging()
.AddControllers()
.Services
.BuildServiceProvider();
httpContext.Response.Body = new MemoryStream();
return new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor());
}
private sealed class BlockingReadStream : Stream
{
private readonly TaskCompletionSource _continue = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _readStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => 0;
public override long Position { get; set; }
public Task WaitForRead() => _readStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
public void Complete() => _continue.SetResult();
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
_readStarted.TrySetResult();
return ReadAfterContinue();
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
private async ValueTask<int> ReadAfterContinue()
{
await _continue.Task;
return 0;
}
}
private sealed class ThrowingReadStream(Exception exception) : Stream
{
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => 0;
public override long Position { get; set; }
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
ValueTask.FromException<int>(exception);
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) =>
ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
public override int Read(byte[] buffer, int offset, int count) => throw exception;
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
+17 -3
View File
@@ -25,6 +25,7 @@ namespace ErsatzTV.Controllers;
[ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))] [ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))]
public class IptvController : StreamingControllerBase public class IptvController : StreamingControllerBase
{ {
private readonly IDirectStreamSessionTracker _directStreamSessionTracker;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILogger<IptvController> _logger; private readonly ILogger<IptvController> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
@@ -33,9 +34,11 @@ public class IptvController : StreamingControllerBase
IMediator mediator, IMediator mediator,
IGraphicsEngine graphicsEngine, IGraphicsEngine graphicsEngine,
ILogger<IptvController> logger, ILogger<IptvController> logger,
IFFmpegSegmenterService ffmpegSegmenterService) IFFmpegSegmenterService ffmpegSegmenterService,
IDirectStreamSessionTracker directStreamSessionTracker)
: base(graphicsEngine, logger) : base(graphicsEngine, logger)
{ {
_directStreamSessionTracker = directStreamSessionTracker;
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
_ffmpegSegmenterService = ffmpegSegmenterService; _ffmpegSegmenterService = ffmpegSegmenterService;
@@ -150,7 +153,14 @@ public class IptvController : StreamingControllerBase
} }
process.Start(); process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t"); return mode == "ts-legacy"
? new TrackedFileStreamResult(
process.StandardOutput.BaseStream,
"video/mp2t",
_directStreamSessionTracker,
channelNumber,
StreamingMode.TransportStream)
: new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
}, },
error => BadRequest(error.Value))); error => BadRequest(error.Value)));
} }
@@ -353,7 +363,11 @@ public class IptvController : StreamingControllerBase
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request); Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
return GetProcessResponse(result, channelNumber, StreamingMode.HttpLiveStreamingDirect); return GetProcessResponse(
result,
channelNumber,
StreamingMode.HttpLiveStreamingDirect,
_directStreamSessionTracker);
} }
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"]) private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])
@@ -16,7 +16,8 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
protected IActionResult GetProcessResponse( protected IActionResult GetProcessResponse(
Either<BaseError, PlayoutItemProcessModel> result, Either<BaseError, PlayoutItemProcessModel> result,
string channelNumber, string channelNumber,
StreamingMode mode) StreamingMode mode,
IDirectStreamSessionTracker directStreamSessionTracker = null)
{ {
foreach (BaseError error in result.LeftToSeq()) foreach (BaseError error in result.LeftToSeq())
{ {
@@ -30,14 +31,18 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
foreach (PlayoutItemProcessModel processModel in result.RightToSeq()) foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
{ {
return StartPlayout(processModel); return StartPlayout(processModel, channelNumber, mode, directStreamSessionTracker);
} }
// this will never happen // this will never happen
return new NotFoundResult(); return new NotFoundResult();
} }
private FileStreamResult StartPlayout(PlayoutItemProcessModel processModel) private FileStreamResult StartPlayout(
PlayoutItemProcessModel processModel,
string channelNumber,
StreamingMode mode,
IDirectStreamSessionTracker directStreamSessionTracker)
{ {
// for process counter // for process counter
var ffmpegProcess = new FFmpegProcess(); var ffmpegProcess = new FFmpegProcess();
@@ -86,6 +91,10 @@ public abstract class StreamingControllerBase(IGraphicsEngine graphicsEngine, IL
null, null,
TaskScheduler.Default); TaskScheduler.Default);
return new FileStreamResult(pipe.Reader.AsStream(), "video/mp2t"); Stream stream = pipe.Reader.AsStream();
return directStreamSessionTracker is null
? new FileStreamResult(stream, "video/mp2t")
: new TrackedFileStreamResult(stream, "video/mp2t", directStreamSessionTracker, channelNumber, mode);
} }
} }
@@ -0,0 +1,20 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Streaming;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers;
public class TrackedFileStreamResult(
Stream fileStream,
string contentType,
IDirectStreamSessionTracker directStreamSessionTracker,
string channelNumber,
StreamingMode streamingMode) : FileStreamResult(fileStream, contentType)
{
public override async Task ExecuteResultAsync(ActionContext context)
{
using IDisposable registration = directStreamSessionTracker.Register(channelNumber, streamingMode);
await base.ExecuteResultAsync(context);
}
}
+2
View File
@@ -44,6 +44,7 @@ using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling; using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling; using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Core.Search; using ErsatzTV.Core.Search;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Core.Trakt; using ErsatzTV.Core.Trakt;
using ErsatzTV.Core.Troubleshooting; using ErsatzTV.Core.Troubleshooting;
using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Capabilities;
@@ -819,6 +820,7 @@ public class Startup
services.AddSingleton<IScannerProxyService, ScannerProxyService>(); services.AddSingleton<IScannerProxyService, ScannerProxyService>();
services.AddSingleton<IScriptedPlayoutBuilderService, ScriptedPlayoutBuilderService>(); services.AddSingleton<IScriptedPlayoutBuilderService, ScriptedPlayoutBuilderService>();
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>(); services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
services.AddSingleton<IDirectStreamSessionTracker, DirectStreamSessionTracker>();
services.AddSingleton<ITempFilePool, TempFilePool>(); services.AddSingleton<ITempFilePool, TempFilePool>();
services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>(); services.AddSingleton<IHlsPlaylistFilter, HlsPlaylistFilter>();
services.AddSingleton<RecyclableMemoryStreamManager>(); services.AddSingleton<RecyclableMemoryStreamManager>();