feat(server): elegantly force transcoding for black bar removal

- Intercept HLS playlist queries to append `allowVideoStreamCopy=false`
- Append `transcodeReasons=VideoResolutionNotSupported` for accurate dashboard reporting
- Modify ffmpeg-wrapper.sh to cleanly inject `-vf crop=...` during active transcodes
- Strip `file:` prefix from inputs in the wrapper to ensure correct API lookups
- Remove hacky URL manipulations (e.g., dummy codecs) that caused FFmpeg crashes
This commit is contained in:
2026-09-17 17:41:59 +02:00
parent 5f8674fae4
commit 795f42048f
3 changed files with 287 additions and 10 deletions
@@ -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<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;
}
// 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);
}
}
}
+4 -1
View File
@@ -8,8 +8,11 @@ namespace JellyfinPlugin
{ {
public void RegisterServices(IServiceCollection serviceCollection, MediaBrowser.Controller.IServerApplicationHost applicationHost) public void RegisterServices(IServiceCollection serviceCollection, MediaBrowser.Controller.IServerApplicationHost applicationHost)
{ {
// Rejestracja serwisów tła // Zarejestrowanie filtru wstrzykującego nasz skrypt kliencki
serviceCollection.AddSingleton<Microsoft.AspNetCore.Hosting.IStartupFilter, ScriptInjectionStartupFilter>(); serviceCollection.AddSingleton<Microsoft.AspNetCore.Hosting.IStartupFilter, ScriptInjectionStartupFilter>();
// Zarejestrowanie filtru wymuszającego transkodowanie dla Server Mode
serviceCollection.AddSingleton<Microsoft.AspNetCore.Hosting.IStartupFilter, PlaybackInfoInterceptorStartupFilter>();
serviceCollection.AddHostedService<WrapperGenerator>(); serviceCollection.AddHostedService<WrapperGenerator>();
serviceCollection.AddHostedService<LibraryMonitor>(); serviceCollection.AddHostedService<LibraryMonitor>();
+59 -9
View File
@@ -77,29 +77,79 @@ namespace JellyfinPlugin
} }
string script = $@"#!/bin/bash string script = $@"#!/bin/bash
# Zmienna przechowująca ścieżkę do prawdziwego FFmpeg Jellyfina
REAL_FFMPEG=""{realFfmpegPath}"" REAL_FFMPEG=""{realFfmpegPath}""
# Znajdujemy ścieżkę pliku wejściowego (argument po -i)
INPUT_FILE="""" INPUT_FILE=""""
for i in ""${{!@}}""; do for (( i=1; i<=$#; i++ )); do
if [[ ""${{@:$i:1}}"" == ""-i"" ]]; then arg=""${{!i}}""
INPUT_FILE=""${{@:$i+1:1}}"" if [[ ""$arg"" == ""-i"" ]]; then
let next_idx=i+1
if [ $next_idx -le $# ]; then
INPUT_FILE=""${{!next_idx}}""
fi
break break
fi fi
done done
# Pobieramy crop z API naszej wtyczki
if [ -n ""$INPUT_FILE"" ]; then 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 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 exit 0
fi fi
fi fi
# Zwykłe uruchomienie bez crop
exec ""$REAL_FFMPEG"" ""$@"" exec ""$REAL_FFMPEG"" ""$@""
"; ";
File.WriteAllText(wrapperPath, script); File.WriteAllText(wrapperPath, script);