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; } var isAutoMode = config.PlaybackMode == "Auto" || config.PlaybackMode == "Both"; if (isAutoMode && IsJellyfinWebClient(context)) { // W trybie Auto dla przeglądarki Web pozwalamy na Direct Play (przycięcie wykona skrypt CSS w przeglądarce). 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.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) { 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 request for {itemId}. Found crop in DB: {cropData}. Disabling DirectPlayProfiles in incoming request."); // 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 { rootNode = string.IsNullOrWhiteSpace(reqJson) ? new JsonObject() : (JsonNode.Parse(reqJson) ?? new JsonObject()); } catch { rootNode = new JsonObject(); } if (rootNode["DeviceProfile"] is not JsonObject devProfile) { devProfile = new JsonObject(); rootNode["DeviceProfile"] = devProfile; } devProfile["DirectPlayProfiles"] = new JsonArray(); var newReqBytes = Encoding.UTF8.GetBytes(rootNode.ToJsonString()); var reqMs = new MemoryStream(newReqBytes); context.Request.Body = reqMs; context.Request.ContentLength = newReqBytes.Length; await nextMw().ConfigureAwait(false); } private static bool IsJellyfinWebClient(HttpContext context) { if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var authHeader) || context.Request.Headers.TryGetValue("Authorization", out authHeader)) { var authStr = authHeader.ToString(); if (authStr.Contains("Client=\"Jellyfin Web\"", StringComparison.OrdinalIgnoreCase) || authStr.Contains("Client=\"Jellyfin%20Web\"", StringComparison.OrdinalIgnoreCase) || authStr.Contains("Client=Jellyfin Web", StringComparison.OrdinalIgnoreCase)) { return true; } } if (context.Request.Headers.TryGetValue("User-Agent", out var uaHeader)) { var ua = uaHeader.ToString(); if (ua.Contains("JellyfinMediaPlayer", StringComparison.OrdinalIgnoreCase) || ua.Contains("Jellyfin-Android", StringComparison.OrdinalIgnoreCase) || ua.Contains("ExoPlayer", StringComparison.OrdinalIgnoreCase) || ua.Contains("okhttp", StringComparison.OrdinalIgnoreCase) || ua.Contains("Kodi", StringComparison.OrdinalIgnoreCase)) { return false; } if (ua.Contains("Mozilla", StringComparison.OrdinalIgnoreCase) || ua.Contains("Chrome", StringComparison.OrdinalIgnoreCase) || ua.Contains("Safari", StringComparison.OrdinalIgnoreCase) || ua.Contains("Firefox", StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } } }