1004 lines
33 KiB
C#
1004 lines
33 KiB
C#
using Newtonsoft.Json.Linq;
|
|
using Shared;
|
|
using Shared.Services;
|
|
using Shared.Models.Base;
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Web;
|
|
|
|
namespace Transcoding;
|
|
|
|
internal sealed class TranscodingService
|
|
{
|
|
static readonly Serilog.ILogger Log = Serilog.Log.ForContext<TranscodingService>();
|
|
|
|
static readonly Lazy<TranscodingService> _lazy = new(() => new TranscodingService());
|
|
|
|
readonly ConcurrentDictionary<string, TranscodingJob> _jobs = new();
|
|
readonly Regex _safeFileNameRegex = new("^[A-Za-z0-9_.-]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
readonly Regex _segmentFileRegex = new("^seg_(\\d+)\\.(m4s|ts)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
|
|
|
int _segmentCleanupRunning;
|
|
readonly Timer _segmentCleanupTimer;
|
|
|
|
byte[] _hmacKey = RandomNumberGenerator.GetBytes(32);
|
|
static string _ffmpegPath;
|
|
|
|
public static TranscodingService Instance => _lazy.Value;
|
|
|
|
private TranscodingService()
|
|
{
|
|
_segmentCleanupTimer = new Timer(_ => CleanupSegments(), null, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(20));
|
|
}
|
|
|
|
public void Configure(ModuleConf config)
|
|
{
|
|
if (config == null)
|
|
return;
|
|
|
|
_ffmpegPath = string.IsNullOrWhiteSpace(config.ffmpeg)
|
|
? (CoreInit.Win32NT ? "data/ffmpeg.exe" : (File.Exists("data/ffmpeg") ? "data/ffmpeg" : "ffmpeg"))
|
|
: config.ffmpeg;
|
|
|
|
if (string.IsNullOrWhiteSpace(config.tempRoot))
|
|
config.tempRoot = Path.Combine("cache", "transcoding");
|
|
|
|
try
|
|
{
|
|
if (!Directory.Exists(config.tempRoot))
|
|
{
|
|
Directory.CreateDirectory(config.tempRoot);
|
|
}
|
|
else
|
|
{
|
|
foreach (var dir in Directory.GetDirectories(config.tempRoot))
|
|
{
|
|
try
|
|
{
|
|
Directory.Delete(dir, true);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_g6yuafvi");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_quspivc7");
|
|
}
|
|
}
|
|
|
|
public ICollection<TranscodingJob> Jobs => _jobs.Values;
|
|
|
|
async public Task<(TranscodingJob job, string error)> Start(TranscodingStartRequest request)
|
|
{
|
|
var config = GetConfig();
|
|
|
|
if (request == null || string.IsNullOrWhiteSpace(request.src))
|
|
return (null!, "Source URL is required");
|
|
|
|
if (_jobs.Count >= Math.Max(1, config.maxConcurrentJobs))
|
|
return (null!, "Maximum concurrent jobs reached");
|
|
|
|
if (!TryValidateSource(request.src, config, out var source, out var error))
|
|
return (null!, error);
|
|
|
|
var id = Guid.NewGuid().ToString("N");
|
|
var streamId = BuildToken(id);
|
|
|
|
var outputDir = Path.Combine(config.tempRoot!, id);
|
|
Directory.CreateDirectory(outputDir);
|
|
|
|
#region ffprobe
|
|
var ffprobe = await Http.Get<JObject>($"http://{CoreInit.conf.listen.localhost}:{CoreInit.conf.listen.port}/ffprobe?media={HttpUtility.UrlEncode(request.src)}", headers: HeadersModel.Init("lcrqpasswd", CoreInit.rootPasswd));
|
|
|
|
if (ffprobe == null || !ffprobe.ContainsKey("format"))
|
|
return (null!, "ffprobe");
|
|
#endregion
|
|
|
|
var context = new TranscodingStartContext(
|
|
source,
|
|
SanitizeHeader(GetHeader(request.headers, "userAgent"), Http.UserAgent),
|
|
SanitizeHeader(GetHeader(request.headers, "referer")),
|
|
MergeHlsOptions(config, request.hls),
|
|
MergeAudioOptions(config, request.audio),
|
|
request.live,
|
|
request.subtitles ?? config.defaultSubtitles,
|
|
outputDir,
|
|
null,
|
|
ffprobe
|
|
);
|
|
|
|
var process = CreateProcess(context);
|
|
|
|
try
|
|
{
|
|
if (!process.Start())
|
|
{
|
|
process.Dispose();
|
|
return (null!, "Failed to start ffmpeg");
|
|
}
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Serilog.Log.Error(ex, "CatchId={CatchId}", "id_71c20640");
|
|
process.Dispose();
|
|
return (null!, $"Failed to start ffmpeg: {ex.Message}");
|
|
}
|
|
|
|
var job = new TranscodingJob(id, streamId, outputDir, process, context);
|
|
if (!_jobs.TryAdd(id, job))
|
|
{
|
|
try
|
|
{
|
|
process.Kill(true);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_avj4m3h6");
|
|
}
|
|
|
|
process.Dispose();
|
|
try
|
|
{
|
|
if (Directory.Exists(outputDir))
|
|
Directory.Delete(outputDir, true);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_q5jm90jq");
|
|
}
|
|
return (null!, "Failed to register job");
|
|
}
|
|
|
|
_ = Task.Run(() => PumpStdErrAsync(job));
|
|
_ = Task.Run(() => IdleWatchdogAsync(job, config));
|
|
|
|
process.EnableRaisingEvents = true;
|
|
process.Exited += (_, _) =>
|
|
{
|
|
if (process.EnableRaisingEvents)
|
|
OnProcessExit(job);
|
|
};
|
|
|
|
return (job, string.Empty);
|
|
}
|
|
|
|
public bool TryResolveJob(string streamId, out TranscodingJob job)
|
|
{
|
|
job = null!;
|
|
if (string.IsNullOrWhiteSpace(streamId))
|
|
return false;
|
|
|
|
if (!TryParseToken(streamId, out var id))
|
|
return false;
|
|
|
|
if (!_jobs.TryGetValue(id, out job))
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
public async Task<(bool success, string error)> SeekAsync(string streamId, int seconds, int? startSegment = null)
|
|
{
|
|
if (seconds < 0)
|
|
return (false, "ss must be greater or equal 0");
|
|
|
|
if (!TryResolveJob(streamId, out var job))
|
|
return (false, "Job not found");
|
|
|
|
var config = GetConfig();
|
|
|
|
var newContext = job.Context with
|
|
{
|
|
HlsOptions = new TranscodingHlsOptions
|
|
{
|
|
seek = seconds,
|
|
segDur = job.Context.HlsOptions.segDur,
|
|
winSize = job.Context.HlsOptions.winSize,
|
|
fmp4 = job.Context.HlsOptions.fmp4
|
|
},
|
|
startNumber = startSegment
|
|
};
|
|
|
|
job.Process.EnableRaisingEvents = false;
|
|
await StopJobAsync(job, forced: true, cleanup: false);
|
|
|
|
Directory.CreateDirectory(newContext.OutputDirectory);
|
|
|
|
var process = CreateProcess(newContext);
|
|
|
|
try
|
|
{
|
|
if (!process.Start())
|
|
{
|
|
process.Dispose();
|
|
return (false, "Failed to start ffmpeg");
|
|
}
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Serilog.Log.Error(ex, "CatchId={CatchId}", "id_1af34286");
|
|
process.Dispose();
|
|
return (false, $"Failed to start ffmpeg: {ex.Message}");
|
|
}
|
|
|
|
var newJob = new TranscodingJob(job.Id, job.StreamId, job.OutputDirectory, process, newContext);
|
|
|
|
if (_jobs.AddOrUpdate(job.Id, newJob, (k, v) => newJob) == null)
|
|
{
|
|
try
|
|
{
|
|
process.Kill(true);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_rteqssej");
|
|
}
|
|
|
|
process.Dispose();
|
|
return (false, "Failed to restart job");
|
|
}
|
|
|
|
_ = Task.Run(() => PumpStdErrAsync(newJob));
|
|
_ = Task.Run(() => IdleWatchdogAsync(newJob, config));
|
|
|
|
process.EnableRaisingEvents = true;
|
|
process.Exited += (_, _) =>
|
|
{
|
|
if (process.EnableRaisingEvents)
|
|
OnProcessExit(newJob);
|
|
};
|
|
|
|
return (true, string.Empty);
|
|
}
|
|
|
|
public async Task<bool> StopAsync(string streamId)
|
|
{
|
|
if (!TryResolveJob(streamId, out var job))
|
|
return false;
|
|
|
|
await StopJobAsync(job);
|
|
return true;
|
|
}
|
|
|
|
public void Touch(TranscodingJob job) => job.UpdateLastAccess();
|
|
|
|
public string GetFilePath(TranscodingJob job, string file)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(file) || !_safeFileNameRegex.IsMatch(file))
|
|
return null;
|
|
|
|
var candidate = Path.Combine(job.OutputDirectory, file);
|
|
if (!candidate.StartsWith(job.OutputDirectory, StringComparison.Ordinal))
|
|
return null;
|
|
|
|
return File.Exists(candidate) ? candidate : null;
|
|
}
|
|
|
|
public void ReportSegmentAccess(TranscodingJob job, int segmentIndex)
|
|
{
|
|
job?.UpdateLastSegmentIndex(segmentIndex);
|
|
}
|
|
|
|
private async Task StopJobAsync(TranscodingJob job, bool forced = false, bool cleanup = true)
|
|
{
|
|
try
|
|
{
|
|
if (!job.Process.HasExited)
|
|
{
|
|
if (forced)
|
|
{
|
|
job.Process.Kill(true);
|
|
}
|
|
else
|
|
{
|
|
try
|
|
{
|
|
await job.Process.StandardInput.WriteLineAsync("q");
|
|
await job.Process.StandardInput.FlushAsync();
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_a5h7gduz");
|
|
}
|
|
|
|
var waitTask = job.Process.WaitForExitAsync();
|
|
var timeout = Task.Delay(TimeSpan.FromMilliseconds(1500));
|
|
var completed = await Task.WhenAny(waitTask, timeout);
|
|
if (completed != waitTask)
|
|
{
|
|
try
|
|
{
|
|
job.Process.Kill(true);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_9lgmzpo5");
|
|
}
|
|
|
|
try
|
|
{
|
|
await job.Process.WaitForExitAsync();
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_zmkmg1y6");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
try
|
|
{
|
|
await waitTask;
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_a56zgctr");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (cleanup)
|
|
Cleanup(job);
|
|
}
|
|
}
|
|
|
|
private void Cleanup(TranscodingJob job)
|
|
{
|
|
var removed = _jobs.TryRemove(job.Id, out _);
|
|
|
|
try
|
|
{
|
|
job.StopBackground();
|
|
job.SignalExit();
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_9c5you07");
|
|
}
|
|
|
|
if (!removed)
|
|
return;
|
|
|
|
try
|
|
{
|
|
if (Directory.Exists(job.OutputDirectory))
|
|
Directory.Delete(job.OutputDirectory, true);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_z4e0wm3m");
|
|
}
|
|
|
|
job.Dispose();
|
|
}
|
|
|
|
private ModuleConf GetConfig()
|
|
{
|
|
return ModInit.conf;
|
|
}
|
|
|
|
private static string GetHeader(Dictionary<string, string> headers, string key)
|
|
{
|
|
if (headers == null || headers.Count == 0)
|
|
return null;
|
|
|
|
foreach (var (k, v) in headers)
|
|
{
|
|
if (string.Equals(k, key, StringComparison.OrdinalIgnoreCase))
|
|
return v;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static TranscodingHlsOptions MergeHlsOptions(ModuleConf config, TranscodingHlsOptions request)
|
|
{
|
|
var opt = request ?? config.hlsOptions;
|
|
|
|
return new TranscodingHlsOptions
|
|
{
|
|
seek = opt?.seek > 0 ? opt.seek : 0,
|
|
segDur = opt?.segDur > 1 ? opt.segDur : 1,
|
|
winSize = opt?.winSize > 5 ? opt.winSize : 5,
|
|
fmp4 = opt?.fmp4 ?? true
|
|
};
|
|
}
|
|
|
|
private static TranscodingAudioOptions MergeAudioOptions(ModuleConf config, TranscodingAudioOptions request)
|
|
{
|
|
var opt = request ?? config.audioOptions;
|
|
|
|
return new TranscodingAudioOptions
|
|
{
|
|
index = opt?.index >= 0 ? opt.index : 0,
|
|
bitrateKbps = opt?.bitrateKbps is > 0 and <= 512 ? opt.bitrateKbps : 160,
|
|
stereo = opt?.stereo ?? true,
|
|
codec_copy = opt?.codec_copy ?? config.audioOptions.codec_copy
|
|
};
|
|
}
|
|
|
|
private static bool TryValidateSource(string src, ModuleConf config, out Uri uri, out string error)
|
|
{
|
|
error = string.Empty;
|
|
uri = null!;
|
|
|
|
if (!Uri.TryCreate(src, UriKind.Absolute, out uri) ||
|
|
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
|
{
|
|
error = "Only http/https URLs are allowed";
|
|
return false;
|
|
}
|
|
|
|
if (config.allowHosts != null && config.allowHosts.Length > 0)
|
|
{
|
|
if (!config.allowHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
error = "Source host is not allowed";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public void StopAll()
|
|
{
|
|
try
|
|
{
|
|
foreach (var job in _jobs.Values.ToArray())
|
|
{
|
|
try
|
|
{
|
|
_ = StopJobAsync(job, forced: true).ConfigureAwait(false);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_sp9w94ug");
|
|
}
|
|
}
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Serilog.Log.Error(ex, "{Class} {CatchId}", "ModInit", "id_ley70nc7");
|
|
}
|
|
}
|
|
|
|
private Process CreateProcess(TranscodingStartContext context)
|
|
{
|
|
var config = GetConfig();
|
|
|
|
var process = new Process
|
|
{
|
|
StartInfo = new ProcessStartInfo
|
|
{
|
|
FileName = _ffmpegPath,
|
|
UseShellExecute = false,
|
|
RedirectStandardError = true,
|
|
RedirectStandardInput = true,
|
|
RedirectStandardOutput = false,
|
|
CreateNoWindow = true,
|
|
WorkingDirectory = context.OutputDirectory
|
|
}
|
|
};
|
|
|
|
var args = process.StartInfo.ArgumentList;
|
|
|
|
/*
|
|
-hide_banner — отключает вывод баннера ffmpeg (версия/конфигурация) в stderr, чтобы логи были чище.
|
|
-user_agent {context.UserAgent} — задаёт заголовок User-Agent для HTTP-запросов к входному URL.
|
|
-headers "Referer: {context.Referer}\n" — добавляет произвольные HTTP-заголовки (здесь: Referer) при обращении к входному URL.
|
|
-re — читает входной поток в реальном времени; полезно при трансляции/стриминге, чтобы не читать вход быстрее реального времени.
|
|
-threads 0 — позволяет ffmpeg автоматически выбрать количество потоков (CPU) для кодирования/декодирования.
|
|
-fflags +genpts — генерирует PTS для кадров, если их нет (избегает проблем с отсутствующими временными метками).
|
|
-i {Source} — указывает входной источник (URL/файл).
|
|
-map 0:v:0 — маппит первый видеопоток первого входа в выход.
|
|
-map 0:a:0 — маппит первый аудиопоток первого входа в выход.
|
|
-sn (опционально, при subtitles == false) — исключить субтитры из вывода.
|
|
-dn — исключить data-потоки (metadata/data tracks).
|
|
-map_metadata -1 — не копировать глобальные метаданные в выход (очищает метаданные).
|
|
-map_chapters -1 — не копировать главы (удаляет chapters).
|
|
-c:v copy — копирование видеопотока без перекодирования (для минимальной нагрузки и без потери качества).
|
|
|
|
Аудио:
|
|
Если transcodeToAac:
|
|
-c:a aac — кодировать аудио в AAC.
|
|
-ac 2 / -ac 1 — число каналов (стерео/моно) по настройке.
|
|
-b:a {N}k — битрейт аудио.
|
|
-profile:a aac_low — профиль AAC (обычно LC для совместимости и низкой задержки).
|
|
Иначе: -c:a copy — копирование аудио без перекодирования.
|
|
|
|
HLS / контейнер:
|
|
-avoid_negative_ts disabled — не сдвигать временные метки для предотвращения отрицательных TS; сохраняет исходные PTS при HLS-сегментации.
|
|
-max_muxing_queue_size 2048 — увеличивает очередь пакетов при мультиплексировании (помогает против “Too many packets buffered”).
|
|
-f hls — формат выхода HLS (HTTP Live Streaming).
|
|
-max_delay 5000000 — максимальная задержка буфера (в мкс; зависит от контекста), снижает риск избыточного буферинга.
|
|
-hls_segment_type fmp4 или mpegts — тип сегментов HLS (CMAF/fMP4 или MPEG-TS).
|
|
для mpegts: -bsf:v h264_mp4toannexb — конвертирует H.264 к Annex-B, как требуется для TS.
|
|
-hls_time {segDur} — длительность сегмента в секундах.
|
|
-hls_flags append_list+omit_endlist —
|
|
append_list — дописывать записи в плейлист;
|
|
omit_endlist — не добавлять #EXT-X-ENDLIST, чтобы плейлист считался “живым”.
|
|
-hls_list_size {winSize} — размер окна плейлиста (сколько последних сегментов видит клиент).
|
|
-master_pl_name index.m3u8 — имя master-плейлиста (если используется).
|
|
-hls_fmp4_init_filename {init.mp4} — имя init-сегмента для fMP4.
|
|
-hls_segment_filename {seg_%05d.m4s | seg_%05d.ts} — шаблон имени файлов сегментов.
|
|
-y {PlaylistPath} — перезаписать выходной файл без подтверждения (путь итогового плейлиста/файла).
|
|
*/
|
|
|
|
args.Add("-hide_banner");
|
|
|
|
if (context.UserAgent != null)
|
|
{
|
|
args.Add("-user_agent");
|
|
args.Add(context.UserAgent);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(context.Referer))
|
|
{
|
|
args.Add("-headers");
|
|
args.Add($"Referer: {context.Referer}\\r\\n");
|
|
}
|
|
|
|
if (context.HlsOptions.seek > 0)
|
|
{
|
|
args.Add("-ss");
|
|
args.Add(context.HlsOptions.seek.ToString());
|
|
args.Add("-noaccurate_seek");
|
|
}
|
|
|
|
args.Add("-nostats");
|
|
args.Add("-progress");
|
|
args.Add("pipe:2");
|
|
args.Add("-stats_period");
|
|
args.Add(context.live ? "1" : "5");
|
|
|
|
#region demuxer
|
|
foreach (var c in config.comand["demuxer"])
|
|
{
|
|
foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
|
args.Add(a);
|
|
}
|
|
#endregion
|
|
|
|
#region readrate
|
|
if (context.live)
|
|
{
|
|
args.Add("-re");
|
|
}
|
|
else if (config.playlistOptions.readrate > 0)
|
|
{
|
|
args.Add("-readrate");
|
|
args.Add(config.playlistOptions.readrate.ToString().Replace(",", "."));
|
|
|
|
if (config.playlistOptions.burst > 0)
|
|
{
|
|
args.Add("-readrate_initial_burst");
|
|
args.Add(config.playlistOptions.burst.ToString());
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
args.Add("-i");
|
|
args.Add(context.Source.AbsoluteUri);
|
|
|
|
#region input
|
|
foreach (var c in config.comand["input"])
|
|
{
|
|
foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
|
args.Add(a);
|
|
}
|
|
#endregion
|
|
|
|
#region subtitles map
|
|
if (context.subtitles && !context.live && 0 >= config.playlistOptions.readrate && context.ffprobe.ContainsKey("streams"))
|
|
{
|
|
args.Add("-copyts");
|
|
|
|
foreach (var s in context.ffprobe["streams"])
|
|
{
|
|
if (s.Value<string>("codec_type") != "subtitle")
|
|
continue;
|
|
|
|
string codec_name = s.Value<string>("codec_name");
|
|
if (!string.IsNullOrEmpty(codec_name) && config.subtitleOptions.codec.Contains(codec_name))
|
|
{
|
|
int subIndex = s.Value<int>("index");
|
|
if (subIndex == 0)
|
|
continue;
|
|
|
|
foreach (var c in config.subtitleOptions.comand)
|
|
{
|
|
foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
|
args.Add(a.Replace("{subIndex}", subIndex.ToString()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region HLS map
|
|
foreach (var c in config.comand["output"])
|
|
{
|
|
foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
|
args.Add(a.Replace("{audio_index}", $"{(0 >= context.Audio.index ? 0 : context.Audio.index)}"));
|
|
}
|
|
|
|
#region -c:v
|
|
if (config.convertOptions.transcodeVideo && config.convertOptions.codec != null &&
|
|
context.ffprobe != null && context.ffprobe.ContainsKey("streams") &&
|
|
config.convertOptions.comand != null && config.convertOptions.comand.Count > 0)
|
|
{
|
|
try
|
|
{
|
|
bool convert = false;
|
|
|
|
string codec_name = context.ffprobe["streams"].First.Value<string>("codec_name") ?? "";
|
|
if (!string.IsNullOrEmpty(codec_name) && config.convertOptions.codec.Contains(codec_name))
|
|
convert = true;
|
|
|
|
string pix_fmt = context.ffprobe["streams"].First.Value<string>("pix_fmt") ?? "";
|
|
if (!string.IsNullOrEmpty(pix_fmt) && config.convertOptions.codec.Contains(pix_fmt))
|
|
convert = true;
|
|
|
|
if (config.convertOptions.codec.Contains($"{codec_name}_{pix_fmt}"))
|
|
convert = true;
|
|
|
|
if (convert)
|
|
{
|
|
string[] comand = config.convertOptions.comand["default"];
|
|
|
|
foreach (string key in new string[] { $"{codec_name}_{pix_fmt}", pix_fmt, codec_name })
|
|
{
|
|
if (config.convertOptions.comand.ContainsKey(key))
|
|
{
|
|
comand = config.convertOptions.comand[key];
|
|
break;
|
|
}
|
|
}
|
|
|
|
foreach (var c in comand)
|
|
{
|
|
foreach (var t in c.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
|
args.Add(t);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
args.Add("-c:v");
|
|
args.Add("copy");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
args.Add("-c:v");
|
|
args.Add("copy");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
args.Add("-c:v");
|
|
args.Add("copy");
|
|
}
|
|
#endregion
|
|
|
|
#region -c:a
|
|
{
|
|
bool convert = true;
|
|
|
|
var audioCodec = context.ffprobe["streams"].FirstOrDefault(i => i.Value<int>("index") == ((0 >= context.Audio.index ? 0 : context.Audio.index) + 1));
|
|
|
|
string codec_name = audioCodec?.Value<string>("codec_name") ?? string.Empty;
|
|
if (!string.IsNullOrEmpty(codec_name) && context.Audio.codec_copy.Contains(codec_name))
|
|
convert = false;
|
|
|
|
string channel = audioCodec?.Value<int?>("channels")?.ToString() ?? string.Empty;
|
|
if (!string.IsNullOrEmpty(channel) && context.Audio.codec_copy.Contains(channel))
|
|
convert = false;
|
|
|
|
if (context.Audio.codec_copy.Contains($"{codec_name}_{channel}"))
|
|
convert = false;
|
|
|
|
if (convert)
|
|
{
|
|
string[] comand = config.audioOptions.comand_transcode["default"];
|
|
|
|
foreach (string key in new string[] { $"{codec_name}_{channel}", channel, codec_name })
|
|
{
|
|
if (config.audioOptions.comand_transcode.ContainsKey(key))
|
|
{
|
|
comand = config.audioOptions.comand_transcode[key];
|
|
break;
|
|
}
|
|
}
|
|
|
|
foreach (var c in comand)
|
|
{
|
|
foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
|
args.Add(a.Replace("{stereo}", context.Audio.stereo ? "2" : "1").Replace("{bitrateKbps}", $"{Math.Clamp(context.Audio.bitrateKbps, 32, 512)}k"));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
args.Add("-c:a");
|
|
args.Add("copy");
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
args.Add("-f");
|
|
args.Add("hls");
|
|
|
|
foreach (var c in config.hlsOptions.comand["output"])
|
|
{
|
|
foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
|
args.Add(a);
|
|
}
|
|
|
|
#region -hls_segment_type
|
|
args.Add("-hls_segment_type");
|
|
|
|
if (context.HlsOptions.fmp4)
|
|
{
|
|
args.Add("fmp4");
|
|
}
|
|
else
|
|
{
|
|
args.Add("mpegts");
|
|
|
|
foreach (var c in config.hlsOptions.comand["segment_mpegts"])
|
|
{
|
|
foreach (var a in c.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
|
args.Add(a);
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
args.Add("-hls_time");
|
|
args.Add(context.HlsOptions.segDur.ToString(CultureInfo.InvariantCulture));
|
|
|
|
if (context.live)
|
|
{
|
|
args.Add("-hls_flags");
|
|
args.Add("append_list+omit_endlist+delete_segments");
|
|
}
|
|
|
|
args.Add("-hls_list_size");
|
|
args.Add(context.HlsOptions.winSize.ToString(CultureInfo.InvariantCulture));
|
|
|
|
#region -start_number
|
|
int? startNumber = context.startNumber;
|
|
if (!startNumber.HasValue && context.HlsOptions.seek > 0)
|
|
{
|
|
int segDur = Math.Max(1, context.HlsOptions.segDur);
|
|
startNumber = context.HlsOptions.seek / segDur;
|
|
}
|
|
|
|
if (startNumber.HasValue)
|
|
{
|
|
args.Add("-start_number");
|
|
args.Add(startNumber.Value.ToString());
|
|
}
|
|
#endregion
|
|
|
|
args.Add("-master_pl_name");
|
|
args.Add("index.m3u8");
|
|
|
|
if (context.HlsOptions.fmp4)
|
|
{
|
|
args.Add("-hls_fmp4_init_filename");
|
|
args.Add("init.mp4");
|
|
}
|
|
else
|
|
{
|
|
args.Add("-hls_playlist_type");
|
|
args.Add("vod");
|
|
}
|
|
|
|
args.Add("-hls_segment_filename");
|
|
args.Add(context.HlsOptions.fmp4 ? "seg_%05d.m4s" : "seg_%05d.ts");
|
|
|
|
args.Add("-y");
|
|
args.Add("index.m3u8");
|
|
#endregion
|
|
|
|
return process;
|
|
}
|
|
|
|
private async Task PumpStdErrAsync(TranscodingJob job)
|
|
{
|
|
try
|
|
{
|
|
string line;
|
|
while ((line = await job.Process.StandardError.ReadLineAsync()) != null)
|
|
job.AppendLog(line);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_9kis6e5h");
|
|
}
|
|
}
|
|
|
|
private async Task IdleWatchdogAsync(TranscodingJob job, ModuleConf config)
|
|
{
|
|
var idle = TimeSpan.FromSeconds(Math.Max(180, config.idleTimeoutSec));
|
|
var idle_live = TimeSpan.FromSeconds(Math.Max(20, config.idleTimeoutSec_live));
|
|
|
|
try
|
|
{
|
|
while (!job.CancellationToken.IsCancellationRequested && !job.Process.HasExited)
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(1), job.CancellationToken);
|
|
|
|
if (job.Context.live)
|
|
{
|
|
if (config.idleTimeoutSec_live == -1)
|
|
continue;
|
|
|
|
if (DateTime.UtcNow - job.LastAccessUtc > idle_live)
|
|
{
|
|
await StopJobAsync(job);
|
|
break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
|
|
if (config.idleTimeoutSec == -1)
|
|
continue;
|
|
|
|
if (DateTime.UtcNow - job.LastAccessUtc > idle)
|
|
{
|
|
await StopJobAsync(job);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
}
|
|
}
|
|
|
|
private void OnProcessExit(TranscodingJob job)
|
|
{
|
|
if (job.Context.live)
|
|
{
|
|
job.SignalExit();
|
|
Cleanup(job);
|
|
}
|
|
}
|
|
|
|
private bool TryParseToken(string streamId, out string id)
|
|
{
|
|
id = string.Empty;
|
|
var parts = streamId.Split('.', 2);
|
|
if (parts.Length != 2)
|
|
return false;
|
|
|
|
id = parts[0];
|
|
if (id.Length != 32)
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var padded = parts[1].PadRight(parts[1].Length + (4 - parts[1].Length % 4) % 4, '=');
|
|
var hmac = Convert.FromBase64String(padded.Replace('-', '+').Replace('_', '/'));
|
|
var expected = ComputeHmac(id);
|
|
if (!CryptographicOperations.FixedTimeEquals(hmac, expected))
|
|
return false;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private string BuildToken(string id)
|
|
{
|
|
var mac = ComputeHmac(id);
|
|
var b64 = Convert.ToBase64String(mac).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
return $"{id}.{b64}";
|
|
}
|
|
|
|
private byte[] ComputeHmac(string id)
|
|
{
|
|
using var hmac = new HMACSHA256(_hmacKey);
|
|
return hmac.ComputeHash(Encoding.UTF8.GetBytes(id));
|
|
}
|
|
|
|
private static string SanitizeHeader(string value, string fallback = "")
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return fallback;
|
|
|
|
var clean = value.Replace("\r", string.Empty).Replace("\n", string.Empty);
|
|
return string.IsNullOrWhiteSpace(clean) ? fallback : clean;
|
|
}
|
|
|
|
private void CleanupSegments()
|
|
{
|
|
if (Interlocked.Exchange(ref _segmentCleanupRunning, 1) == 1)
|
|
return;
|
|
|
|
try
|
|
{
|
|
if (!(ModInit.conf?.playlistOptions?.delete_segments ?? false))
|
|
return;
|
|
|
|
foreach (var job in _jobs.Values)
|
|
{
|
|
if (job.Context.live)
|
|
continue;
|
|
|
|
var lastIndex = job.LastSegmentIndex;
|
|
if (0 >= lastIndex)
|
|
continue;
|
|
|
|
try
|
|
{
|
|
if (!Directory.Exists(job.OutputDirectory))
|
|
continue;
|
|
|
|
foreach (var file in Directory.GetFiles(job.OutputDirectory, "seg_*"))
|
|
{
|
|
try
|
|
{
|
|
var name = Path.GetFileName(file);
|
|
var match = _segmentFileRegex.Match(name);
|
|
if (!match.Success)
|
|
continue;
|
|
|
|
if (!int.TryParse(match.Groups[1].Value, out var index))
|
|
continue;
|
|
|
|
if (index >= lastIndex - 1)
|
|
continue;
|
|
|
|
// первые 10 сегментов оставляем
|
|
if (10 >= index)
|
|
continue;
|
|
|
|
File.Delete(file);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_nwuuqesw");
|
|
}
|
|
}
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_x7fqvn06");
|
|
}
|
|
}
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
Log.Error(ex, "CatchId={CatchId}", "id_zr6i3b00");
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _segmentCleanupRunning, 0);
|
|
}
|
|
}
|
|
}
|