Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,594 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Shared;
|
||||
using Shared.Services;
|
||||
using Shared.Models.AppConf;
|
||||
using Shared.Models.Module;
|
||||
using Shared.Models.Module.Interfaces;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Shared.Models.Events;
|
||||
|
||||
namespace Transcoding;
|
||||
|
||||
public class ModInit : IModuleLoaded
|
||||
{
|
||||
public static string modpath;
|
||||
public static ModuleConf conf;
|
||||
|
||||
|
||||
public void Loaded(InitspaceModel initspace)
|
||||
{
|
||||
modpath = initspace.path;
|
||||
|
||||
updateConf();
|
||||
EventListener.UpdateInitFile += updateConf;
|
||||
|
||||
foreach (var m in conf.limit_map)
|
||||
CoreInit.conf.WAF.limit_map.Insert(0, m);
|
||||
|
||||
Directory.CreateDirectory("database/tracks");
|
||||
Directory.CreateDirectory(conf.tempRoot);
|
||||
|
||||
TranscodingService.Instance.Configure(conf);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
EventListener.UpdateInitFile -= updateConf;
|
||||
TranscodingService.Instance.StopAll();
|
||||
}
|
||||
|
||||
void updateConf()
|
||||
{
|
||||
conf = ModuleInvoke.Init("transcoding", new ModuleConf()
|
||||
{
|
||||
tempRoot = Path.Combine("cache", "transcoding"),
|
||||
idleTimeoutSec = 60 * 5,
|
||||
idleTimeoutSec_live = 120,
|
||||
maxConcurrentJobs = 5,
|
||||
limit_map = new List<WafLimitRootMap>()
|
||||
{
|
||||
new("^/transcoding/", new WafLimitMap { limit = 50, second = 1 })
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Shared.Models.Module;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Transcoding;
|
||||
|
||||
public class ModuleConf : ModuleBaseConf
|
||||
{
|
||||
public string ffmpeg { get; set; }
|
||||
|
||||
public string tsuri { get; set; }
|
||||
|
||||
public string tempRoot { get; set; }
|
||||
|
||||
public int idleTimeoutSec { get; set; }
|
||||
|
||||
public int idleTimeoutSec_live { get; set; }
|
||||
|
||||
public bool defaultSubtitles { get; set; }
|
||||
|
||||
public int maxConcurrentJobs { get; set; }
|
||||
|
||||
public string[] allowHosts { get; set; } = Array.Empty<string>();
|
||||
|
||||
public TranscodingHlsOptions hlsOptions { get; set; } = new();
|
||||
|
||||
public TranscodingAudioOptions audioOptions { get; set; } = new();
|
||||
|
||||
public TranscodingSubtitleOptions subtitleOptions { get; set; } = new();
|
||||
|
||||
public TranscodingPlaylistOptions playlistOptions { get; set; } = new();
|
||||
|
||||
public TranscodingConvertOptions convertOptions { get; set; } = new();
|
||||
|
||||
[JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Dictionary<string, string[]> comand { get; set; } = new Dictionary<string, string[]>()
|
||||
{
|
||||
["demuxer"] = ["-threads 0", "-fflags +genpts"],
|
||||
["input"] = ["-avoid_negative_ts disabled"],
|
||||
["output"] = [
|
||||
"-map 0:v:0", "-map 0:a:{audio_index}",
|
||||
"-dn", "-sn",
|
||||
"-map_metadata -1", "-map_chapters -1", "-max_muxing_queue_size 2048"
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
public class TranscodingHlsOptions
|
||||
{
|
||||
[JsonIgnore]
|
||||
public int seek { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// hls_time
|
||||
/// </summary>
|
||||
public int segDur { get; set; } = 6;
|
||||
|
||||
/// <summary>
|
||||
/// hls_list_size
|
||||
/// </summary>
|
||||
public int winSize { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// hls_segment_type fmp4 / mpegts
|
||||
/// </summary>
|
||||
public bool fmp4 { get; set; } = true;
|
||||
|
||||
[JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Dictionary<string, string[]> comand { get; set; } = new Dictionary<string, string[]>()
|
||||
{
|
||||
["output"] = ["-max_delay 5000000"],
|
||||
["segment_mpegts"] = ["-bsf:v h264_mp4toannexb"],
|
||||
};
|
||||
}
|
||||
|
||||
public class TranscodingAudioOptions
|
||||
{
|
||||
[JsonIgnore]
|
||||
public int index { get; set; }
|
||||
|
||||
public int bitrateKbps { get; set; } = 192;
|
||||
|
||||
public bool stereo { get; set; } = true;
|
||||
|
||||
[JsonProperty("codec_copy", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string[] codec_copy { get; set; } = Array.Empty<string>();
|
||||
|
||||
[JsonProperty("comand_transcode", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Dictionary<string, string[]> comand_transcode { get; set; } = new Dictionary<string, string[]>()
|
||||
{
|
||||
["default"] = ["-c:a aac", "-ac {stereo}", "-b:a {bitrateKbps}", "-profile:a aac_low"]
|
||||
};
|
||||
}
|
||||
|
||||
public class TranscodingPlaylistOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// sped
|
||||
/// </summary>
|
||||
public double readrate { get; set; } = 1.6;
|
||||
|
||||
/// <summary>
|
||||
/// 10 MB
|
||||
/// </summary>
|
||||
public int burst { get; set; } = 10485760;
|
||||
|
||||
public bool delete_segments { get; set; } = true;
|
||||
}
|
||||
|
||||
public class TranscodingConvertOptions
|
||||
{
|
||||
public bool transcodeVideo { get; set; }
|
||||
|
||||
[JsonProperty("codec", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string[] codec { get; set; } = { "mpeg4", "msmpeg4v3", "flv1", "av1" };
|
||||
|
||||
[JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
|
||||
public Dictionary<string, string[]> comand { get; set; } = new Dictionary<string, string[]>()
|
||||
{
|
||||
["default"] = ["-c:v libx264", "-preset veryfast", "-tune zerolatency", "-pix_fmt yuv420p"],
|
||||
|
||||
["h264_yuv420p10le"] = [
|
||||
"-vf", "scale=in_color_matrix=bt2020nc:out_color_matrix=bt709:in_range=pc:out_range=tv,format=yuv420p",
|
||||
"-c:v libx264", "-preset veryfast", "-tune zerolatency", "-pix_fmt yuv420p",
|
||||
"-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709",
|
||||
"-color_primaries bt709", "-color_trc bt709", "-colorspace bt709", "-color_range tv"
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
public class TranscodingSubtitleOptions
|
||||
{
|
||||
[JsonProperty("codec", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string[] codec { get; set; } = { "subrip", "webvtt", "ass", "ssa", "mov_text", "ttml", "sami" };
|
||||
|
||||
[JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string[] comand { get; set; } = ["-map 0:{subIndex}", "-an -vn", "-c:s webvtt", "-flush_packets 1", "-max_interleave_delta 0", "-muxpreload 0", "-muxdelay 0", "-f webvtt", "subs_{subIndex}.vtt"];
|
||||
}
|
||||
|
||||
public record TranscodingStartContext(
|
||||
Uri Source,
|
||||
string UserAgent,
|
||||
string Referer,
|
||||
TranscodingHlsOptions HlsOptions,
|
||||
TranscodingAudioOptions Audio,
|
||||
bool live,
|
||||
bool subtitles,
|
||||
string OutputDirectory,
|
||||
int? startNumber,
|
||||
JObject ffprobe
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
<b>Актуальная реализация</b>:<br>
|
||||
https://github.com/lampac-nextgen/lampac/blob/main/Modules/GStreamer/README.md
|
||||
|
||||
|
||||
# Transcoding (legacy)
|
||||
|
||||
Серверное **перекодирование потоков** (FFmpeg и связанная логика): очередь задач, каталог временных файлов, ограничение параллельных заданий и HTTP API под **`/transcoding`**.
|
||||
|
||||
## Назначение
|
||||
|
||||
- Путь модуля сохраняется в **`Transcoding.modpath`**.
|
||||
- **`TranscodingService`** конфигурируется при загрузке модуля и останавливает все задачи при выгрузке (`StopAll`).
|
||||
- Создаются каталоги **`database/tracks`** (общий паттерн с Tracks) и **`tempRoot`** для промежуточных файлов (по умолчанию `cache/transcoding`).
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Секция в `init.conf`: **`transcoding`** (имя ключа в нижнем регистре в `ModuleInvoke.Init`).
|
||||
|
||||
Параметры по умолчанию в **`ModInit`**:
|
||||
|
||||
| Параметр | Значение в коде |
|
||||
|----------|-----------------|
|
||||
| **`tempRoot`** | **`cache/transcoding`** |
|
||||
| **`idleTimeoutSec`** | **300** с |
|
||||
| **`idleTimeoutSec_live`** | **120** с |
|
||||
| **`maxConcurrentJobs`** | **5** |
|
||||
| **`limit_map`** | **`^/transcoding/`** — **50** запросов / **1** с |
|
||||
|
||||
## HTTP
|
||||
|
||||
Базовый префикс контроллера: **`transcoding`** — дочерние маршруты см. **`Controller.cs`**.
|
||||
|
||||
## Зависимости
|
||||
|
||||
- Доступность **ffmpeg** на машине (или иной исполнитель, зашитый в `TranscodingService`).
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace Transcoding;
|
||||
|
||||
internal sealed class TranscodingJob : IDisposable
|
||||
{
|
||||
static readonly Serilog.ILogger Log = Serilog.Log.ForContext<TranscodingJob>();
|
||||
|
||||
private const int MaxLogLines = 200;
|
||||
|
||||
private readonly LinkedList<string> _log = new();
|
||||
private readonly object _logSync = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private int _lastSegmentIndex = -1;
|
||||
|
||||
public TranscodingJob(string id, string streamId, string outputDirectory, Process process, TranscodingStartContext context)
|
||||
{
|
||||
Id = id;
|
||||
StreamId = streamId;
|
||||
OutputDirectory = outputDirectory;
|
||||
Process = process;
|
||||
Context = context;
|
||||
StartedUtc = DateTime.UtcNow;
|
||||
LastAccessUtc = StartedUtc;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string StreamId { get; }
|
||||
|
||||
public string OutputDirectory { get; }
|
||||
|
||||
public Process Process { get; }
|
||||
|
||||
public TranscodingStartContext Context { get; }
|
||||
|
||||
public DateTime StartedUtc { get; }
|
||||
|
||||
public DateTime LastAccessUtc { get; private set; }
|
||||
|
||||
public int? ExitCode { get; private set; }
|
||||
|
||||
public bool HasExited => Process.HasExited;
|
||||
|
||||
public CancellationToken CancellationToken => _cts.Token;
|
||||
|
||||
public void UpdateLastAccess() => LastAccessUtc = DateTime.UtcNow;
|
||||
|
||||
public int LastSegmentIndex => Volatile.Read(ref _lastSegmentIndex);
|
||||
|
||||
public void UpdateLastSegmentIndex(int segmentIndex)
|
||||
{
|
||||
if (segmentIndex < 0)
|
||||
return;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var current = Volatile.Read(ref _lastSegmentIndex);
|
||||
if (segmentIndex <= current)
|
||||
return;
|
||||
|
||||
if (Interlocked.CompareExchange(ref _lastSegmentIndex, segmentIndex, current) == current)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void AppendLog(string line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
return;
|
||||
|
||||
lock (_logSync)
|
||||
{
|
||||
foreach (var part in line.Split('\n'))
|
||||
{
|
||||
string trimmed = part.TrimEnd('\r');
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
continue;
|
||||
|
||||
if (trimmed.Length > 2000)
|
||||
trimmed = trimmed[..2000];
|
||||
|
||||
_log.AddLast(trimmed);
|
||||
if (_log.Count > MaxLogLines)
|
||||
_log.RemoveFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string[] SnapshotLog()
|
||||
{
|
||||
lock (_logSync)
|
||||
return _log.ToArray();
|
||||
}
|
||||
|
||||
public void SignalExit()
|
||||
{
|
||||
if (Process.HasExited)
|
||||
ExitCode = Process.ExitCode;
|
||||
}
|
||||
|
||||
public void StopBackground()
|
||||
=> _cts.Cancel();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
StopBackground();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Log.Error(ex, "CatchId={CatchId}", "id_p5i63hde");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Process.Dispose();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Log.Error(ex, "CatchId={CatchId}", "id_6fb2ahbb");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Transcoding;
|
||||
|
||||
public sealed class TranscodingStartRequest
|
||||
{
|
||||
public string src { get; set; } = string.Empty;
|
||||
|
||||
public TranscodingAudioOptions audio { get; set; }
|
||||
|
||||
public TranscodingHlsOptions hls { get; set; }
|
||||
|
||||
public Dictionary<string, string> headers { get; set; }
|
||||
|
||||
public bool live { get; set; }
|
||||
|
||||
public bool? subtitles { get; set; }
|
||||
}
|
||||
|
||||
public enum TranscodingJobState
|
||||
{
|
||||
Running,
|
||||
Idle,
|
||||
Stopped
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>library</OutputType>
|
||||
<IsPackable>true</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="plugin.js">
|
||||
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"enable": true,
|
||||
"tree": [
|
||||
"Services",
|
||||
"Controller.cs",
|
||||
"ModInit.cs",
|
||||
"ModuleConf.cs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
(function () {
|
||||
var API_BASE = '{localhost}';
|
||||
|
||||
var activeJob = null;
|
||||
var heartbeatTimer = null;
|
||||
var waitHandle = null;
|
||||
|
||||
function account(url) {
|
||||
url = url + '';
|
||||
if (url.indexOf('account_email=') == -1) {
|
||||
var email = Lampa.Storage.get('account_email');
|
||||
if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email));
|
||||
}
|
||||
if (url.indexOf('uid=') == -1) {
|
||||
var uid = Lampa.Storage.get('lampac_unic_id', '');
|
||||
if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid));
|
||||
}
|
||||
if (url.indexOf('token=') == -1) {
|
||||
var token = '{token}';
|
||||
if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function showWait(message) {
|
||||
Lampa.Loading.start(function(){}, message);
|
||||
}
|
||||
|
||||
function hideWait() {
|
||||
Lampa.Loading.stop();
|
||||
}
|
||||
|
||||
|
||||
function log() {
|
||||
try {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
args.unshift('Transcoder');
|
||||
console.log.apply(console, args);
|
||||
}
|
||||
catch (err) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function notify(message) {
|
||||
if (window.Lampa && Lampa.Noty && Lampa.Noty.show) {
|
||||
Lampa.Noty.show(message);
|
||||
}
|
||||
else {
|
||||
log(message);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMediaUrl(data) {
|
||||
if (data && data.url) return data.url;
|
||||
return '';
|
||||
}
|
||||
|
||||
function isMkvSource(data) {
|
||||
var url = resolveMediaUrl(data);
|
||||
if (!url) return false;
|
||||
return /\.(mkv|avi|flv)($|\?|#)/i.test(url) || /\/lite\/pidtor\//i.test(url);
|
||||
}
|
||||
|
||||
function requestFfprobe(mediaUrl, onSuccess, onError) {
|
||||
if (!mediaUrl) {
|
||||
if (onError) onError(new Error('Empty media url'));
|
||||
return;
|
||||
}
|
||||
|
||||
var net = new Lampa.Reguest();
|
||||
var target = account(API_BASE + '/ffprobe?media=' + encodeURIComponent(mediaUrl));
|
||||
|
||||
log('request ffprobe', target);
|
||||
|
||||
net.native(target, function (response) {
|
||||
try {
|
||||
var json = typeof response === 'string' ? JSON.parse(response) : response;
|
||||
try {
|
||||
var streams = json && Array.isArray(json.streams) ? json.streams : [];
|
||||
var v = streams.find(function(s){ return s.codec_type === 'video'; });
|
||||
var rawCodec = (v && (v.codec_name || (v.tags && v.tags.codec_name))) || '';
|
||||
} catch(e) { /* noop */ }
|
||||
if (onSuccess) onSuccess(json || {});
|
||||
} catch(e) {
|
||||
if (onError) onError(e);
|
||||
}
|
||||
}, function (error) {
|
||||
hideWait();
|
||||
if (onError) onError(error || new Error('ffprobe request failed'));
|
||||
}, null, {
|
||||
dataType: 'text',
|
||||
timeout: 1000 * 40
|
||||
});
|
||||
}
|
||||
|
||||
function formatAudioItem(track, index) {
|
||||
var tags = track && track.tags ? track.tags : {};
|
||||
var title = tags.title || tags.handler_name || ('Audio #' + (index + 1));
|
||||
var lang = (tags.language || '').toUpperCase();
|
||||
var codec = (track.codec_name || '').toUpperCase();
|
||||
var channels = (track.channel_layout || '').replace('(side)', '').replace('stereo', '2.0');
|
||||
var rate = track.bit_rate ? Math.round(track.bit_rate / 1000) + ' kbps' : '';
|
||||
|
||||
var subtitleParts = [];
|
||||
if (lang) subtitleParts.push(lang);
|
||||
if (codec) subtitleParts.push(codec);
|
||||
if (channels) subtitleParts.push(channels);
|
||||
if (rate) subtitleParts.push(rate);
|
||||
|
||||
return {
|
||||
title: title,
|
||||
subtitle: subtitleParts.join(' • '),
|
||||
track: track
|
||||
};
|
||||
}
|
||||
|
||||
function selectAutoAudioTrack(audioTracks) {
|
||||
for (var i = 0; i < audioTracks.length; i++) {
|
||||
var disposition = audioTracks[i] && audioTracks[i].disposition;
|
||||
if (disposition && (disposition.default === 1 || disposition.default === true)) {
|
||||
return audioTracks[i];
|
||||
}
|
||||
}
|
||||
|
||||
var language = String(Lampa.Storage.get('language', 'ru') || 'ru').toLowerCase().split('-')[0];
|
||||
var aliases = {
|
||||
ru: ['ru', 'rus'],
|
||||
en: ['en', 'eng'],
|
||||
uk: ['uk', 'ukr'],
|
||||
be: ['be', 'bel'],
|
||||
bg: ['bg', 'bul'],
|
||||
he: ['he', 'heb'],
|
||||
cs: ['cs', 'ces', 'cze'],
|
||||
ro: ['ro', 'ron', 'rum'],
|
||||
de: ['de', 'deu', 'ger'],
|
||||
fr: ['fr', 'fra', 'fre'],
|
||||
es: ['es', 'spa'],
|
||||
it: ['it', 'ita'],
|
||||
pt: ['pt', 'por'],
|
||||
pl: ['pl', 'pol'],
|
||||
tr: ['tr', 'tur'],
|
||||
ja: ['ja', 'jpn'],
|
||||
ko: ['ko', 'kor'],
|
||||
zh: ['zh', 'zho', 'chi']
|
||||
};
|
||||
var preferred = aliases[language] || (language ? [language] : []);
|
||||
|
||||
for (var j = 0; j < audioTracks.length; j++) {
|
||||
var tags = audioTracks[j] && audioTracks[j].tags ? audioTracks[j].tags : {};
|
||||
var trackLanguage = String(tags.language || '').toLowerCase();
|
||||
if (preferred.indexOf(trackLanguage) !== -1) {
|
||||
return audioTracks[j];
|
||||
}
|
||||
}
|
||||
|
||||
return audioTracks[0];
|
||||
}
|
||||
|
||||
function showAudioSelector(data, audioTracks) {
|
||||
if (!audioTracks.length) {
|
||||
notify('Не удалось найти аудиодорожки для MKV файла');
|
||||
return;
|
||||
}
|
||||
|
||||
var manualSelection = Lampa.Storage.get('transcoding_audio_prompt', false);
|
||||
if (!(manualSelection === true || manualSelection === 'true')) {
|
||||
startTranscoding(data, selectAutoAudioTrack(audioTracks));
|
||||
return;
|
||||
}
|
||||
|
||||
var items = audioTracks.map(function (track, index) {
|
||||
return formatAudioItem(track, index);
|
||||
});
|
||||
|
||||
var last_controller = Lampa.Controller.enabled().name
|
||||
|
||||
Lampa.Select.show({
|
||||
title: 'Выберите аудиодорожку',
|
||||
items: items,
|
||||
onSelect: function (item) {
|
||||
Lampa.Select.close();
|
||||
if (!item || !item.track) {
|
||||
notify('Выбрана пустая дорожка');
|
||||
return;
|
||||
}
|
||||
startTranscoding(data, item.track);
|
||||
},
|
||||
onBack: function () {
|
||||
Lampa.Controller.toggle(last_controller)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startTranscoding(data, track) {
|
||||
stopHeartbeat();
|
||||
ensureJobStopped(true);
|
||||
|
||||
var payload = {
|
||||
src: resolveMediaUrl(data),
|
||||
audio: { index: track.index -1 },
|
||||
live: false
|
||||
};
|
||||
|
||||
/*if (data && data.subtitles) {
|
||||
payload.subtitles = !!data.subtitles;
|
||||
}*/
|
||||
|
||||
var net = new Lampa.Reguest();
|
||||
net.native(account(API_BASE + '/transcoding/start'), function (response) {
|
||||
var json = typeof response === 'string' ? JSON.parse(response) : response;
|
||||
if (!json || !json.streamId || !json.playlistUrl) {
|
||||
notify('Некорректный ответ от сервера перекодирования');
|
||||
log('invalid start response', response);
|
||||
return;
|
||||
}
|
||||
|
||||
activeJob = {
|
||||
streamId: json.streamId,
|
||||
playlistUrl: json.playlistUrl
|
||||
};
|
||||
|
||||
var playback = data || {};
|
||||
playback.transcoding = true;
|
||||
playback.ffprobe = null;
|
||||
playback.url = json.playlistUrl;
|
||||
playback.subtitles_call = json.subtitlesUrl;
|
||||
playback.hls_manifest_timeout = json.hls_timeout_seconds * 1000;
|
||||
Lampa.Player.play(playback);
|
||||
|
||||
}, function (error) {
|
||||
hideWait();
|
||||
notify('Не удалось запустить перекодирование MKV');
|
||||
log('start error', error);
|
||||
}, JSON.stringify(payload), {
|
||||
dataType: 'json',
|
||||
type: 'POST',
|
||||
timeout: 1000 * 20,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendHeartbeat() {
|
||||
if (!activeJob) return;
|
||||
|
||||
var net = new Lampa.Reguest();
|
||||
net.native(account(API_BASE + '/transcoding/' + activeJob.streamId + '/heartbeat'), function () {
|
||||
log('heartbeat sent');
|
||||
}, function (error) {
|
||||
hideWait();
|
||||
log('heartbeat error', error);
|
||||
}, null, {
|
||||
dataType: 'text',
|
||||
timeout: 1000 * 10
|
||||
});
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureJobStopped(sendRemote) {
|
||||
if (!activeJob) return;
|
||||
|
||||
var job = activeJob;
|
||||
activeJob = null;
|
||||
stopHeartbeat();
|
||||
|
||||
if (sendRemote) {
|
||||
var net = new Lampa.Reguest();
|
||||
net.native(account(API_BASE + '/transcoding/' + job.streamId + '/stop'), function () {
|
||||
log('stop sent');
|
||||
}, function (error) {
|
||||
hideWait();
|
||||
log('stop error', error);
|
||||
}, null, {
|
||||
dataType: 'text',
|
||||
timeout: 1000 * 10
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerSettings() {
|
||||
if (window.lampac_transcoding_audio_setting || !Lampa.SettingsApi || !Lampa.SettingsApi.addParam) return;
|
||||
window.lampac_transcoding_audio_setting = true;
|
||||
Lampa.SettingsApi.addParam({
|
||||
component: 'player',
|
||||
param: {
|
||||
name: 'transcoding_audio_prompt',
|
||||
type: 'trigger',
|
||||
values: '',
|
||||
default: false
|
||||
},
|
||||
field: {
|
||||
name: 'Спрашивать аудиодорожку при транскодировании',
|
||||
description: 'Включите для ручного выбора дорожки перед запуском MKV'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handlePlayerStart(e) {
|
||||
if (!isMkvSource(e.data) || e.data.transcoding || /\/transcoding\//i.test(e.data.url)) return;
|
||||
|
||||
e.abort();
|
||||
e.data.url = e.data.url.replace(/&(preload|stat|m3u)/g, '&play');
|
||||
|
||||
log('intercept mkv playback', e.data.url);
|
||||
|
||||
showWait('Получение списка аудио дорожек...');
|
||||
|
||||
requestFfprobe(resolveMediaUrl(e.data), function (info) {
|
||||
hideWait();
|
||||
var streams = info && Array.isArray(info.streams) ? info.streams : [];
|
||||
var audioTracks = streams.filter(function (track) {
|
||||
return track.codec_type === 'audio';
|
||||
});
|
||||
showAudioSelector(e.data, audioTracks);
|
||||
}, function (error) {
|
||||
hideWait();
|
||||
notify('Ошибка анализа MKV файла');
|
||||
log('ffprobe error', error);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePlayerDestroy() {
|
||||
ensureJobStopped(true);
|
||||
}
|
||||
|
||||
function handleVideoPause() {
|
||||
if (!activeJob) return;
|
||||
|
||||
stopHeartbeat();
|
||||
heartbeatTimer = setInterval(sendHeartbeat, 1000 * 10);
|
||||
}
|
||||
|
||||
function handleVideoPlay() {
|
||||
if (!activeJob) return;
|
||||
|
||||
stopHeartbeat();
|
||||
}
|
||||
|
||||
if (!window.lampac_transcoding_plugin) {
|
||||
window.lampac_transcoding_plugin = true;
|
||||
registerSettings();
|
||||
|
||||
Lampa.Player.listener.follow('create', handlePlayerStart);
|
||||
Lampa.Player.listener.follow('destroy', handlePlayerDestroy);
|
||||
Lampa.PlayerVideo.listener.follow('pause', handleVideoPause);
|
||||
Lampa.PlayerVideo.listener.follow('play', handleVideoPlay);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user