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(); } } }