lampac/Tracks/Engine/TranscodingService.cs
lampac-talks f843f04fd4 chore: initial commit 154.3
Signed-off-by: lampac-talks <lampac-talks@users.noreply.github.com>
2026-01-30 16:23:09 +03:00

960 lines
34 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Shared;
using Shared.Engine;
using Shared.Models.AppConf;
using Shared.Models.Events;
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 Tracks.Controllers;
namespace Tracks.Engine
{
internal sealed class 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(TranscodingConf config)
{
if (config == null)
return;
_ffmpegPath = string.IsNullOrWhiteSpace(config.ffmpeg)
? (AppInit.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 { }
}
}
}
catch { }
}
public ICollection<TranscodingJob> Jobs => _jobs.Values;
async public Task<(TranscodingJob job, string error)> Start(TranscodingStartRequest request)
{
var config = GetConfig();
if (!config.enable)
return (null!, "Transcoding disabled");
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
JObject ffprobe = null;
string ffprobeJson = await TracksController.FfprobeJson(null, null, IHybridCache.Get(null), request.src);
if (string.IsNullOrEmpty(ffprobeJson) || ffprobeJson == "{}")
return (null!, "ffprobe");
ffprobe = JsonConvert.DeserializeObject<JObject>(ffprobeJson);
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 (Exception ex)
{
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 { }
process.Dispose();
try
{
if (Directory.Exists(outputDir))
Directory.Delete(outputDir, true);
}
catch { }
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 (Exception ex)
{
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 { }
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 { }
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 { }
try
{
await job.Process.WaitForExitAsync();
}
catch { }
}
else
{
try
{
await waitTask;
}
catch { }
}
}
}
}
finally
{
if (cleanup)
Cleanup(job);
}
}
private void Cleanup(TranscodingJob job)
{
var removed = _jobs.TryRemove(job.Id, out _);
try
{
job.StopBackground();
job.SignalExit();
}
catch { }
if (!removed)
return;
try
{
if (Directory.Exists(job.OutputDirectory))
Directory.Delete(job.OutputDirectory, true);
}
catch { }
job.Dispose();
}
private TranscodingConf GetConfig()
{
return AppInit.conf.transcoding;
}
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(TranscodingConf 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(TranscodingConf 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, TranscodingConf 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()
{
var jobs = _jobs.Values.ToArray();
foreach (var job in jobs)
{
try
{
_ = StopJobAsync(job, forced: true).ConfigureAwait(false);
}
catch { }
}
}
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
InvkEvent.Transcoding(new EventTranscoding(args, startNumber, context));
return process;
}
private async Task PumpStdErrAsync(TranscodingJob job)
{
try
{
while (!job.Process.StandardError.EndOfStream)
{
var line = await job.Process.StandardError.ReadLineAsync();
if (line == null)
break;
job.AppendLog(line);
}
}
catch { }
}
private async Task IdleWatchdogAsync(TranscodingJob job, TranscodingConf 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 (!(AppInit.conf?.transcoding?.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;
File.Delete(file);
}
catch { }
}
}
catch { }
}
}
catch { }
finally
{
Interlocked.Exchange(ref _segmentCleanupRunning, 0);
}
}
}
}