Initial commit: Jellyfin Black Bar Remover MVP
This commit is contained in:
+21
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `<PluginsPath>/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
|
||||||
|
<script src="/BlackBarRemover/ClientScript.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
When a video is scanned, a background process runs `ffmpeg -skip_frame nokey -i <file> -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.
|
||||||
@@ -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 <video_file>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
file_path = sys.argv[1]
|
||||||
|
result = detect_crop(file_path)
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
@@ -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 <input_file> <output_file> <crop_param>")
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user