Files
jellyfin-plugin-smartnotify/Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs
TDPI b3304e61bf
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
fix:: Plugin Erste Tests
2026-03-01 16:01:26 +01:00

309 lines
10 KiB
C#

using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using Jellyfin.Plugin.SmartNotify.Services;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace Jellyfin.Plugin.SmartNotify.Notifiers;
/// <summary>
/// Background service that monitors library changes and sends smart notifications.
/// </summary>
public class SmartNotifyBackgroundService : IHostedService, IDisposable
{
private readonly ILogger<SmartNotifyBackgroundService> _logger;
private readonly ILibraryManager _libraryManager;
private readonly ItemHistoryService _historyService;
private readonly DiscordNotificationService _discordService;
private Timer? _processTimer;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SmartNotifyBackgroundService"/> class.
/// </summary>
public SmartNotifyBackgroundService(
ILogger<SmartNotifyBackgroundService> logger,
ILibraryManager libraryManager,
ItemHistoryService historyService,
DiscordNotificationService discordService)
{
_logger = logger;
_libraryManager = libraryManager;
_historyService = historyService;
_discordService = discordService;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("SmartNotify background service starting");
// Subscribe to library events
_libraryManager.ItemAdded += OnItemAdded;
_libraryManager.ItemRemoved += OnItemRemoved;
// Start the notification processing timer (runs every minute)
_processTimer = new Timer(60_000);
_processTimer.Elapsed += ProcessPendingNotifications;
_processTimer.AutoReset = true;
_processTimer.Start();
_logger.LogInformation("SmartNotify is now monitoring library changes");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("SmartNotify background service stopping");
_libraryManager.ItemAdded -= OnItemAdded;
_libraryManager.ItemRemoved -= OnItemRemoved;
_processTimer?.Stop();
return Task.CompletedTask;
}
/// <summary>
/// Called when an item is added to the library.
/// </summary>
private void OnItemAdded(object? sender, ItemChangeEventArgs e)
{
var item = e.Item;
// Only process Episodes and Movies
if (item is not Episode && item is not Movie)
{
return;
}
_logger.LogDebug("Item added: {Name} (Type: {Type}, ID: {Id})", item.Name, item.GetType().Name, item.Id);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return;
}
// Check if this type of notification is enabled
if (item is Episode && !config.EnableEpisodeNotifications)
{
return;
}
if (item is Movie && !config.EnableMovieNotifications)
{
return;
}
try
{
ProcessNewItem(item);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing new item {Name}", item.Name);
}
}
/// <summary>
/// Processes a newly added item.
/// </summary>
private void ProcessNewItem(BaseItem item)
{
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return;
}
// Check 1: Is this a quality upgrade? (Same content, different file)
var isUpgrade = _historyService.IsQualityUpgrade(item);
// Check 2: Is there duplicate content currently in the library?
// This catches the case where the new file arrives BEFORE the old one is deleted
var hasDuplicate = _historyService.HasDuplicateContent(item, _libraryManager);
if (isUpgrade || hasDuplicate)
{
_logger.LogInformation(
"Suppressing notification for {Name} - IsUpgrade: {IsUpgrade}, HasDuplicate: {HasDuplicate}",
item.Name,
isUpgrade,
hasDuplicate);
if (config.SuppressUpgrades)
{
// Just record the item and don't notify
_historyService.RecordItem(item);
return;
}
// If not suppressing, we could send an "upgrade" notification instead
// For now, just fall through and send as normal
}
// Record this item in our database
_historyService.RecordItem(item);
// Create pending notification
var notification = CreateNotification(item);
if (notification == null)
{
return;
}
_historyService.QueueNotification(notification);
_logger.LogInformation("Queued notification for {Name}", item.Name);
}
/// <summary>
/// Creates a pending notification from a base item.
/// </summary>
private PendingNotification? CreateNotification(BaseItem item)
{
var config = Plugin.Instance?.Configuration;
var serverUrl = config?.ServerUrl?.TrimEnd('/') ?? string.Empty;
var notification = new PendingNotification
{
JellyfinItemId = item.Id.ToString(),
ItemType = item.GetType().Name,
Name = item.Name,
QueuedAt = DateTime.UtcNow,
Type = NotificationType.NewContent,
ProviderIdsJson = JsonSerializer.Serialize(item.ProviderIds ?? new Dictionary<string, string>()),
Overview = item.Overview
};
// Set image URL
if (!string.IsNullOrEmpty(serverUrl))
{
notification.ImageUrl = $"{serverUrl}/Items/{item.Id}/Images/Primary";
}
if (item is Episode episode)
{
notification.SeriesName = episode.SeriesName;
notification.SeriesId = episode.SeriesId.ToString();
notification.SeasonNumber = episode.ParentIndexNumber;
notification.EpisodeNumber = episode.IndexNumber;
notification.Year = episode.ProductionYear;
// Use series image if episode doesn't have one
if (!string.IsNullOrEmpty(serverUrl) && episode.SeriesId != Guid.Empty)
{
notification.ImageUrl = $"{serverUrl}/Items/{episode.SeriesId}/Images/Primary";
}
}
else if (item is Movie movie)
{
notification.Year = movie.ProductionYear;
}
return notification;
}
/// <summary>
/// Called when an item is removed from the library.
/// </summary>
private void OnItemRemoved(object? sender, ItemChangeEventArgs e)
{
// We keep the history record! This is important for detecting upgrades.
// When old file is deleted after new one is added, we don't want to
// remove our knowledge of this content.
_logger.LogDebug("Item removed: {Name} (ID: {Id})", e.Item.Name, e.Item.Id);
}
/// <summary>
/// Processes pending notifications (called by timer).
/// </summary>
private async void ProcessPendingNotifications(object? sender, ElapsedEventArgs e)
{
try
{
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{
return;
}
var delayMinutes = config.NotificationDelayMinutes;
var groupingWindowMinutes = config.GroupingWindowMinutes;
var cutoff = DateTime.UtcNow.AddMinutes(-delayMinutes);
var pendingNotifications = _historyService.GetPendingNotifications(cutoff).ToList();
if (pendingNotifications.Count == 0)
{
return;
}
_logger.LogDebug("Processing {Count} pending notifications", pendingNotifications.Count);
// Group episodes by series
var episodesBySeries = pendingNotifications
.Where(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId))
.GroupBy(n => n.SeriesId!)
.ToList();
// Process each series group
foreach (var seriesGroup in episodesBySeries)
{
var oldestInGroup = seriesGroup.Min(n => n.QueuedAt);
var groupingCutoff = DateTime.UtcNow.AddMinutes(-groupingWindowMinutes);
// Only process if the oldest notification is outside the grouping window
if (oldestInGroup > groupingCutoff)
{
_logger.LogDebug(
"Waiting for grouping window for series {SeriesId}, oldest: {Oldest}",
seriesGroup.Key,
oldestInGroup);
continue;
}
await _discordService.SendGroupedEpisodeNotificationAsync(
seriesGroup,
CancellationToken.None);
var idsToRemove = seriesGroup.Select(n => n.Id).ToList();
_historyService.RemoveNotifications(idsToRemove);
}
// Process movies
var movies = pendingNotifications
.Where(n => n.ItemType == "Movie")
.ToList();
foreach (var movie in movies)
{
await _discordService.SendMovieNotificationAsync(movie, CancellationToken.None);
_historyService.RemoveNotifications(new[] { movie.Id });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing pending notifications");
}
}
/// <inheritdoc />
public void Dispose()
{
if (!_disposed)
{
_processTimer?.Dispose();
_disposed = true;
}
}
}