Initial commit: Jellyfin Black Bar Remover MVP

This commit is contained in:
2026-09-17 14:13:05 +02:00
commit bb7af50879
12 changed files with 861 additions and 0 deletions
+122
View File
@@ -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<BlackBarController> _logger;
public BlackBarController(ILibraryManager libraryManager, ILogger<BlackBarController> logger)
{
_libraryManager = libraryManager;
_logger = logger;
}
[HttpGet("CropByPath")]
public ActionResult<string> 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<string> 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");
}
}
}
@@ -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";
}
}
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head>
<title>Black Bar Remover</title>
</head>
<body>
<div data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form class="blackBarRemoverConfigForm">
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkEnablePlugin" />
<span>Włącz wykrywanie czarnych pasów</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Włącza automatyczne skanowanie nowych i zaktualizowanych materiałów wideo w bibliotece.</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="txtCropLimit">Tolerancja detekcji czarnych pasów (0.01 - 1.0):</label>
<input type="number" step="0.01" min="0.01" max="1.0" id="txtCropLimit" is="emby-input" required />
<div class="fieldDescription">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).</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="selectPlaybackMode">Sposób usuwania pasów w odtwarzaczu:</label>
<select is="emby-select" id="selectPlaybackMode" class="emby-select-withcolor">
<option value="Server">Tylko Serwer (Transkodowanie FFmpeg)</option>
<option value="Client">Tylko Klient (CSS Clip w Jellyfin Web)</option>
<option value="Both">Oba (Transkodowanie lub CSS, zależy od klienta)</option>
</select>
<div class="fieldDescription">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.</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Zapisz</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var BlackBarRemoverConfig = {
pluginUniqueId: "12345678-1234-1234-1234-1234567890ab"
};
$('.blackBarRemoverConfigForm').on('pageinit', function (event) {
var page = event.target;
page.addEventListener('viewshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
$('#chkEnablePlugin', page).prop('checked', config.IsEnabled || false);
$('#txtCropLimit', page).val(config.CropDetectLimit || 0.15);
$('#selectPlaybackMode', page).val(config.PlaybackMode || "Both");
Dashboard.hideLoadingMsg();
});
});
$('.blackBarRemoverConfigForm').on('submit', function (e) {
e.preventDefault();
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(BlackBarRemoverConfig.pluginUniqueId).then(function (config) {
config.IsEnabled = $('#chkEnablePlugin', page).prop('checked');
config.CropDetectLimit = parseFloat($('#txtCropLimit', page).val());
config.PlaybackMode = $('#selectPlaybackMode', page).val();
ApiClient.updatePluginConfiguration(BlackBarRemoverConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
});
});
</script>
</div>
</body>
</html>
+93
View File
@@ -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<HtmlPatcher> _logger;
public HtmlPatcher(IApplicationPaths appPaths, ILogger<HtmlPatcher> 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 = "<script src=\"/BlackBarRemover/ClientScript.js\"></script>";
if (!indexContent.Contains("BlackBarRemover/ClientScript.js"))
{
// Wstrzykujemy skrypt tuż przed końcem taga </body>
indexContent = indexContent.Replace("</body>", $" {scriptTag}\n</body>");
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()
{
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<!-- Zależności Jellyfin -->
<PackageReference Include="Jellyfin.Controller" Version="12.0.0" />
<PackageReference Include="Jellyfin.Model" Version="12.0.0" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
</Project>
+174
View File
@@ -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<LibraryMonitor> _logger;
private readonly SemaphoreSlim _ffmpegSemaphore = new SemaphoreSlim(2, 2);
public LibraryMonitor(ILibraryManager libraryManager, ILogger<LibraryMonitor> 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<string, string>();
}
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<string> 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<string, int>();
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();
}
}
}
+35
View File
@@ -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<PluginConfiguration>, 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<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = this.Name,
EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace)
}
};
}
}
}
+101
View File
@@ -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<WrapperGenerator> _logger;
private readonly IApplicationPaths _appPaths;
public WrapperGenerator(ILogger<WrapperGenerator> 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()
{
}
}
}