fix:: Plugin Erste Tests
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user