using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; using Newtonsoft.Json; using Shared; using Shared.Engine; using Shared.Engine.Utilities; using Shared.Models.AppConf; using Shared.Models.Templates; using System; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Web; using Tracks.Engine; namespace Tracks.Controllers { [ApiController] [Route("transcoding")] public sealed class TranscodingController : Controller { #region static readonly TranscodingService _service = TranscodingService.Instance; static readonly FileExtensionContentTypeProvider provider = new FileExtensionContentTypeProvider() { Mappings = { [".m4s"] = "video/mp4", [".ts"] = "video/mp2t", [".mp4"] = "video/mp4", [".m2ts"] = "video/MP2T" } }; #endregion #region transcoding.js [AllowAnonymous] [HttpGet("/transcoding.js")] [HttpGet("js/{token}")] public ActionResult TranscodingJs(string token) { if (!AppInit.conf.transcoding.enable) return Content(string.Empty); var sb = new StringBuilder(FileCache.ReadAllText("plugins/transcoding.js")); sb.Replace("{localhost}", AppInit.Host(HttpContext)) .Replace("{token}", HttpUtility.UrlEncode(token)); return Content(sb.ToString(), "application/javascript; charset=utf-8"); } #endregion #region Start [HttpGet("start.m3u8")] public async Task StartM3u8(string src, int a, int s, bool subtitles, bool live) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); if (string.IsNullOrEmpty(src)) return BadRequest(new { error = "src" }); if (src.Contains("/proxy/") && src.Contains(".mkv")) { string hash = Regex.Match(src, "/proxy/([^\n\r]+\\.mkv)").Groups[1].Value; src = ProxyLink.Decrypt(hash, null)?.uri; if (string.IsNullOrWhiteSpace(src)) return BadRequest(new { error = "src decrypt" }); } var defaults = AppInit.conf.transcoding; var (job, error) = await _service.Start(new TranscodingStartRequest() { src = src, live = live, subtitles = subtitles, audio = new TranscodingAudioOptions() { index = a }, hls = new TranscodingHlsOptions() { seek = s } }); if (job == null) return BadRequest(new { error }); string uri = $"{AppInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(live ? "live" : "main")}.m3u8"; return Redirect(AccsDbInvk.Args(uri, HttpContext)); } [HttpPost("start")] public async Task Start([FromBody] TranscodingStartRequest request) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); if (request == null) return BadRequest(new { error = "Request body is required" }); var (job, error) = await _service.Start(request); if (job == null) return BadRequest(new { error }); return Ok(new { job.StreamId, playlistUrl = AccsDbInvk.Args($"{AppInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(job.Context.live ? "live" : "main")}.m3u8", HttpContext), subtitlesUrl = job.Context.subtitles ? AccsDbInvk.Args($"{AppInit.Host(HttpContext)}/transcoding/{job.StreamId}/subtitles", HttpContext) : null, hls_timeout_seconds = 60 }); } #endregion #region Seek [HttpGet("{streamId}/seek/{ss}")] public async Task Seek(string streamId, int ss) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); if (!_service.TryResolveJob(streamId, out var job)) return NotFound(); if (!job.Context.live) return BadRequest(new { error = "Context not live" }); if (ss < 0) return BadRequest(new { error = "ss must be greater or equal 0" }); var (success, error) = await _service.SeekAsync(streamId, ss); if (!success) return BadRequest(new { error }); return Ok(); } #endregion #region Live [HttpGet("{streamId}/live.m3u8")] public async Task Live(string streamId) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); if (!_service.TryResolveJob(streamId, out var job)) return NotFound(); if (!job.Context.live) return BadRequest(new { error = "Context not live" }); _service.Touch(job); string path = Path.Combine(job.Context.OutputDirectory, "index.m3u8"); var fileExistsTimeout = TimeSpan.FromSeconds(60); var sw = System.Diagnostics.Stopwatch.StartNew(); bool fileExists = System.IO.File.Exists(path); while (!fileExists && sw.Elapsed < fileExistsTimeout) { await Task.Delay(250); fileExists = System.IO.File.Exists(path); if (fileExists) break; } if (!fileExists) return NotFound(); string m3u8 = null; sw.Restart(); while (sw.Elapsed < fileExistsTimeout) { try { m3u8 = System.IO.File.ReadAllText(path); } catch { m3u8 = null; } if (!string.IsNullOrEmpty(m3u8) && Regex.IsMatch(m3u8, "seg_[0-9]+\\.(m4s|ts)")) break; await Task.Delay(250); } if (string.IsNullOrEmpty(m3u8)) return NotFound(); m3u8 = Regex.Replace(m3u8, "#EXT-X-MAP:URI=[^\n\r]+", $"#EXT-X-MAP:URI=\"{AccsDbInvk.Args("init.mp4", HttpContext)}\""); m3u8 = Regex.Replace(m3u8, "(seg_[0-9]+\\.(m4s|ts))", r => { string file = r.Groups[1].Value; return AccsDbInvk.Args(file, HttpContext); }); return Content(m3u8, "application/vnd.apple.mpegurl"); } #endregion #region Playlist [HttpGet("{streamId}/main.m3u8")] public async Task Playlist(string streamId) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); if (!_service.TryResolveJob(streamId, out var job)) return NotFound(); if (job.Context.live) return BadRequest(new { error = "Context not playlist" }); if (!int.TryParse(job.Context.ffprobe["format"].Value("duration").Split('.')[0].Split(',')[0], out int duration) || duration == 0) return BadRequest(new { error = "duration" }); _service.Touch(job); var fileExistsTimeout = TimeSpan.FromSeconds(60); var sw = System.Diagnostics.Stopwatch.StartNew(); while (sw.Elapsed < fileExistsTimeout) { if (Directory.GetFiles(job.Context.OutputDirectory).Length > 2) break; await Task.Delay(250); } int segDur = job.Context.HlsOptions.segDur; var builder = new StringBuilder(); builder.AppendLine("#EXTM3U"); builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD"); builder.AppendLine($"#EXT-X-VERSION:{(job.Context.HlsOptions.fmp4 ? 7 : 3)}"); builder.AppendLine($"#EXT-X-TARGETDURATION:{segDur}"); builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0"); if (job.Context.HlsOptions.fmp4) builder.AppendLine($"#EXT-X-MAP:URI=\"{AccsDbInvk.Args("init.mp4", HttpContext)}\""); for (int i = 0; i < (duration / segDur); i++) { builder.AppendLine($"#EXTINF:{segDur}.0,"); builder.AppendLine(AccsDbInvk.Args($"seg_{i:d5}.{(job.Context.HlsOptions.fmp4 ? "m4s" : "ts")}", HttpContext)); } builder.AppendLine("#EXT-X-ENDLIST"); return Content(builder.ToString(), "application/vnd.apple.mpegurl"); } #endregion #region Segment [HttpGet("{streamId}/{file}")] public async Task Segment(string streamId, string file) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); if (!_service.TryResolveJob(streamId, out var job)) return NotFound(); _service.Touch(job); int? segmentIndex = null; if (!job.Context.live && file != null) { var matchSegment = Regex.Match(file, @"seg_(\d+)\.(m4s|ts)$", RegexOptions.IgnoreCase); if (matchSegment.Success && int.TryParse(matchSegment.Groups[1].Value, out var idx)) segmentIndex = idx; } var fileExistsTimeout = TimeSpan.FromSeconds(60); var sw = System.Diagnostics.Stopwatch.StartNew(); string resolved = _service.GetFilePath(job, file); if (job.Context.live == false && resolved == null && !file.Contains(".vtt")) { #region SeekAsync if (segmentIndex.HasValue) { int segDur = job.Context.HlsOptions.segDur; int ss = segmentIndex.Value * segDur; if (job.Context.HlsOptions.seek == 0 && 30 > ss) { // первые 30 секунд без seek-а - не трогаем } else if (job.Context.HlsOptions.seek == ss) { // ffmpeg на текущем сегменте } else { // работает дальше чем текущий сегмент bool goSeek = job.Context.HlsOptions.seek > ss; string extension = Path.GetExtension(file); int segmentsPerMinute = (int)Math.Ceiling(30.0 / segDur); int startIndex = Math.Max(0, segmentIndex.Value - segmentsPerMinute); if (goSeek == false) { goSeek = true; for (int i = startIndex; i < segmentIndex.Value; i++) { string candidate = $"seg_{i:d5}{extension}"; if (_service.GetFilePath(job, candidate) != null) { // есть сегменты в пределах последних 30 секунд goSeek = false; break; } } } if (goSeek) await _service.SeekAsync(streamId, ss, segmentIndex.Value); } } #endregion do { try { await Task.Delay(200); resolved = _service.GetFilePath(job, file); if (resolved != null) break; } catch { } } while (sw.Elapsed < fileExistsTimeout); } if (resolved == null) return NotFound(); #region ждем появления следующего сегмента while (segmentIndex.HasValue && sw.Elapsed < fileExistsTimeout) { try { string seg = Path.Combine(job.OutputDirectory, $"seg_{segmentIndex.Value + 1:d5}"); if (System.IO.File.Exists($"{seg}.{(job.Context.HlsOptions.fmp4 ? "m4s" : "ts")}")) break; } catch { } await Task.Delay(200); } #endregion #region FileStream FileStream fs = null; do { try { fs = file.Contains(".vtt") ? new FileStream(resolved, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, PoolInvk.bufferSize) : System.IO.File.OpenRead(resolved); break; } catch (IOException) { await Task.Delay(200); } catch (UnauthorizedAccessException) { await Task.Delay(200); } } while (sw.Elapsed < fileExistsTimeout); if (fs == null) return NotFound(); #endregion if (!provider.TryGetContentType(resolved, out var contentType)) contentType = "application/octet-stream"; if (segmentIndex.HasValue) _service.ReportSegmentAccess(job, segmentIndex.Value); return File(fs, contentType, enableRangeProcessing: true); } #endregion #region Subtitles [HttpGet("{streamId}/subtitles")] public IActionResult Subtitles(string streamId) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); if (!_service.TryResolveJob(streamId, out var job)) return NotFound(); _service.Touch(job); var subsTpl = new SubtitleTpl(); if (job.Context.subtitles && job.Context.ffprobe.ContainsKey("streams")) { foreach (var s in job.Context.ffprobe["streams"]) { if (s.Value("codec_type") != "subtitle") continue; if (s.Value("codec_name") is "subrip" or "webvtt" or "ass" or "ssa" or "mov_text" or "ttml" or "sami") { int subIndex = s.Value("index"); if (subIndex == 0) continue; string uri = $"{AppInit.Host(HttpContext)}/transcoding/{streamId}/{AccsDbInvk.Args($"subs_{subIndex}.vtt", HttpContext)}"; string name = s["tags"].Value("title") ?? s["language"].Value("title") ?? $"sub_{subIndex}"; subsTpl.Append(name, uri); } } } return Ok(subsTpl.ToObject()); } #endregion #region Heartbeat [HttpGet("{streamId}/heartbeat")] public IActionResult Heartbeat(string streamId) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); if (!_service.TryResolveJob(streamId, out var job)) return NotFound(); _service.Touch(job); return Ok(); } #endregion #region StopAsync [HttpGet("{streamId}/stop")] public async Task StopAsync(string streamId) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); var stopped = await _service.StopAsync(streamId); return stopped ? Ok() : NotFound(); } #endregion #region Status [HttpGet("{streamId}/status")] public IActionResult Status(string streamId) { if (!AppInit.conf.transcoding.enable || !ModInit.IsInitialization) return BadRequest(new { error = "Transcoding disabled" }); if (!_service.TryResolveJob(streamId, out var job)) return NotFound(); var now = DateTime.UtcNow; var state = job.Process.HasExited ? TranscodingJobState.Stopped : TranscodingJobState.Running; var uptime = now - job.StartedUtc; int? exitCode = null; try { if (job.Process.HasExited) exitCode = job.Process.ExitCode; } catch { } var snapshotLog = job.SnapshotLog(); ulong time_ms = 0; foreach (string line in snapshotLog.Reverse()) { if (!line.Contains("out_time_ms=")) continue; if (!ulong.TryParse(Regex.Match(line, "out_time_ms=([0-9]+)").Groups[1].Value, out time_ms)) continue; break; } return Content(JsonConvertPool.SerializeObject(new { job.StreamId, state = state.ToString(), job.OutputDirectory, ffmpeg = string.Join(" ", job.Process.StartInfo.ArgumentList), startedUtc = job.StartedUtc, lastAccessUtc = job.LastAccessUtc, uptime = uptime.TotalSeconds, positionSec = (ulong)(job.Context.HlsOptions.seek + (time_ms > 0 ? (time_ms / 1000000.0) : 0)), exitCode, job.Context.ffprobe, log = snapshotLog }), "application/json; charset=utf-8"); } #endregion #region DOC [HttpGet("")] public IActionResult DOC() { var endpoints = new object[] { new { path = "/transcoding/start.m3u8", method = "GET", query = new object[] { new { name = "src", type = "string", required = true, description = "Source URL or local path to media" }, new { name = "a", type = "int", required = false, description = "Audio index (optional)" }, new { name = "s", type = "int", required = false, description = "Seek position in seconds (optional)" }, new { name = "subtitles", type = "bool", required = false, description = "subtitles on/off" }, new { name = "live", type = "bool", required = false, description = "Context live/playlist" } }, description = "Start transcoding with query parameters and redirect to the generated HLS playlist" }, new { path = "/transcoding/start", method = "POST", contentType = "application/json", body = new { src = "https://example.com/media.mp4", videoFormat = "", live = false, subtitles = false, headers = new { referer = "https://example.com", userAgent = "HlsProxy/1.0" }, audio = AppInit.conf.transcoding.audioOptions, hls = AppInit.conf.transcoding.hlsOptions }, description = "Start transcoding by POSTing a JSON body. Returns StreamId and playlist URL" }, new { path = "/transcoding/{streamId}/live.m3u8", method = "GET", route = new { name = "streamId", type = "string", required = true }, description = "Returns the HLS live for the given transcoding job" }, new { path = "/transcoding/{streamId}/main.m3u8", method = "GET", route = new { name = "streamId", type = "string", required = true }, description = "Returns the HLS master/variant playlist for the given transcoding job" }, new { path = "/transcoding/{streamId}/{file}", method = "GET", route = new object[] { new { name = "streamId", type = "string", required = true }, new { name = "file", type = "string", required = true, description = "Requested segment or asset (e.g. init.mp4, seg_1.m4s, index.m3u8)" } }, description = "Serves individual segment files, init files and playlists produced by the transcoder. Supports range requests." }, new { path = "/transcoding/{streamId}/seek/{ss}", method = "GET", route = new object[] { new { name = "streamId", type = "string", required = true }, new { name = "ss", type = "int", required = true, description = "Seek position in seconds (>= 0)" } }, description = "Request the transcoder to seek to the specified position (async). Returns 200 on success." }, new { path = "/transcoding/{streamId}/heartbeat", method = "GET", route = new { name = "streamId", type = "string", required = true }, description = "Touch the job to keep it alive. Returns 200 if job exists." }, new { path = "/transcoding/{streamId}/stop", method = "GET", route = new { name = "streamId", type = "string", required = true }, description = "Stop the transcoding job. Returns 200 if stopped or 404 if job not found." }, new { path = "/transcoding/{streamId}/status", method = "GET", query = new { name = "log", type = "bool", required = false, description = "Include job log snapshot when true" }, route = new { name = "streamId", type = "string", required = true }, description = "Return current job status, uptime, position and optional log snapshot." } }; return Ok(JsonConvert.SerializeObject(endpoints, Formatting.Indented)); } #endregion } }