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 _logger; private readonly IMediator _mediator; public IptvController( IMediator mediator, IGraphicsEngine graphicsEngine, ILogger 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 GetChannelPlaylist( [FromQuery] string mode = "mixed") { NoStoreForTokenBearingManifest(); return _mediator.Send( new GetChannelPlaylist( Request.Scheme, Request.Host.ToString(), Request.PathBase, mode, Request.Headers.UserAgent, Request.Query["access_token"])) .Map(Ok); } [HttpHead("iptv/xmltv.xml")] [HttpGet("iptv/xmltv.xml")] public Task GetGuide() { NoStoreForTokenBearingManifest(); return _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 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 GetTransportStreamVideo( string channelNumber, [FromQuery] string mode = null) { // The unspecified/mixed-mode branch below can 302 to .m3u8 with the token in the Location. NoStoreForTokenBearingManifest(); Option 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( 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 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 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 GetHttpLiveStreamingVideo( string channelNumber, [FromQuery] string mode = "mixed") { // The multi-variant/media playlists and the redirect Location below all carry the access_token. NoStoreForTokenBearingManifest(); Option 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 result = await _mediator.Send(request); string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber); return result.Match( _ => { _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( 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 GetImage(string fileName) { Either cachedImagePath = await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo)); return cachedImagePath.Match( 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 GetStream(string channelNumber) => await GetHlsDirectStream(channelNumber); private async Task GetMultiVariantPlaylist(string channelNumber) { var variantPlaylist = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}"; Option 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 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 GetHlsDirectStream(string channelNumber) { var request = new GetPlayoutItemProcessByChannelNumber( channelNumber, StreamingMode.HttpLiveStreamingDirect, DateTimeOffset.Now, false, true, DateTimeOffset.Now, TimeSpan.Zero, Option.None, IsTroubleshooting: false, Option.None); Either result = await _mediator.Send(request); return GetProcessResponse( result, channelNumber, StreamingMode.HttpLiveStreamingDirect, _directStreamSessionTracker); } // Percent-encode the token so a value containing '"' or '&' cannot break the M3U/HLS URL it is placed // into (#421). ASP.NET decodes Request.Query, so re-encoding round-trips to the original token; for a // normal base64url JWT this is a no-op. private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"]) ? string.Empty : $"?access_token={Uri.EscapeDataString(Request.Query["access_token"].ToString())}"; // Dynamic /iptv manifests embed the caller's access_token in their body/URLs; mark them no-store so a // browser or intermediary cache can't retain the token-bearing response and replay it (#559). private void NoStoreForTokenBearingManifest() => Response.Headers.CacheControl = "private, no-store"; }