From 322cfd9acd5a71606aab9214ed217347317c0bee Mon Sep 17 00:00:00 2001 From: Karol Labus Date: Thu, 17 Sep 2026 20:51:54 +0200 Subject: [PATCH] fix: fix server mode crop application, request rewrite, and prevent infinite library scan loop --- JellyfinPlugin/LibraryMonitor.cs | 14 +++ .../PlaybackInfoInterceptorStartupFilter.cs | 110 ++++-------------- JellyfinPlugin/WrapperGenerator.cs | 89 ++++++++------ 3 files changed, 87 insertions(+), 126 deletions(-) diff --git a/JellyfinPlugin/LibraryMonitor.cs b/JellyfinPlugin/LibraryMonitor.cs index 7c8f31c..f0a4a66 100644 --- a/JellyfinPlugin/LibraryMonitor.cs +++ b/JellyfinPlugin/LibraryMonitor.cs @@ -23,6 +23,8 @@ namespace JellyfinPlugin private readonly ILogger _logger; private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(2, 2); + private readonly ConcurrentDictionary _processedItems = new ConcurrentDictionary(); + public LibraryMonitor(ILibraryManager libraryManager, ILogger logger) { _libraryManager = libraryManager; @@ -48,6 +50,16 @@ namespace JellyfinPlugin { if (!Plugin.Instance!.Configuration.IsEnabled) return; + if (_processedItems.ContainsKey(video.Id)) return; + + if (video.ProviderIds != null && + video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) && + !string.IsNullOrEmpty(existingCrop)) + { + _processedItems.TryAdd(video.Id, 0); + return; + } + // Fire and forget, don't block library scan _ = Task.Run(() => ProcessVideoAsync(video)); } @@ -57,6 +69,8 @@ namespace JellyfinPlugin { try { + if (!_processedItems.TryAdd(video.Id, 0)) return; + if (video.ProviderIds != null && video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) && !string.IsNullOrEmpty(existingCrop)) diff --git a/JellyfinPlugin/PlaybackInfoInterceptorStartupFilter.cs b/JellyfinPlugin/PlaybackInfoInterceptorStartupFilter.cs index e4548ab..047b2d8 100644 --- a/JellyfinPlugin/PlaybackInfoInterceptorStartupFilter.cs +++ b/JellyfinPlugin/PlaybackInfoInterceptorStartupFilter.cs @@ -63,8 +63,8 @@ namespace JellyfinPlugin // --- HLS STREAM QUERY REWRITE --- // If the client requests the m3u8 playlist, we simply tell Jellyfin NOT to allow video stream copy. // This elegantly forces transcoding for the BEST supported codec without breaking FFmpeg tags/filters! - if (path.EndsWith("master.m3u8", StringComparison.OrdinalIgnoreCase) || - path.EndsWith("main.m3u8", StringComparison.OrdinalIgnoreCase)) + if (path.Contains("/Videos/", StringComparison.OrdinalIgnoreCase) && + (path.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) || path.Contains("/stream", StringComparison.OrdinalIgnoreCase))) { var matchId = Regex.Match(path, @"/Videos/([^/]+)/", RegexOptions.IgnoreCase); if (matchId.Success) @@ -112,106 +112,40 @@ namespace JellyfinPlugin return; } - _logger.LogInformation($"[Black Bar Remover DEBUG] Intercepting PlaybackInfo for {itemId}. Found crop in DB: {cropData}. Forcing transcode."); + _logger.LogInformation($"[Black Bar Remover DEBUG] Intercepting PlaybackInfo request for {itemId}. Found crop in DB: {cropData}. Disabling DirectPlayProfiles in incoming request."); - // Kestrel middleware do nadpisania odpowiedzi - context.Request.Headers.Remove("Accept-Encoding"); - var originalBody = context.Response.Body; - using var buffer = new MemoryStream(); - context.Response.Body = buffer; + // Modify incoming request body to clear DirectPlayProfiles + context.Request.EnableBuffering(); + string reqJson = string.Empty; + using (var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true)) + { + reqJson = await reader.ReadToEndAsync().ConfigureAwait(false); + } + JsonNode rootNode; try { - await nextMw().ConfigureAwait(false); + rootNode = string.IsNullOrWhiteSpace(reqJson) ? new JsonObject() : (JsonNode.Parse(reqJson) ?? new JsonObject()); } catch { - context.Response.Body = originalBody; - throw; + rootNode = new JsonObject(); } - context.Response.Body = originalBody; - buffer.Seek(0, SeekOrigin.Begin); - - var isJson = context.Response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) ?? false; - - if (!isJson || context.Response.StatusCode != 200) + if (rootNode["DeviceProfile"] is not JsonObject devProfile) { - await buffer.CopyToAsync(originalBody).ConfigureAwait(false); - return; + devProfile = new JsonObject(); + rootNode["DeviceProfile"] = devProfile; } - string json; - using (var reader = new StreamReader(buffer, Encoding.UTF8, true, 1024, leaveOpen: true)) - { - json = await reader.ReadToEndAsync().ConfigureAwait(false); - } + devProfile["DirectPlayProfiles"] = new JsonArray(); - try - { - var root = JsonNode.Parse(json); - var mediaSources = root?["MediaSources"]?.AsArray(); - if (mediaSources != null) - { - bool modified = false; - foreach (var source in mediaSources) - { - if (source == null) continue; - - var obj = source.AsObject(); - - // Pobieramy flagi niezależnie od wielkości liter (Jellyfin 10.9+ może używać camelCase) - var dpNode = obj.FirstOrDefault(x => x.Key.Equals("SupportsDirectPlay", StringComparison.OrdinalIgnoreCase)).Value; - var dsNode = obj.FirstOrDefault(x => x.Key.Equals("SupportsDirectStream", StringComparison.OrdinalIgnoreCase)).Value; - - var dp = dpNode?.GetValue() ?? false; - var ds = dsNode?.GetValue() ?? false; + var newReqBytes = Encoding.UTF8.GetBytes(rootNode.ToJsonString()); + var reqMs = new MemoryStream(newReqBytes); + context.Request.Body = reqMs; + context.Request.ContentLength = newReqBytes.Length; - if (dp || ds) - { - // Szukamy oryginalnych kluczy, żeby ich nie zdublować w innym casing-u - var dpKey = obj.FirstOrDefault(x => x.Key.Equals("SupportsDirectPlay", StringComparison.OrdinalIgnoreCase)).Key ?? "SupportsDirectPlay"; - var dsKey = obj.FirstOrDefault(x => x.Key.Equals("SupportsDirectStream", StringComparison.OrdinalIgnoreCase)).Key ?? "SupportsDirectStream"; - var trKey = obj.FirstOrDefault(x => x.Key.Equals("TranscodingReasons", StringComparison.OrdinalIgnoreCase)).Key ?? "TranscodingReasons"; - - obj[dpKey] = false; - obj[dsKey] = false; - - var transcodingReasons = obj[trKey]?.GetValue(); - string customReason = "VideoCodecNotSupported"; - - if (transcodingReasons == null) - { - obj[trKey] = customReason; - } - else if (!transcodingReasons.Contains(customReason)) - { - obj[trKey] = transcodingReasons + "," + customReason; - } - - modified = true; - _logger.LogInformation($"[Black Bar Remover DEBUG] Modified PlaybackInfo JSON: forced {dpKey}/{dsKey} to false for MediaSource ID {obj.FirstOrDefault(x => x.Key.Equals("Id", StringComparison.OrdinalIgnoreCase)).Value}"); - } - } - - if (modified) - { - json = root!.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); - _logger.LogInformation($"[Black Bar Remover DEBUG] Successfully rewrote PlaybackInfo JSON to enforce transcoding."); - } - } - } - catch (Exception ex) - { - _logger.LogWarning($"[Black Bar Remover DEBUG] Error modifying PlaybackInfo JSON: {ex.Message}"); - } - - var bytes = Encoding.UTF8.GetBytes(json); - context.Response.ContentType = "application/json; charset=utf-8"; - context.Response.ContentLength = bytes.Length; - context.Response.Headers.Remove("ETag"); - context.Response.Headers.Remove("Last-Modified"); - await originalBody.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + await nextMw().ConfigureAwait(false); } } } diff --git a/JellyfinPlugin/WrapperGenerator.cs b/JellyfinPlugin/WrapperGenerator.cs index 95bad56..5d8619f 100644 --- a/JellyfinPlugin/WrapperGenerator.cs +++ b/JellyfinPlugin/WrapperGenerator.cs @@ -44,37 +44,34 @@ namespace JellyfinPlugin return; } - // Pobieramy obecną konfigurację kodowania - var encodingConfig = _configManager.GetConfiguration("encoding"); - if (encodingConfig == null) return; + // Przygotowujemy zamianę pliku ffmpeg bezpośrednio w /usr/lib/jellyfin-ffmpeg/ffmpeg + string defaultFfmpeg = "/usr/lib/jellyfin-ffmpeg/ffmpeg"; + string origFfmpeg = "/usr/lib/jellyfin-ffmpeg/ffmpeg.original"; + string realFfmpegPath = origFfmpeg; - // Używamy dynamic, aby ominąć potrzebę rzutowania na konkretny, wewnętrzny typ Jellyfin (EncodingOptions) - dynamic dynConfig = encodingConfig; - string currentFfmpegPath = dynConfig.EncoderAppPath ?? "/usr/lib/jellyfin-ffmpeg/ffmpeg"; + if (!File.Exists(origFfmpeg)) + { + if (File.Exists(defaultFfmpeg)) + { + try + { + File.Move(defaultFfmpeg, origFfmpeg); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Nie udało się przenieść {DefaultFfmpeg} do {OrigFfmpeg}", defaultFfmpeg, origFfmpeg); + realFfmpegPath = defaultFfmpeg; + } + } + else if (File.Exists("/usr/bin/ffmpeg")) + { + realFfmpegPath = "/usr/bin/ffmpeg"; + } + } string wrapperDir = Path.Combine(_appPaths.PluginsPath, "BlackBarRemover"); Directory.CreateDirectory(wrapperDir); - string wrapperPath = Path.Combine(wrapperDir, "ffmpeg-wrapper.sh"); - - // Jeśli obecna ścieżka to już nasz wrapper, nie robimy pętli w nieskończoność. - // Znajdujemy poprzednio zapisaną prawdziwą ścieżkę z pliku. - string realFfmpegPath = currentFfmpegPath; - if (currentFfmpegPath == wrapperPath) - { - if (File.Exists(wrapperPath)) - { - var lines = File.ReadAllLines(wrapperPath); - foreach (var line in lines) - { - if (line.StartsWith("REAL_FFMPEG=")) - { - realFfmpegPath = line.Split('=')[1].Trim('"'); - break; - } - } - } - if (realFfmpegPath == wrapperPath) realFfmpegPath = "/usr/lib/jellyfin-ffmpeg/ffmpeg"; - } + string wrapperPath = Path.Combine(wrapperDir, "ffmpeg"); string script = $@"#!/bin/bash REAL_FFMPEG=""{realFfmpegPath}"" @@ -124,7 +121,7 @@ if [ -n ""$INPUT_FILE"" ]; then if [ $next_idx -le $# ]; then next_arg=""${{!next_idx}}"" fi - NEW_ARGS+=(""crop=$CROP_DATA,$next_arg"") + NEW_ARGS+=(""$next_arg,crop=$CROP_DATA"") let i++ continue fi @@ -151,21 +148,37 @@ fi exec ""$REAL_FFMPEG"" ""$@"" "; File.WriteAllText(wrapperPath, script); - - if (Environment.OSVersion.Platform == PlatformID.Unix) + if (File.Exists(origFfmpeg) || defaultFfmpeg != origFfmpeg) { - System.Diagnostics.Process.Start("chmod", $"+x \"{wrapperPath}\""); + try + { + File.WriteAllText(defaultFfmpeg, script); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Nie udało się zapisać skryptu wrappera do {DefaultFfmpeg}", defaultFfmpeg); + } } - _logger.LogInformation("Wygenerowano skrypt FFmpeg Wrapper w lokalizacji: {Path}", wrapperPath); - - // Teraz automagicznie podmieniamy ścieżkę w Jellyfin! - if (dynConfig.EncoderAppPath != wrapperPath) + if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD() || OperatingSystem.IsMacOS()) { - dynConfig.EncoderAppPath = wrapperPath; - _configManager.SaveConfiguration("encoding", encodingConfig); - _logger.LogInformation("Automatycznie zmieniono ścieżkę FFmpeg w konfiguracji Jellyfin na wrapper wtyczki."); + string[] pathsToChmod = new[] { wrapperPath, defaultFfmpeg }; + foreach (var path in pathsToChmod) + { + if (!File.Exists(path)) continue; + try + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + catch + { + var proc = System.Diagnostics.Process.Start("chmod", $"+x \"{path}\""); + proc?.WaitForExit(); + } + } } + + _logger.LogInformation("Wygenerowano skrypt FFmpeg Wrapper w lokalizacji: {Path} oraz {DefaultFfmpeg}", wrapperPath, defaultFfmpeg); } catch (Exception ex) {