diff --git a/JellyfinPlugin/PlaybackInfoInterceptorStartupFilter.cs b/JellyfinPlugin/PlaybackInfoInterceptorStartupFilter.cs new file mode 100644 index 0000000..96fe384 --- /dev/null +++ b/JellyfinPlugin/PlaybackInfoInterceptorStartupFilter.cs @@ -0,0 +1,224 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace JellyfinPlugin +{ + public class PlaybackInfoInterceptorStartupFilter : IStartupFilter + { + private readonly ILogger _logger; + private readonly MediaBrowser.Controller.Library.ILibraryManager _libraryManager; + + public PlaybackInfoInterceptorStartupFilter(ILogger logger, MediaBrowser.Controller.Library.ILibraryManager libraryManager) + { + _logger = logger; + _libraryManager = libraryManager; + } + + public Action Configure(Action next) + { + return app => + { + app.Use(InvokeAsync); + next(app); + }; + } + + private string? GetCropFromId(string itemId) + { + if (Guid.TryParse(itemId, out Guid id)) + { + var item = _libraryManager.GetItemById(id); + if (item != null && item.ProviderIds != null) + { + if (item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData)) + { + return cropData; + } + } + } + return null; + } + + private async Task InvokeAsync(HttpContext context, Func nextMw) + { + var path = context.Request.Path.Value ?? string.Empty; + + var config = Plugin.Instance?.Configuration; + if (config == null || !config.IsEnabled || config.PlaybackMode == "Client") + { + await nextMw().ConfigureAwait(false); + return; + } + + // --- 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)) + { + var matchId = Regex.Match(path, @"/Videos/([^/]+)/", RegexOptions.IgnoreCase); + if (matchId.Success) + { + var idStr = matchId.Groups[1].Value; + var crop = GetCropFromId(idStr); + if (crop != null) + { + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(context.Request.QueryString.Value); + var items = query.SelectMany(x => x.Value, (col, val) => new System.Collections.Generic.KeyValuePair(col.Key, val)).ToList(); + + var newQueryString = "?"; + foreach (var kvp in items) + { + if (!kvp.Key.Equals("allowVideoStreamCopy", StringComparison.OrdinalIgnoreCase) && + !kvp.Key.Equals("transcodeReasons", StringComparison.OrdinalIgnoreCase)) + { + newQueryString += $"{kvp.Key}={System.Net.WebUtility.UrlEncode(kvp.Value)}&"; + } + } + // Force disable stream copy AND provide a valid reason for the dashboard + newQueryString += "allowVideoStreamCopy=false&transcodeReasons=VideoResolutionNotSupported"; + + context.Request.QueryString = new QueryString(newQueryString); + _logger.LogInformation($"[Black Bar Remover DEBUG] Intercepted HLS request for {idStr}. Appended allowVideoStreamCopy=false and transcodeReasons=VideoResolutionNotSupported."); + } + } + } + + var match = Regex.Match(path, @"/Items/([^/]+)/PlaybackInfo", RegexOptions.IgnoreCase); + + if (!match.Success) + { + await nextMw().ConfigureAwait(false); + return; + } + + var itemId = match.Groups[1].Value; + var cropData = GetCropFromId(itemId); + + if (cropData == null) + { + _logger.LogInformation($"[Black Bar Remover DEBUG] PlaybackInfo request for {itemId}, but no crop found in DB. Skipping interception."); + await nextMw().ConfigureAwait(false); + return; + } + + _logger.LogInformation($"[Black Bar Remover DEBUG] Intercepting PlaybackInfo for {itemId}. Found crop in DB: {cropData}. Forcing transcode."); + + // 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; + + try + { + await nextMw().ConfigureAwait(false); + } + catch + { + context.Response.Body = originalBody; + throw; + } + + 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) + { + await buffer.CopyToAsync(originalBody).ConfigureAwait(false); + return; + } + + string json; + using (var reader = new StreamReader(buffer, Encoding.UTF8, true, 1024, leaveOpen: true)) + { + json = await reader.ReadToEndAsync().ConfigureAwait(false); + } + + 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; + + 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; + } + + // Dodatkowo obcinamy wsparcie dla Direct Stream w pod-strumieniach (Jellyfin czasami wymusza z poziomu klienta) + var reqKey = obj.FirstOrDefault(x => x.Key.Equals("RequiresOpening", StringComparison.OrdinalIgnoreCase)).Key ?? "RequiresOpening"; + obj[reqKey] = true; + + var reqCloseKey = obj.FirstOrDefault(x => x.Key.Equals("RequiresClosing", StringComparison.OrdinalIgnoreCase)).Key ?? "RequiresClosing"; + obj[reqCloseKey] = true; + + 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); + } + } +} diff --git a/JellyfinPlugin/PluginServiceRegistrator.cs b/JellyfinPlugin/PluginServiceRegistrator.cs index 056cde9..061ad6a 100644 --- a/JellyfinPlugin/PluginServiceRegistrator.cs +++ b/JellyfinPlugin/PluginServiceRegistrator.cs @@ -8,8 +8,11 @@ namespace JellyfinPlugin { public void RegisterServices(IServiceCollection serviceCollection, MediaBrowser.Controller.IServerApplicationHost applicationHost) { - // Rejestracja serwisów tła + // Zarejestrowanie filtru wstrzykującego nasz skrypt kliencki serviceCollection.AddSingleton(); + + // Zarejestrowanie filtru wymuszającego transkodowanie dla Server Mode + serviceCollection.AddSingleton(); serviceCollection.AddHostedService(); serviceCollection.AddHostedService(); diff --git a/JellyfinPlugin/WrapperGenerator.cs b/JellyfinPlugin/WrapperGenerator.cs index 136aa07..1ba0ce5 100644 --- a/JellyfinPlugin/WrapperGenerator.cs +++ b/JellyfinPlugin/WrapperGenerator.cs @@ -77,29 +77,79 @@ namespace JellyfinPlugin } string script = $@"#!/bin/bash -# Zmienna przechowująca ścieżkę do prawdziwego FFmpeg Jellyfina REAL_FFMPEG=""{realFfmpegPath}"" -# Znajdujemy ścieżkę pliku wejściowego (argument po -i) INPUT_FILE="""" -for i in ""${{!@}}""; do - if [[ ""${{@:$i:1}}"" == ""-i"" ]]; then - INPUT_FILE=""${{@:$i+1:1}}"" +for (( i=1; i<=$#; i++ )); do + arg=""${{!i}}"" + if [[ ""$arg"" == ""-i"" ]]; then + let next_idx=i+1 + if [ $next_idx -le $# ]; then + INPUT_FILE=""${{!next_idx}}"" + fi break fi done -# Pobieramy crop z API naszej wtyczki if [ -n ""$INPUT_FILE"" ]; then - CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$INPUT_FILE"" http://127.0.0.1:8096/BlackBarRemover/CropByPath) + # Jellyfin przekazuje ścieżkę z prefiksem file: (np. file:""/home/...""). + # Shell usuwa cudzysłowy automatycznie – zostaje nam tylko file: do usunięcia. + CLEAN_PATH=""${{INPUT_FILE#file:}}"" + + CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$CLEAN_PATH"" http://127.0.0.1:8096/BlackBarRemover/CropByPath) if [ -n ""$CROP_DATA"" ] && [ ""$CROP_DATA"" != """" ]; then - exec ""$REAL_FFMPEG"" ""$@"" -vf ""crop=$CROP_DATA"" + # Modyfikujemy argumenty w locie + declare -a NEW_ARGS + HAS_VF=0 + IS_COPY=0 + + for (( i=1; i<=$#; i++ )); do + arg=""${{!i}}"" + + if [[ ""$arg"" == ""copy"" ]]; then + let prev_idx=i-1 + prev_arg="""" + if [ $prev_idx -gt 0 ]; then + prev_arg=""${{!prev_idx}}"" + fi + if [[ ""$prev_arg"" == *codec:v* ]] || [[ ""$prev_arg"" == *-c:v* ]] || [[ ""$prev_arg"" == *-vcodec* ]]; then + IS_COPY=1 + fi + fi + + if [[ ""$arg"" == ""-vf"" ]]; then + HAS_VF=1 + NEW_ARGS+=(""-vf"") + next_arg="""" + let next_idx=i+1 + if [ $next_idx -le $# ]; then + next_arg=""${{!next_idx}}"" + fi + NEW_ARGS+=(""crop=$CROP_DATA,$next_arg"") + let i++ + continue + fi + + NEW_ARGS+=(""$arg"") + done + + # Jeżeli Jellyfin nadal upiera się przy copy, nie możemy wstrzyknąć filtra crop (bo zcrashuje FFmpega) + if [ $IS_COPY -eq 1 ]; then + exec ""$REAL_FFMPEG"" ""$@"" + exit 0 + fi + + if [ $HAS_VF -eq 0 ]; then + NEW_ARGS+=(""-vf"") + NEW_ARGS+=(""crop=$CROP_DATA"") + fi + + exec ""$REAL_FFMPEG"" ""${{NEW_ARGS[@]}}"" exit 0 fi fi -# Zwykłe uruchomienie bez crop exec ""$REAL_FFMPEG"" ""$@"" "; File.WriteAllText(wrapperPath, script);