Files
ersatztv/ErsatzTV/Controllers/IptvController.cs
T
2026-07-04 19:57:50 +02:00

377 lines
15 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Text;
using CliWrap;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Images;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers;
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))]
public class IptvController : StreamingControllerBase
{
private readonly IDirectStreamSessionTracker _directStreamSessionTracker;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILogger<IptvController> _logger;
private readonly IMediator _mediator;
public IptvController(
IMediator mediator,
IGraphicsEngine graphicsEngine,
ILogger<IptvController> logger,
IFFmpegSegmenterService ffmpegSegmenterService,
IDirectStreamSessionTracker directStreamSessionTracker)
: base(graphicsEngine, logger)
{
_directStreamSessionTracker = directStreamSessionTracker;
_mediator = mediator;
_logger = logger;
_ffmpegSegmenterService = ffmpegSegmenterService;
}
[HttpHead("iptv/channels.m3u")]
[HttpGet("iptv/channels.m3u")]
public Task<IActionResult> GetChannelPlaylist(
[FromQuery]
string mode = "mixed") =>
_mediator.Send(
new GetChannelPlaylist(
Request.Scheme,
Request.Host.ToString(),
Request.PathBase,
mode,
Request.Headers.UserAgent,
Request.Query["access_token"]))
.Map<ChannelPlaylist, IActionResult>(Ok);
[HttpHead("iptv/xmltv.xml")]
[HttpGet("iptv/xmltv.xml")]
public Task<IActionResult> GetGuide() =>
_mediator.Send(
new GetChannelGuide(
Request.Scheme,
Request.Host.ToString(),
Request.PathBase,
Request.Query["access_token"]))
.ToActionResult();
[HttpHead("iptv/hdhr/channel/{channelNumber}.ts")]
[HttpGet("iptv/hdhr/channel/{channelNumber}.ts")]
public Task<IActionResult> GetHDHRVideo(string channelNumber, [FromQuery] string mode = "ts")
{
// don't redirect to the correct channel mode for HDHR clients; always use TS
if (mode != "ts" && mode != "ts-legacy")
{
mode = "ts";
}
return GetTransportStreamVideo(channelNumber, mode);
}
[HttpHead("iptv/channel/{channelNumber}.ts")]
[HttpGet("iptv/channel/{channelNumber}.ts")]
public async Task<IActionResult> GetTransportStreamVideo(
string channelNumber,
[FromQuery]
string mode = null)
{
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelByNumber(channelNumber));
if (maybeChannel.IsNone || !await maybeChannel.Map(c => c.IsEnabled).IfNoneAsync(false))
{
return NotFound();
}
// if mode is "unspecified" - find the configured mode and set it or redirect
if (string.IsNullOrWhiteSpace(mode) || mode == "mixed")
{
foreach (ChannelViewModel channel in maybeChannel)
{
switch (channel.StreamingMode)
{
case StreamingMode.TransportStream:
mode = "ts-legacy";
break;
case StreamingMode.TransportStreamHybrid:
mode = "ts";
break;
default:
return Redirect($"~/iptv/channel/{channelNumber}.m3u8{AccessTokenQuery()}");
}
}
}
FFmpegProcessRequest request = mode switch
{
"ts-legacy" => new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber),
_ => new GetWrappedProcessByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
Request.Query["access_token"],
channelNumber)
};
return await _mediator.Send(request)
.Map(result => result.Match<IActionResult>(
processModel =>
{
Command command = processModel.Process;
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
_logger.LogDebug("ffmpeg 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 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)));
}
[HttpHead("iptv/session/{channelNumber}/hls.m3u8")]
[HttpGet("iptv/session/{channelNumber}/hls.m3u8")]
public async Task<IActionResult> GetLivePlaylist(string channelNumber, CancellationToken cancellationToken)
{
// _logger.LogDebug("Checking for session worker for channel {Channel}", channelNumber);
if (_ffmpegSegmenterService.TryGetWorker(channelNumber, out IHlsSessionWorker worker) && worker is not null)
{
// _logger.LogDebug("Trimming playlist for channel {Channel}", channelNumber);
DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30);
Option<TrimPlaylistResult> maybePlaylist = await worker.TrimPlaylist(now, cancellationToken);
foreach (TrimPlaylistResult result in maybePlaylist)
{
return Content(result.Playlist, "application/vnd.apple.mpegurl");
}
// TODO: better error here?
_logger.LogWarning("Trim playlist failure; will return not found for channel {Channel}", channelNumber);
return NotFound();
}
_logger.LogWarning(
"Unable to locate session worker for channel {Channel}; will redirect to start session",
channelNumber);
return RedirectToAction(nameof(GetHttpLiveStreamingVideo), new { channelNumber });
}
[HttpHead("iptv/channel/{channelNumber}.m3u8")]
[HttpGet("iptv/channel/{channelNumber}.m3u8")]
public async Task<IActionResult> GetHttpLiveStreamingVideo(
string channelNumber,
[FromQuery]
string mode = "mixed")
{
Option<ChannelViewModel> maybeChannel = await _mediator.Send(new GetChannelByNumber(channelNumber));
if (maybeChannel.IsNone || !await maybeChannel.Map(c => c.IsEnabled).IfNoneAsync(false))
{
return NotFound();
}
// if mode is "unspecified" - find the configured mode and set it or redirect
if (string.IsNullOrWhiteSpace(mode) || mode == "mixed")
{
foreach (ChannelViewModel channel in maybeChannel)
{
switch (channel.StreamingMode)
{
case StreamingMode.HttpLiveStreamingDirect:
mode = "hls-direct";
break;
case StreamingMode.HttpLiveStreamingSegmenter:
mode = "segmenter";
break;
default:
return Redirect($"~/iptv/channel/{channelNumber}.ts{AccessTokenQuery()}");
}
}
}
switch (mode)
{
case "segmenter":
case "segmenter-v2":
case "segmenter-fmp4":
_logger.LogDebug(
"Maybe starting ffmpeg session for channel {Channel}, mode {Mode}",
channelNumber,
mode);
var request = new StartFFmpegSession(channelNumber, mode, Request.Scheme, Request.Host.ToString());
Either<BaseError, Unit> result = await _mediator.Send(request);
string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber);
return result.Match<IActionResult>(
_ =>
{
_logger.LogDebug(
"Session started; returning multi-variant playlist for channel {Channel}",
channelNumber);
return Content(multiVariantPlaylist, "application/vnd.apple.mpegurl");
// return Redirect($"~/iptv/session/{channelNumber}/hls.m3u8");
},
error =>
{
switch (error)
{
case ChannelSessionAlreadyActive:
_logger.LogDebug(
"Session is already active; returning multi-variant playlist for channel {Channel}",
channelNumber);
return Content(multiVariantPlaylist, "application/vnd.apple.mpegurl");
// return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8");
default:
_logger.LogWarning(
"Failed to start segmenter for channel {ChannelNumber}: {Error}",
channelNumber,
error.ToString());
return NotFound();
}
});
default:
return await _mediator.Send(
new GetHlsPlaylistByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
channelNumber,
mode))
.Map(r => r.Match<IActionResult>(
playlist => Content(playlist, "application/vnd.apple.mpegurl"),
error => BadRequest(error.Value)));
}
}
[HttpHead("iptv/logos/{fileName}")]
[HttpGet("iptv/logos/{fileName}")]
[HttpHead("iptv/logos/{fileName}.jpg")]
[HttpGet("iptv/logos/{fileName}.jpg")]
public async Task<IActionResult> GetImage(string fileName, [FromQuery] string contentType)
{
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo, contentType));
return cachedImagePath.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
}
[HttpGet("iptv/hls-direct/{channelNumber}")]
[HttpGet("iptv/hls-direct/{channelNumber}.ts")]
[HttpGet("iptv/hls-direct/{channelNumber}.mkv")]
[HttpGet("iptv/hls-direct/{channelNumber}.mp4")]
public async Task<IActionResult> GetStream(string channelNumber) =>
await GetHlsDirectStream(channelNumber);
private async Task<string> GetMultiVariantPlaylist(string channelNumber)
{
var variantPlaylist =
$"{Request.Scheme}://{Request.Host}{Request.PathBase}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}";
Option<ChannelStreamingSpecsViewModel> maybeStreamingSpecs =
await _mediator.Send(new GetChannelStreamingSpecs(channelNumber));
string resolution = string.Empty;
var bitrate = "10000000";
foreach (ChannelStreamingSpecsViewModel streamingSpecs in maybeStreamingSpecs)
{
string videoCodec = streamingSpecs.VideoFormat switch
{
FFmpegProfileVideoFormat.Av1 => "av01.0.01M.08",
FFmpegProfileVideoFormat.Hevc => "hvc1.1.6.L93.B0",
FFmpegProfileVideoFormat.H264 => "avc1.4D4028",
_ => string.Empty
};
string audioCodec = streamingSpecs.AudioFormat switch
{
FFmpegProfileAudioFormat.Ac3 => "ac-3",
FFmpegProfileAudioFormat.Aac or FFmpegProfileAudioFormat.AacLatm => "mp4a.40.2",
_ => string.Empty
};
List<string> codecStrings = [];
if (!string.IsNullOrWhiteSpace(videoCodec))
{
codecStrings.Add(videoCodec);
}
if (!string.IsNullOrWhiteSpace(audioCodec))
{
codecStrings.Add(audioCodec);
}
string codecs = codecStrings.Count > 0 ? $",CODECS=\"{string.Join(",", codecStrings)}\"" : string.Empty;
resolution = $",RESOLUTION={streamingSpecs.Width}x{streamingSpecs.Height}{codecs}";
bitrate = streamingSpecs.Bitrate.ToString(CultureInfo.InvariantCulture);
}
return $@"#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution}
{variantPlaylist}";
}
private async Task<IActionResult> GetHlsDirectStream(string channelNumber)
{
var request = new GetPlayoutItemProcessByChannelNumber(
channelNumber,
StreamingMode.HttpLiveStreamingDirect,
DateTimeOffset.Now,
false,
true,
DateTimeOffset.Now,
TimeSpan.Zero,
Option<FrameRate>.None,
IsTroubleshooting: false,
Option<int>.None);
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
return GetProcessResponse(
result,
channelNumber,
StreamingMode.HttpLiveStreamingDirect,
_directStreamSessionTracker);
}
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])
? string.Empty
: $"?access_token={Request.Query["access_token"]}";
}