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); } } }