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); private readonly ConcurrentDictionary _processedItems = new ConcurrentDictionary(); 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; if (_processedItems.ContainsKey(video.Id)) return; if (video.ProviderIds != null && video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) && !string.IsNullOrEmpty(existingCrop)) { _processedItems.TryAdd(video.Id, 0); return; } // Fire and forget, don't block library scan _ = Task.Run(() => ProcessVideoAsync(video)); } } private async Task ProcessVideoAsync(Video video) { try { if (!_processedItems.TryAdd(video.Id, 0)) return; if (video.ProviderIds != null && video.ProviderIds.TryGetValue("BlackBarCrop", out string? existingCrop) && !string.IsNullOrEmpty(existingCrop)) { return; } var path = video.Path; if (string.IsNullOrEmpty(path) || !File.Exists(path)) return; await _ffmpegSemaphore.WaitAsync(); try { double limit = Plugin.Instance!.Configuration.CropDetectLimit; string cropData = await CropHelper.DetectCropAsync(path, limit); if (!string.IsNullOrEmpty(cropData)) { if (video.ProviderIds == null) { video.ProviderIds = new System.Collections.Generic.Dictionary(); } video.ProviderIds["BlackBarCrop"] = cropData; await _libraryManager.UpdateItemAsync(video, video, ItemUpdateType.MetadataEdit, CancellationToken.None); _logger.LogInformation("Saved crop proportions {CropData} for file {Path}", cropData, path); } } finally { _ffmpegSemaphore.Release(); } } catch (Exception ex) { _logger.LogError(ex, "Error processing video: {Path}", video.Path); } } public void Dispose() { _libraryManager.ItemAdded -= OnItemAddedOrUpdated; _libraryManager.ItemUpdated -= OnItemAddedOrUpdated; _ffmpegSemaphore.Dispose(); } } }