Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
+594
View File
@@ -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
}
+54
View File
@@ -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 })
}
});
}
}
+153
View File
@@ -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
);
+35
View File
@@ -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`).
+130
View File
@@ -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
+19
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
{
"enable": true,
"tree": [
"Services",
"Controller.cs",
"ModInit.cs",
"ModuleConf.cs"
]
}
+356
View File
@@ -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);
}
})();