Files
lampac/mods/Transcoding/Controller.cs
T

595 lines
20 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Newtonsoft.Json;
using Shared;
using Shared.Services;
using Shared.Services.Pools;
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 Shared.Services.Utilities;
using Shared.Services.Pools.Json;
namespace Transcoding;
[ApiController]
[Route("transcoding")]
public sealed class TranscodingController : Controller
{
#region static
static readonly Serilog.ILogger Log = Serilog.Log.ForContext<TranscodingController>();
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)
{
HttpContext.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; // HTTP 1.1.
HttpContext.Response.Headers["Pragma"] = "no-cache"; // HTTP 1.0.
HttpContext.Response.Headers["Expires"] = "0"; // Proxies.
var plugin = FileCache.ReadAllText($"{ModInit.modpath}/plugin.js", "transcoding.js")
.Replace("{localhost}", CoreInit.Host(HttpContext))
.Replace("{token}", HttpUtility.UrlEncode(token));
return Content(plugin, "application/javascript; charset=utf-8");
}
#endregion
#region Start
[HttpGet("start.m3u8")]
public async Task<IActionResult> StartM3u8(string src, int a, int s, bool subtitles, bool live)
{
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.AsSpan(), null)?.uri;
if (string.IsNullOrWhiteSpace(src))
return BadRequest(new { error = "src decrypt" });
}
var defaults = ModInit.conf;
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 = $"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(live ? "live" : "main")}.m3u8";
return Redirect(AccsDbInvk.Args(uri, HttpContext));
}
[HttpPost("start")]
public async Task<IActionResult> Start([FromBody] TranscodingStartRequest request)
{
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($"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(job.Context.live ? "live" : "main")}.m3u8", HttpContext),
subtitlesUrl = job.Context.subtitles ? AccsDbInvk.Args($"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/subtitles", HttpContext) : null,
hls_timeout_seconds = 60
});
}
#endregion
#region Seek
[HttpGet("{streamId}/seek/{ss}")]
public async Task<IActionResult> Seek(string streamId, int ss)
{
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<IActionResult> Live(string streamId)
{
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<IActionResult> Playlist(string streamId)
{
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<string>("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<IActionResult> Segment(string streamId, string file)
{
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 (System.Exception ex)
{
Log.Error(ex, "CatchId={CatchId}", "id_om7pdvok");
}
}
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 (System.Exception ex)
{
Log.Error(ex, "CatchId={CatchId}", "id_4pxipm5i");
}
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)
: 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 (!_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<string>("codec_type") != "subtitle")
continue;
if (s.Value<string>("codec_name") is "subrip" or "webvtt" or "ass" or "ssa" or "mov_text" or "ttml" or "sami")
{
int subIndex = s.Value<int>("index");
if (subIndex == 0)
continue;
string uri = $"{CoreInit.Host(HttpContext)}/transcoding/{streamId}/{AccsDbInvk.Args($"subs_{subIndex}.vtt", HttpContext)}";
string name = s["tags"].Value<string>("title")
?? s["language"].Value<string>("title")
?? $"sub_{subIndex}";
subsTpl.Append(name, uri);
}
}
}
return Ok(subsTpl.ToObject());
}
#endregion
#region Heartbeat
[HttpGet("{streamId}/heartbeat")]
public IActionResult Heartbeat(string streamId)
{
if (!_service.TryResolveJob(streamId, out var job))
return NotFound();
_service.Touch(job);
return Ok();
}
#endregion
#region StopAsync
[HttpGet("{streamId}/stop")]
public async Task<IActionResult> StopAsync(string streamId)
{
var stopped = await _service.StopAsync(streamId);
return stopped ? Ok() : NotFound();
}
#endregion
#region Status
[HttpGet("{streamId}/status")]
public IActionResult Status(string streamId)
{
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 (System.Exception ex)
{
Log.Error(ex, "CatchId={CatchId}", "id_iuxl2cac");
}
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 = ModInit.conf.audioOptions,
hls = ModInit.conf.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
}