218 lines
9.7 KiB
C#
218 lines
9.7 KiB
C#
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<PlaybackInfoInterceptorStartupFilter> _logger;
|
|
private readonly MediaBrowser.Controller.Library.ILibraryManager _libraryManager;
|
|
|
|
public PlaybackInfoInterceptorStartupFilter(ILogger<PlaybackInfoInterceptorStartupFilter> logger, MediaBrowser.Controller.Library.ILibraryManager libraryManager)
|
|
{
|
|
_logger = logger;
|
|
_libraryManager = libraryManager;
|
|
}
|
|
|
|
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> 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<Task> 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<string, string>(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<bool>() ?? false;
|
|
var ds = dsNode?.GetValue<bool>() ?? 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>();
|
|
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);
|
|
}
|
|
}
|
|
}
|