diff --git a/JellyfinPlugin/CropHelper.cs b/JellyfinPlugin/CropHelper.cs new file mode 100644 index 0000000..1d24f72 --- /dev/null +++ b/JellyfinPlugin/CropHelper.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace JellyfinPlugin +{ + public static class CropHelper + { + public static async Task DetectCropAsync(string filePath, double limit) + { + 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; + + return $"{finalW}:{finalH}:{minX}:{minY}"; + } + } +} diff --git a/JellyfinPlugin/LibraryMonitor.cs b/JellyfinPlugin/LibraryMonitor.cs index f5f4a04..8643505 100644 --- a/JellyfinPlugin/LibraryMonitor.cs +++ b/JellyfinPlugin/LibraryMonitor.cs @@ -68,7 +68,8 @@ namespace JellyfinPlugin await _ffmpegSemaphore.WaitAsync(); try { - string cropData = await DetectCropAsync(path); + double limit = Plugin.Instance!.Configuration.CropDetectLimit; + string cropData = await CropHelper.DetectCropAsync(path, limit); if (!string.IsNullOrEmpty(cropData)) { if (video.ProviderIds == null) @@ -76,7 +77,6 @@ namespace JellyfinPlugin 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); } @@ -92,78 +92,6 @@ namespace JellyfinPlugin } } - 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; diff --git a/JellyfinPlugin/Tasks/LibraryRetroactiveScanTask.cs b/JellyfinPlugin/Tasks/LibraryRetroactiveScanTask.cs new file mode 100644 index 0000000..588c852 --- /dev/null +++ b/JellyfinPlugin/Tasks/LibraryRetroactiveScanTask.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace JellyfinPlugin.Tasks +{ + public class LibraryRetroactiveScanTask : IScheduledTask + { + private readonly ILogger _logger; + private readonly ILibraryManager _libraryManager; + + public LibraryRetroactiveScanTask(ILogger logger, ILibraryManager libraryManager) + { + _logger = logger; + _libraryManager = libraryManager; + } + + public string Name => "Skanuj brakujące czarne pasy (Black Bar Remover)"; + public string Key => "BlackBarRetroactiveScanTask"; + public string Description => "Skanuje istniejącą bibliotekę, pomijając już przeanalizowane pliki. Użyj tego po zainstalowaniu wtyczki."; + public string Category => "Library"; + + public async Task ExecuteAsync(IProgress progress, CancellationToken cancellationToken) + { + var config = Plugin.Instance!.Configuration; + if (!config.IsEnabled) + { + _logger.LogInformation("Wtyczka wyłączona. Pomijanie."); + return; + } + + var items = _libraryManager.RootFolder.GetRecursiveChildren() + .Where(x => x is Movie || x is Episode) + .ToList(); + + int total = items.Count; + int processed = 0; + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (item.ProviderIds != null && item.ProviderIds.ContainsKey("BlackBarCrop")) + { + processed++; + progress.Report((processed / (double)total) * 100); + continue; + } + + var path = item.Path; + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + try + { + string cropData = await CropHelper.DetectCropAsync(path, config.CropDetectLimit); + + if (item.ProviderIds == null) + { + item.ProviderIds = new Dictionary(); + } + + // Zawsze dodajemy flagę, nawet jak puste, żeby nie skanować drugi raz tego samego pliku + // (puste oznacza że nie ma czarnych pasów do ucięcia) + item.ProviderIds["BlackBarCrop"] = cropData ?? string.Empty; + await _libraryManager.UpdateItemAsync(item, item, ItemUpdateType.MetadataEdit, cancellationToken); + + if (!string.IsNullOrEmpty(cropData)) + _logger.LogInformation("Znaleziono pasy {Crop} w {Path}", cropData, path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Błąd skanowania pliku {Path}", path); + } + } + + processed++; + progress.Report((processed / (double)total) * 100); + } + } + + public IEnumerable GetDefaultTriggers() + { + return Array.Empty(); + } + } +} diff --git a/README.md b/README.md index 29f58b6..807289f 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,6 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin ## Installation -### Method 1: Building from Source - 1. Clone this repository. 2. Build the plugin using the .NET 10 SDK: ```bash @@ -28,7 +26,7 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin 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 +### Configuration & Initial Scan 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. @@ -36,6 +34,7 @@ A Jellyfin plugin that automatically detects and removes black bars (letterboxin - `Client`: Uses CSS injection in the web player. - `Server`: Requires setting up the FFmpeg wrapper. - `Both`: Attempts both methods depending on the client. +4. **Important for existing libraries**: The plugin automatically scans *new* files when they are added to Jellyfin. For your existing library, go to **Dashboard -> Scheduled Tasks** and run the **"Skanuj brakujące czarne pasy (Black Bar Remover)"** task manually. It will scan all existing movies and episodes in the background. ### Activating the Server-Side Wrapper (Optional)