From bb7af508792b5e20add60441df16d6a87aace64c Mon Sep 17 00:00:00 2001 From: Karol Labus Date: Thu, 17 Sep 2026 14:13:05 +0200 Subject: [PATCH] Initial commit: Jellyfin Black Bar Remover MVP --- .gitignore | 21 +++ JellyfinPlugin/Api/BlackBarService.cs | 122 ++++++++++++ .../Configuration/PluginConfiguration.cs | 16 ++ JellyfinPlugin/Configuration/configPage.html | 81 ++++++++ JellyfinPlugin/HtmlPatcher.cs | 93 ++++++++++ JellyfinPlugin/JellyfinPlugin.csproj | 26 +++ JellyfinPlugin/LibraryMonitor.cs | 174 ++++++++++++++++++ JellyfinPlugin/Plugin.cs | 35 ++++ JellyfinPlugin/WrapperGenerator.cs | 101 ++++++++++ README.md | 56 ++++++ detect_crop.py | 79 ++++++++ transcode_crop.py | 57 ++++++ 12 files changed, 861 insertions(+) create mode 100644 .gitignore create mode 100644 JellyfinPlugin/Api/BlackBarService.cs create mode 100644 JellyfinPlugin/Configuration/PluginConfiguration.cs create mode 100644 JellyfinPlugin/Configuration/configPage.html create mode 100644 JellyfinPlugin/HtmlPatcher.cs create mode 100644 JellyfinPlugin/JellyfinPlugin.csproj create mode 100644 JellyfinPlugin/LibraryMonitor.cs create mode 100644 JellyfinPlugin/Plugin.cs create mode 100644 JellyfinPlugin/WrapperGenerator.cs create mode 100644 README.md create mode 100644 detect_crop.py create mode 100644 transcode_crop.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46611c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# .NET SDK +dotnet/ + +# .NET Core +bin/ +obj/ + +# Test files +testFiles/ +*.mp4 +*.mkv +test_output* + +# MacOS +.DS_Store + +# IDEs +.idea/ +.vscode/ +*.user +*.suo diff --git a/JellyfinPlugin/Api/BlackBarService.cs b/JellyfinPlugin/Api/BlackBarService.cs new file mode 100644 index 0000000..8ce857a --- /dev/null +++ b/JellyfinPlugin/Api/BlackBarService.cs @@ -0,0 +1,122 @@ +using System; +using System.Linq; +using System.Net.Mime; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JellyfinPlugin.Api +{ + [ApiController] + [Route("BlackBarRemover")] + public class BlackBarController : ControllerBase + { + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + + public BlackBarController(ILibraryManager libraryManager, ILogger logger) + { + _libraryManager = libraryManager; + _logger = logger; + } + + [HttpGet("CropByPath")] + public ActionResult GetCropByPath([FromQuery] string path) + { + if (string.IsNullOrEmpty(path)) return Ok(string.Empty); + + var mode = Plugin.Instance!.Configuration.PlaybackMode; + if (mode == "Client") return Ok(string.Empty); + + var item = _libraryManager.RootFolder.GetRecursiveChildren().FirstOrDefault(x => x.Path == path); + + if (item != null && item.ProviderIds != null && item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData)) + { + return Ok(cropData ?? string.Empty); + } + + return Ok(string.Empty); + } + + [HttpGet("CropById")] + public ActionResult GetCropById([FromQuery] string itemId) + { + if (string.IsNullOrEmpty(itemId)) return Ok(string.Empty); + + var mode = Plugin.Instance!.Configuration.PlaybackMode; + if (mode == "Server") return Ok(string.Empty); + + if (Guid.TryParse(itemId, out Guid id)) + { + var item = _libraryManager.GetItemById(id); + if (item != null && item.ProviderIds != null && item.ProviderIds.TryGetValue("BlackBarCrop", out string? cropData)) + { + return Ok(cropData ?? string.Empty); + } + } + + return Ok(string.Empty); + } + + [HttpGet("ClientScript.js")] + [Produces("application/javascript")] + public ContentResult GetClientScript() + { + string js = @" +(function() { + console.log('Black Bar Remover: Script loaded'); + let currentVideoElement = null; + + function applyCrop(cropData) { + if (!currentVideoElement) return; + const parts = cropData.split(':'); + if (parts.length === 4) { + const w = parseInt(parts[0]); + const h = parseInt(parts[1]); + const x = parseInt(parts[2]); + const y = parseInt(parts[3]); + + const topPercent = (y / 1080) * 100; + const bottomPercent = ((1080 - (y + h)) / 1080) * 100; + const leftPercent = (x / 1920) * 100; + const rightPercent = ((1920 - (x + w)) / 1920) * 100; + + currentVideoElement.style.clipPath = `inset(${topPercent}% ${rightPercent}% ${bottomPercent}% ${leftPercent}%)`; + + const scaleY = 1 / (1 - (topPercent + bottomPercent)/100); + const scaleX = 1 / (1 - (leftPercent + rightPercent)/100); + const scale = Math.max(scaleY, scaleX); + + if (scale > 1.01) { + currentVideoElement.style.transform = `scale(${scale})`; + } + console.log('Black Bar Remover: Applied crop', cropData, 'scale', scale); + } + } + + function checkPlayback() { + const video = document.querySelector('video'); + if (video && video !== currentVideoElement) { + currentVideoElement = video; + const params = new URLSearchParams(window.location.hash.split('?')[1]); + const itemId = params.get('id') || params.get('itemId'); + + if (itemId) { + fetch('/BlackBarRemover/CropById?itemId=' + itemId) + .then(r => r.text()) + .then(crop => { + if (crop) applyCrop(crop); + }); + } + } else if (!video && currentVideoElement) { + currentVideoElement = null; + } + } + + setInterval(checkPlayback, 2000); +})(); +"; + return Content(js, "application/javascript"); + } + } +} diff --git a/JellyfinPlugin/Configuration/PluginConfiguration.cs b/JellyfinPlugin/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..f24011e --- /dev/null +++ b/JellyfinPlugin/Configuration/PluginConfiguration.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Model.Plugins; + +namespace JellyfinPlugin.Configuration +{ + public class PluginConfiguration : BasePluginConfiguration + { + // Tolerancja detekcji czarnych pasów (domyślnie 0.15 czyli 15%) + public double CropDetectLimit { get; set; } = 0.15; + + // Czy wtyczka jest aktywna + public bool IsEnabled { get; set; } = true; + + // Tryb odtwarzania: "Server" (FFmpeg wrapper), "Client" (CSS injection w Web UI), "Both" (Obydwa naraz) + public string PlaybackMode { get; set; } = "Both"; + } +} diff --git a/JellyfinPlugin/Configuration/configPage.html b/JellyfinPlugin/Configuration/configPage.html new file mode 100644 index 0000000..fae89d8 --- /dev/null +++ b/JellyfinPlugin/Configuration/configPage.html @@ -0,0 +1,81 @@ + + + + Black Bar Remover + + +
+
+
+
+ +
+ +
Włącza automatyczne skanowanie nowych i zaktualizowanych materiałów wideo w bibliotece.
+
+ +
+ + +
Domyślnie 0.15. Zwiększ, jeśli czarne pasy nie są wykrywane ze względu na szum wideo (np. dla plików WEBDL zmień na 0.2).
+
+ +
+ + +
Wybierz, w jaki sposób wtyczka ma usuwać pasy. Opcja serwerowa wymusza transkodowanie (obciążenie CPU/GPU), opcja kliencka jest lżejsza, ale działa tylko w przeglądarce.
+
+ +
+ +
+
+
+
+ + +
+ + diff --git a/JellyfinPlugin/HtmlPatcher.cs b/JellyfinPlugin/HtmlPatcher.cs new file mode 100644 index 0000000..96c375c --- /dev/null +++ b/JellyfinPlugin/HtmlPatcher.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.Logging; + +using Microsoft.Extensions.Hosting; + +namespace JellyfinPlugin +{ + public class HtmlPatcher : IHostedService + { + private readonly IApplicationPaths _appPaths; + private readonly ILogger _logger; + + public HtmlPatcher(IApplicationPaths appPaths, ILogger logger) + { + _appPaths = appPaths; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + PatchHtml(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private void PatchHtml() + { + try + { + // Znajdujemy ścieżkę do web UI Jellyfina. + // W większości instalacji linuksowych (np. Debian/Ubuntu) to: + string[] possiblePaths = { + Path.Combine(_appPaths.ProgramDataPath, "web"), + "/usr/share/jellyfin/web", + "/jellyfin/jellyfin-web", // Docker + }; + + string webPath = string.Empty; + foreach (var path in possiblePaths) + { + if (Directory.Exists(path) && File.Exists(Path.Combine(path, "index.html"))) + { + webPath = path; + break; + } + } + + if (string.IsNullOrEmpty(webPath)) + { + _logger.LogWarning("Nie udało się znaleźć katalogu Jellyfin Web. Ręczna instalacja skryptu klienckiego będzie wymagana."); + return; + } + + string indexPath = Path.Combine(webPath, "index.html"); + string indexContent = File.ReadAllText(indexPath); + + string scriptTag = ""; + + if (!indexContent.Contains("BlackBarRemover/ClientScript.js")) + { + // Wstrzykujemy skrypt tuż przed końcem taga + indexContent = indexContent.Replace("", $" {scriptTag}\n"); + File.WriteAllText(indexPath, indexContent); + _logger.LogInformation("Pomyślnie wstrzyknięto skrypt BlackBarRemover do index.html w {Path}", indexPath); + } + else + { + _logger.LogInformation("Skrypt kliencki BlackBarRemover jest już wstrzyknięty w index.html"); + } + } + catch (UnauthorizedAccessException) + { + _logger.LogWarning("Brak uprawnień do modyfikacji index.html. Zmodyfikuj uprawnienia plików Jellyfin Web lub dodaj skrypt ręcznie w Custom CSS."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Wystąpił błąd podczas wstrzykiwania skryptu do index.html."); + } + } + + public void Dispose() + { + } + } +} diff --git a/JellyfinPlugin/JellyfinPlugin.csproj b/JellyfinPlugin/JellyfinPlugin.csproj new file mode 100644 index 0000000..bbed41e --- /dev/null +++ b/JellyfinPlugin/JellyfinPlugin.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + diff --git a/JellyfinPlugin/LibraryMonitor.cs b/JellyfinPlugin/LibraryMonitor.cs new file mode 100644 index 0000000..f5f4a04 --- /dev/null +++ b/JellyfinPlugin/LibraryMonitor.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.Logging; + +using Microsoft.Extensions.Hosting; + +namespace JellyfinPlugin +{ + public class LibraryMonitor : IHostedService, IDisposable + { + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(2, 2); + + public LibraryMonitor(ILibraryManager libraryManager, ILogger logger) + { + _libraryManager = libraryManager; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _libraryManager.ItemAdded += OnItemAddedOrUpdated; + _libraryManager.ItemUpdated += OnItemAddedOrUpdated; + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + + private void OnItemAddedOrUpdated(object? sender, ItemChangeEventArgs e) + { + if (e.Item is Video video && (video is Movie || video is Episode)) + { + if (!Plugin.Instance!.Configuration.IsEnabled) return; + + // Fire and forget, don't block library scan + _ = Task.Run(() => ProcessVideoAsync(video)); + } + } + + private async Task ProcessVideoAsync(Video video) + { + try + { + if (video.ProviderIds != null && video.ProviderIds.ContainsKey("BlackBarCrop")) + { + return; + } + + var path = video.Path; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return; + + await _ffmpegSemaphore.WaitAsync(); + try + { + string cropData = await DetectCropAsync(path); + if (!string.IsNullOrEmpty(cropData)) + { + if (video.ProviderIds == null) + { + video.ProviderIds = new System.Collections.Generic.Dictionary(); + } + video.ProviderIds["BlackBarCrop"] = cropData; + // Jellyfin 12.0 signature requires passing item, item, type, cancellationToken + await _libraryManager.UpdateItemAsync(video, video, ItemUpdateType.MetadataEdit, CancellationToken.None); + _logger.LogInformation("Zapisano proporcje crop {CropData} dla pliku {Path}", cropData, path); + } + } + finally + { + _ffmpegSemaphore.Release(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Błąd podczas procesowania wideo: {Path}", video.Path); + } + } + + private async Task DetectCropAsync(string filePath) + { + var config = Plugin.Instance!.Configuration; + double limit = config.CropDetectLimit; + string limitStr = limit.ToString(System.Globalization.CultureInfo.InvariantCulture); + + var startInfo = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = $"-skip_frame nokey -i \"{filePath}\" -vf \"cropdetect=limit={limitStr}:round=16:reset=0\" -f null -", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + string output = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + var regex = new Regex(@"crop=([0-9]+):([0-9]+):([0-9]+):([0-9]+)"); + var matches = regex.Matches(output); + + var crops = new ConcurrentDictionary(); + int totalFrames = 0; + + foreach (Match match in matches) + { + string cropStr = match.Value.Replace("crop=", ""); + crops.AddOrUpdate(cropStr, 1, (_, count) => count + 1); + totalFrames++; + } + + if (totalFrames == 0) return string.Empty; + + var validCrops = crops.Where(kvp => (double)kvp.Value / totalFrames > 0.05).ToList(); + if (!validCrops.Any()) + { + validCrops = crops.OrderByDescending(kvp => kvp.Value).Take(1).ToList(); + } + + int minX = int.MaxValue, minY = int.MaxValue, maxXW = 0, maxYH = 0; + + foreach (var kvp in validCrops) + { + var parts = kvp.Key.Split(':'); + if (parts.Length == 4 && + int.TryParse(parts[0], out int w) && int.TryParse(parts[1], out int h) && + int.TryParse(parts[2], out int x) && int.TryParse(parts[3], out int y)) + { + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxXW = Math.Max(maxXW, x + w); + maxYH = Math.Max(maxYH, y + h); + } + } + + int finalW = maxXW - minX; + int finalH = maxYH - minY; + + if (finalW <= 0 || finalH <= 0) return string.Empty; + + // Sprawdzanie czy faktycznie coś wycięto w pionie lub poziomie. + // Zazwyczaj filmy 1080p mają h=1080. + // Możemy zwrócić pusty string, jeśli ucięcie jest minimalne (np. mniej niż 10 px z każdej strony). + // To zapobiega wymuszaniu transkodowania z byle powodu. + + return $"{finalW}:{finalH}:{minX}:{minY}"; + } + + public void Dispose() + { + _libraryManager.ItemAdded -= OnItemAddedOrUpdated; + _libraryManager.ItemUpdated -= OnItemAddedOrUpdated; + _ffmpegSemaphore.Dispose(); + } + } +} diff --git a/JellyfinPlugin/Plugin.cs b/JellyfinPlugin/Plugin.cs new file mode 100644 index 0000000..1eba452 --- /dev/null +++ b/JellyfinPlugin/Plugin.cs @@ -0,0 +1,35 @@ +using System; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; +using JellyfinPlugin.Configuration; + +namespace JellyfinPlugin +{ + public class Plugin : BasePlugin, IHasWebPages + { + public override string Name => "Black Bar Remover"; + public override Guid Id => Guid.Parse("12345678-1234-1234-1234-1234567890ab"); + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + public static Plugin? Instance { get; private set; } + + public IEnumerable GetPages() + { + return new[] + { + new PluginPageInfo + { + Name = this.Name, + EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace) + } + }; + } + } +} diff --git a/JellyfinPlugin/WrapperGenerator.cs b/JellyfinPlugin/WrapperGenerator.cs new file mode 100644 index 0000000..ab64a07 --- /dev/null +++ b/JellyfinPlugin/WrapperGenerator.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.Logging; +using MediaBrowser.Common.Configuration; + +using Microsoft.Extensions.Hosting; + +namespace JellyfinPlugin +{ + public class WrapperGenerator : IHostedService + { + private readonly ILogger _logger; + private readonly IApplicationPaths _appPaths; + + public WrapperGenerator(ILogger logger, IApplicationPaths appPaths) + { + _logger = logger; + _appPaths = appPaths; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + GenerateWrapper(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private void GenerateWrapper() + { + try + { + string wrapperDir = Path.Combine(_appPaths.PluginsPath, "BlackBarRemover"); + Directory.CreateDirectory(wrapperDir); + string wrapperPath = Path.Combine(wrapperDir, "ffmpeg-wrapper.sh"); + + string script = @"#!/bin/bash +# Zmienna przechowująca ścieżkę do prawdziwego FFmpeg Jellyfina (zmień jeśli trzeba) +REAL_FFMPEG=""/usr/lib/jellyfin-ffmpeg/ffmpeg"" + +# 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}"" + break + fi +done + +# Pobieramy crop z API naszej wtyczki +if [ -n ""$INPUT_FILE"" ]; then + # Jellyfin lokalnie słucha na 8096, odpytujemy API o crop + CROP_DATA=$(curl -s --max-time 1 -G --data-urlencode ""Path=$INPUT_FILE"" http://127.0.0.1:8096/BlackBarRemover/CropByPath) + + if [ -n ""$CROP_DATA"" ] && [ ""$CROP_DATA"" != """" ]; then + # Znaleziono crop! Wstrzykujemy filtr crop=W:H:X:Y do argumentów + # Dodajemy -vf crop przed argumentami wyjścia + NEW_ARGS=() + for arg in ""$@""; do + # Prosta iniekcja, w realnym środowisku szukamy gdzie wkleić -vf, zazwyczaj przed plikiem wyjściowym. + # Jellyfin sprzętowy tworzy filtry z -filter_complex, w softwareowym z -vf. + NEW_ARGS+=(""$arg"") + done + + # Uruchamiamy ffmpeg z crop + # UWAGA: to jest uproszczenie. Prawdziwy wrapper IntroSkipper musi łączyć filtry. + # W tym MVP dodajemy prosty filter: + exec ""$REAL_FFMPEG"" ""$@"" -vf ""crop=$CROP_DATA"" + exit 0 + fi +fi + +# Zwykłe uruchomienie bez crop +exec ""$REAL_FFMPEG"" ""$@"" +"; + File.WriteAllText(wrapperPath, script); + + // Nadajemy uprawnienia do wykonywania w systemach Linux (używając shella) + if (Environment.OSVersion.Platform == PlatformID.Unix) + { + System.Diagnostics.Process.Start("chmod", $"+x \"{wrapperPath}\""); + } + + _logger.LogInformation("Wygenerowano skrypt FFmpeg Wrapper w lokalizacji: {Path}", wrapperPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Błąd podczas generowania skryptu FFmpeg wrapper."); + } + } + + public void Dispose() + { + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..29f58b6 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Jellyfin Black Bar Remover Plugin + +A Jellyfin plugin that automatically detects and removes black bars (letterboxing/pillarboxing) from video files. + +## Features + +- **Automated Scanning**: Hooks into the Jellyfin library scan (`ItemAdded` / `ItemUpdated`) to analyze videos in the background using FFmpeg keyframe skipping. +- **Robust Detection**: Filters out brief format changes (like studio logos or dark transitions) to find the true "safe crop" bounding box for the entire video. +- **Native Storage**: Saves detected crop parameters natively inside the Jellyfin SQLite database via `ProviderIds`. +- **Dual Playback Modes**: + - **Client-Side (CSS Clip)**: Automatically injects a JavaScript module into the Jellyfin Web UI. The script reads the crop data and uses CSS `clip-path` and `transform` to crop the video in the browser without any server-side transcoding overhead (Direct Play). + - **Server-Side (FFmpeg Wrapper)**: Generates an `ffmpeg-wrapper.sh` script that can be used to inject the FFmpeg `-vf crop` filter during active transcoding. + +## Requirements + +- Jellyfin Server **12.0** or newer (.NET 10). +- FFmpeg installed on the server. + +## Installation + +### Method 1: Building from Source + +1. Clone this repository. +2. Build the plugin using the .NET 10 SDK: + ```bash + dotnet build JellyfinPlugin/JellyfinPlugin.csproj -c Release + ``` +3. Copy the compiled DLLs from `JellyfinPlugin/bin/Release/net10.0/` into a new folder named `BlackBarRemover` inside your Jellyfin plugins directory (e.g., `/var/lib/jellyfin/plugins/BlackBarRemover/`). +4. Restart the Jellyfin server. + +### Configuration + +1. Go to **Dashboard** -> **Plugins** -> **Black Bar Remover**. +2. Set your **Crop Detect Limit** (Default is 0.15). If your web-dl rips have noisy black bars that are not being cropped, increase this value to 0.20 or 0.25. +3. Select your preferred **Playback Mode**: + - `Client`: Uses CSS injection in the web player. + - `Server`: Requires setting up the FFmpeg wrapper. + - `Both`: Attempts both methods depending on the client. + +### Activating the Server-Side Wrapper (Optional) + +If you selected "Server" or "Both" mode: +1. The plugin automatically generates a wrapper script on startup at `/BlackBarRemover/ffmpeg-wrapper.sh`. +2. In the Jellyfin Dashboard, go to **Playback** -> **FFmpeg path**. +3. Change the path to point to the generated `ffmpeg-wrapper.sh` file. + +### Activating the Client-Side CSS (Automatic) + +The plugin attempts to automatically inject its JavaScript into the Jellyfin Web `index.html` on startup. If it fails due to file permissions, you can manually activate it by adding the following line to **Dashboard** -> **General** -> **Custom CSS**: +```html + +``` + +## How it works + +When a video is scanned, a background process runs `ffmpeg -skip_frame nokey -i -vf cropdetect`. It collects all crops and ignores anomalies (appearing in < 5% of frames). The resulting bounding box is saved to the Jellyfin database under the `BlackBarCrop` provider ID. diff --git a/detect_crop.py b/detect_crop.py new file mode 100644 index 0000000..9e03ae7 --- /dev/null +++ b/detect_crop.py @@ -0,0 +1,79 @@ +import subprocess +import re +import sys +import json +from collections import Counter + +def detect_crop(file_path): + cmd = [ + 'ffmpeg', + '-skip_frame', 'nokey', + '-i', file_path, + '-vf', 'cropdetect=limit=24/255:round=16:reset=0', + '-f', 'null', + '-' + ] + + process = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE, universal_newlines=True) + crop_pattern = re.compile(r'crop=([0-9]+):([0-9]+):([0-9]+):([0-9]+)') + + crops = [] + + for line in process.stderr: + match = crop_pattern.search(line) + if match: + w, h, x, y = map(int, match.groups()) + # ignorujemy calkowicie ciemne klatki (gdzie cropdetect ucina z kazdej strony) + # interesuje nas glownie format wideo (np proporcje) + crops.append((w, h, x, y)) + + process.wait() + + if not crops: + return {"error": "No crop detected"} + + # Policz jak często występuje dany crop + crop_counts = Counter(crops) + total_frames = len(crops) + + # Filtrujemy losowe błędy i czarne plansze (szukamy cropów które występują co najmniej w 5% klatek kluczowych) + valid_crops = [c for c, count in crop_counts.items() if count / total_frames > 0.05] + + if not valid_crops: + # Jeśli nic nie przekracza 5%, bierzemy po prostu najczęstszy + valid_crops = [crop_counts.most_common(1)[0][0]] + + # Z valid_crops wybieramy taki "safe crop", który jest obwiednią (bounding box) + # dla wszystkich ISTOTNYCH proporcji w filmie. + # To pozwoli na zachowanie scen IMAX, a odcięcie stałych czarnych pasów. + + min_x = min(c[2] for c in valid_crops) + min_y = min(c[3] for c in valid_crops) + max_xw = max(c[2] + c[0] for c in valid_crops) + max_yh = max(c[3] + c[1] for c in valid_crops) + + safe_crop = { + "w": max_xw - min_x, + "h": max_yh - min_y, + "x": min_x, + "y": min_y + } + + most_common_tuple = crop_counts.most_common(1)[0][0] + most_common = f"{most_common_tuple[0]}:{most_common_tuple[1]}:{most_common_tuple[2]}:{most_common_tuple[3]}" + + return { + "safe_crop": safe_crop, + "most_common": most_common, + "valid_crops_considered": len(valid_crops), + "raw_crops_count": total_frames + } + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Usage: python detect_crop.py ") + sys.exit(1) + + file_path = sys.argv[1] + result = detect_crop(file_path) + print(json.dumps(result, indent=2)) diff --git a/transcode_crop.py b/transcode_crop.py new file mode 100644 index 0000000..4efe4bf --- /dev/null +++ b/transcode_crop.py @@ -0,0 +1,57 @@ +import subprocess +import sys +import os + +def transcode_video(input_file, output_file, crop_param, hw_accel=False): + """ + Transcodes the video and applies cropping. + crop_param: string in the format "W:H:X:Y" + hw_accel: boolean, if true we use hardware acceleration (NVENC as an example) + """ + cmd = ['ffmpeg', '-y', '-i', input_file] + + # We apply the crop filter + vf_param = f'crop={crop_param}' + + if hw_accel: + # Example using NVIDIA NVENC. + # In a real Jellyfin environment, this would be constructed based on Jellyfin's profile. + cmd.extend([ + '-c:v', 'h264_nvenc', + '-preset', 'fast', + '-vf', vf_param, + '-c:a', 'copy' # Keep audio as is + ]) + else: + # Software encoding fallback + cmd.extend([ + '-c:v', 'libx264', + '-preset', 'fast', + '-crf', '23', + '-vf', vf_param, + '-c:a', 'copy' + ]) + + cmd.append(output_file) + + print(f"Running transcoding command: {' '.join(cmd)}") + + # In a real scenario, this would stream to Jellyfin's output or write to the required segment files. + process = subprocess.Popen(cmd) + process.wait() + + if process.returncode == 0: + print("Transcoding completed successfully.") + else: + print(f"Transcoding failed with code {process.returncode}") + +if __name__ == '__main__': + if len(sys.argv) < 4: + print("Usage: python transcode_crop.py ") + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] + crop_param = sys.argv[3] + + transcode_video(input_file, output_file, crop_param, hw_accel=False)