All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
309 lines
10 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|