Files
ersatztv/ErsatzTV/Controllers/InternalController.cs
T
Jason DoveandGitHub 5d081ceeff fix editorconfig and run code cleanup (#2324)
* fix formatting rules

* reformat ersatztv

* reformat ersatztv.application

* reformat ersatztv.core

* refactor ersatztv.core.tests

* reformat ersatztv.ffmpeg

* reformat ersatztv.ffmpeg.tests

* reformat ersatztv.infrastructure

* cleanup infra mysql

* cleanup infra sqlite

* cleanup infra tests

* cleanup ersatztv.scanner

* cleanup ersatztv.scanner.tests

* sln cleanup

* update dependencies
2025-08-16 14:44:48 +00:00

371 lines
13 KiB
C#

using System.Diagnostics;
using System.IO.Pipelines;
using System.Text;
using CliWrap;
using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Subtitles.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Extensions;
using Flurl;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers;
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public class InternalController : ControllerBase
{
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IGraphicsEngine _graphicsEngine;
private readonly ILogger<InternalController> _logger;
private readonly IMediator _mediator;
public InternalController(
IFFmpegSegmenterService ffmpegSegmenterService,
IGraphicsEngine graphicsEngine,
IMediator mediator,
ILogger<InternalController> logger)
{
_ffmpegSegmenterService = ffmpegSegmenterService;
_graphicsEngine = graphicsEngine;
_mediator = mediator;
_logger = logger;
}
[HttpGet("ffmpeg/concat/{channelNumber}")]
public Task<IActionResult> GetConcatPlaylist(string channelNumber, [FromQuery] string mode = "ts-legacy") =>
_mediator.Send(
new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber, mode))
.ToActionResult();
[HttpGet("ffmpeg/stream/{channelNumber}")]
public async Task<IActionResult> GetStream(
string channelNumber,
[FromQuery]
string mode = "mixed")
{
switch (mode)
{
case "segmenter-v2":
return await GetSegmenterV2Stream(channelNumber);
default:
return await GetTsLegacyStream(channelNumber, mode);
}
}
[HttpGet("ffmpeg/remote-stream/{remoteStreamId}")]
public async Task<IActionResult> GetRemoteStream(int remoteStreamId, CancellationToken cancellationToken)
{
Option<RemoteStreamViewModel> maybeRemoteStream =
await _mediator.Send(new GetRemoteStreamById(remoteStreamId), cancellationToken);
foreach (RemoteStreamViewModel remoteStream in maybeRemoteStream)
{
if (!string.IsNullOrWhiteSpace(remoteStream.Url))
{
return new RedirectResult(remoteStream.Url);
}
if (!string.IsNullOrWhiteSpace(remoteStream.Script))
{
string[] split = remoteStream.Script.Split(" ");
if (split.Length > 0)
{
Command command = Cli.Wrap(split.Head());
if (split.Length > 1)
{
command = command.WithArguments(split.Tail());
}
var process = new FFmpegProcess
{
StartInfo = new ProcessStartInfo
{
FileName = command.TargetFilePath,
Arguments = command.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true
}
};
HttpContext.Response.RegisterForDispose(process);
foreach ((string key, string value) in command.EnvironmentVariables)
{
process.StartInfo.Environment[key] = value;
}
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
}
}
}
return NotFound();
}
[HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")]
public async Task<IActionResult> GetPlexMedia(
int plexMediaSourceId,
string path,
CancellationToken cancellationToken)
{
#if DEBUG_NO_SYNC
await Task.Delay(100, cancellationToken);
return NotFound();
#else
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId), cancellationToken);
return connectionParameters.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r =>
{
Url fullPath = new Uri(r.Uri, path).SetQueryParam("X-Plex-Token", r.AuthToken);
return new RedirectResult(fullPath.ToString());
});
#endif
}
[HttpGet("/media/jellyfin/{*path}")]
public async Task<IActionResult> GetJellyfinMedia(string path, CancellationToken cancellationToken)
{
Either<BaseError, JellyfinConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetJellyfinConnectionParameters(), cancellationToken);
return connectionParameters.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r =>
{
Url fullPath;
if (path.Contains("Subtitles"))
{
fullPath = Flurl.Url.Parse(r.Address)
.AppendPathSegment(path);
}
else
{
fullPath = Flurl.Url.Parse(r.Address)
.AppendPathSegment("Videos")
.AppendPathSegment(path)
.AppendPathSegment("stream")
.SetQueryParam("static", "true");
}
return new RedirectResult(fullPath.ToString());
});
}
[HttpGet("/media/emby/{*path}")]
public async Task<IActionResult> GetEmbyMedia(string path, CancellationToken cancellationToken)
{
Either<BaseError, EmbyConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetEmbyConnectionParameters(), cancellationToken);
return connectionParameters.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r =>
{
Url fullPath;
if (path.Contains("Subtitles"))
{
fullPath = Flurl.Url.Parse(r.Address)
.AppendPathSegment(path)
.SetQueryParam("X-Emby-Token", r.ApiKey);
}
else
{
fullPath = Flurl.Url.Parse(r.Address)
.AppendPathSegment("Videos")
.AppendPathSegment(path)
.AppendPathSegment("stream")
.SetQueryParam("static", "true")
.SetQueryParam("X-Emby-Token", r.ApiKey);
}
return new RedirectResult(fullPath.ToString());
});
}
[HttpGet("/media/subtitle/{id:int}")]
public async Task<IActionResult> GetSubtitle(int id, [FromQuery] long? seekToMs)
{
Either<BaseError, string> maybePath = await _mediator.Send(new GetSubtitlePathById(id));
foreach (string path in maybePath.RightToSeq())
{
string mimeType = Path.GetExtension(path).ToLowerInvariant() switch
{
".ass" or ".ssa" => "text/x-ssa",
".vtt" => "text/vtt",
_ => "application/x-subrip"
};
if (seekToMs is > 0)
{
Either<BaseError, SeekTextSubtitleProcess> maybeProcess = await _mediator.Send(
new GetSeekTextSubtitleProcess(path, TimeSpan.FromMilliseconds(seekToMs.Value)));
foreach (SeekTextSubtitleProcess processModel in maybeProcess.RightToSeq())
{
Command command = processModel.Process;
_logger.LogDebug("ffmpeg text subtitle arguments {FFmpegArguments}", command.Arguments);
var process = new FFmpegProcess
{
StartInfo = new ProcessStartInfo
{
FileName = command.TargetFilePath,
Arguments = command.Arguments,
RedirectStandardOutput = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true
}
};
HttpContext.Response.RegisterForDispose(process);
foreach ((string key, string value) in command.EnvironmentVariables)
{
process.StartInfo.Environment[key] = value;
}
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, mimeType);
}
return new NotFoundResult();
}
if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
return new RedirectResult(path);
}
return new PhysicalFileResult(path, mimeType);
}
return new NotFoundResult();
}
private async Task<IActionResult> GetSegmenterV2Stream(string channelNumber)
{
if (_ffmpegSegmenterService.TryGetWorker(channelNumber, out IHlsSessionWorker worker) &&
worker is HlsSessionWorkerV2 v2)
{
Either<BaseError, PlayoutItemProcessModel> result = await v2.GetNextPlayoutItemProcess();
return GetProcessResponse(result, channelNumber, "segmenter-v2");
}
_logger.LogWarning("Unable to locate session worker for channel {Channel}", channelNumber);
return new NotFoundResult();
}
private async Task<IActionResult> GetTsLegacyStream(string channelNumber, string mode)
{
var request = new GetPlayoutItemProcessByChannelNumber(
channelNumber,
mode,
DateTimeOffset.Now,
false,
true,
DateTimeOffset.Now,
0,
Option<int>.None);
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
return GetProcessResponse(result, channelNumber, mode);
}
private IActionResult GetProcessResponse(
Either<BaseError, PlayoutItemProcessModel> result,
string channelNumber,
string mode)
{
foreach (BaseError error in result.LeftToSeq())
{
_logger.LogError(
"Failed to create stream for channel {ChannelNumber}: {Error}",
channelNumber,
error.Value);
return BadRequest(error.Value);
}
foreach (PlayoutItemProcessModel processModel in result.RightToSeq())
{
// for process counter
var ffmpegProcess = new FFmpegProcess();
Command process = processModel.Process;
_logger.LogDebug("ffmpeg arguments {FFmpegArguments}", process.Arguments);
var cts = new CancellationTokenSource();
HttpContext.Response.OnCompleted(async () =>
{
ffmpegProcess.Dispose();
await cts.CancelAsync();
cts.Dispose();
});
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cts.Token,
HttpContext.RequestAborted);
var pipe = new Pipe();
var stdErrBuffer = new StringBuilder();
Command processWithPipe = process;
foreach (GraphicsEngineContext graphicsEngineContext in processModel.GraphicsEngineContext)
{
var gePipe = new Pipe();
processWithPipe = process.WithStandardInputPipe(PipeSource.FromStream(gePipe.Reader.AsStream()));
// fire and forget graphics engine task
_ = _graphicsEngine.Run(
graphicsEngineContext,
gePipe.Writer,
linkedCts.Token);
}
CommandTask<CommandResult> task = processWithPipe
.WithStandardOutputPipe(PipeTarget.ToStream(pipe.Writer.AsStream()))
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token);
// ensure pipe writer is completed when ffmpeg exits
_ = task.Task.ContinueWith(
(_, state) => ((PipeWriter)state!).Complete(),
pipe.Writer,
TaskScheduler.Default);
string contentType = mode switch
{
"segmenter-v2" => "video/x-matroska",
_ => "video/mp2t"
};
return new FileStreamResult(pipe.Reader.AsStream(), contentType);
}
// this will never happen
return new NotFoundResult();
}
}