All checks were successful
Create Release PR / Create Release PR (push) Successful in 18s
685 lines
25 KiB
C#
685 lines
25 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Timers;
|
|
using Jellyfin.Data.Enums;
|
|
using Jellyfin.Plugin.SmartNotify.Services;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Entities.Movies;
|
|
using MediaBrowser.Controller.Entities.TV;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Model.Entities;
|
|
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");
|
|
|
|
// Pre-populate DB with all existing library items so they're recognized as "known".
|
|
// This prevents mass notifications on first run or after DB reset.
|
|
SeedExistingLibraryItems();
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds the database with all existing Episodes and Movies from the library.
|
|
/// Runs once at startup — only records items not yet in the DB.
|
|
/// </summary>
|
|
private void SeedExistingLibraryItems()
|
|
{
|
|
try
|
|
{
|
|
var query = new InternalItemsQuery
|
|
{
|
|
IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie },
|
|
IsVirtualItem = false,
|
|
Recursive = true
|
|
};
|
|
|
|
var existingItems = _libraryManager.GetItemList(query);
|
|
var seeded = 0;
|
|
var alreadyKnown = 0;
|
|
|
|
foreach (var item in existingItems)
|
|
{
|
|
if (!_historyService.IsKnownItem(item.Id, item))
|
|
{
|
|
_historyService.RecordItem(item);
|
|
seeded++;
|
|
}
|
|
else
|
|
{
|
|
alreadyKnown++;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"[DEBUG Seed] Seeded {Seeded} new items, {AlreadyKnown} already known, {Total} total in library",
|
|
seeded,
|
|
alreadyKnown,
|
|
existingItems.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error seeding existing library items");
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
// Skip virtual items — these are created by "Add Missing Episodes/Seasons"
|
|
// metadata feature and have no actual file on disk
|
|
if (item.IsVirtualItem || string.IsNullOrEmpty(item.Path))
|
|
{
|
|
_logger.LogDebug("Skipping virtual item (no file): {Name} (ID: {Id})", item.Name, item.Id);
|
|
return;
|
|
}
|
|
|
|
// Debug: log all available metadata on the item at ItemAdded time
|
|
if (item is Episode debugEp)
|
|
{
|
|
_logger.LogInformation(
|
|
"[DEBUG ItemAdded] Episode: Name={Name}, Id={Id}, Path={Path}, " +
|
|
"SeriesName={SeriesName}, SeriesId={SeriesId}, " +
|
|
"Season={Season}, Episode={Episode}, " +
|
|
"ProviderIds={ProviderIds}, " +
|
|
"DateCreated={DateCreated}, PremiereDate={PremiereDate}",
|
|
debugEp.Name,
|
|
debugEp.Id,
|
|
debugEp.Path,
|
|
debugEp.SeriesName,
|
|
debugEp.SeriesId,
|
|
debugEp.ParentIndexNumber,
|
|
debugEp.IndexNumber,
|
|
debugEp.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugEp.ProviderIds) : "null",
|
|
debugEp.DateCreated,
|
|
debugEp.PremiereDate);
|
|
|
|
// Also try to access the Series object directly
|
|
try
|
|
{
|
|
var debugSeries = debugEp.Series;
|
|
if (debugSeries != null)
|
|
{
|
|
_logger.LogInformation(
|
|
"[DEBUG ItemAdded] Series object found: Name={Name}, Id={Id}, ProviderIds={ProviderIds}",
|
|
debugSeries.Name,
|
|
debugSeries.Id,
|
|
debugSeries.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugSeries.ProviderIds) : "null");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("[DEBUG ItemAdded] Series object is NULL for episode {Name}", debugEp.Name);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[DEBUG ItemAdded] Failed to access Series object for {Name}", debugEp.Name);
|
|
}
|
|
}
|
|
else if (item is Movie debugMovie)
|
|
{
|
|
_logger.LogInformation(
|
|
"[DEBUG ItemAdded] Movie: Name={Name}, Id={Id}, Path={Path}, " +
|
|
"ProviderIds={ProviderIds}, Year={Year}",
|
|
debugMovie.Name,
|
|
debugMovie.Id,
|
|
debugMovie.Path,
|
|
debugMovie.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugMovie.ProviderIds) : "null",
|
|
debugMovie.ProductionYear);
|
|
}
|
|
|
|
_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 0: Is this item already known in our DB? (by Jellyfin ID or content key)
|
|
if (_historyService.IsKnownItem(item.Id, item))
|
|
{
|
|
_logger.LogDebug(
|
|
"Item {Name} (ID: {Id}) is already known in DB, skipping notification",
|
|
item.Name,
|
|
item.Id);
|
|
_historyService.RecordItem(item);
|
|
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 local image path for attachment-based sending
|
|
var imagePath = item.GetImagePath(ImageType.Primary, 0);
|
|
if (!string.IsNullOrEmpty(imagePath))
|
|
{
|
|
notification.ImagePath = imagePath;
|
|
}
|
|
|
|
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 provider IDs for external links — episode provider IDs
|
|
// (e.g. AniDB episode ID) lead to wrong URLs when used with /anime/ paths
|
|
if (episode.SeriesId != Guid.Empty)
|
|
{
|
|
var series = _libraryManager.GetItemById(episode.SeriesId);
|
|
if (series?.ProviderIds != null && series.ProviderIds.Count > 0)
|
|
{
|
|
notification.ProviderIdsJson = JsonSerializer.Serialize(series.ProviderIds);
|
|
}
|
|
|
|
var seriesImage = series?.GetImagePath(ImageType.Primary, 0);
|
|
if (!string.IsNullOrEmpty(seriesImage))
|
|
{
|
|
notification.ImagePath = seriesImage;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"[DEBUG CreateNotification] Result: SeriesName={SeriesName}, SeriesId={SeriesId}, " +
|
|
"S{Season}E{Episode}, ProviderIdsJson={ProviderIds}, ImagePath={ImagePath}",
|
|
notification.SeriesName,
|
|
notification.SeriesId,
|
|
notification.SeasonNumber,
|
|
notification.EpisodeNumber,
|
|
notification.ProviderIdsJson,
|
|
notification.ImagePath);
|
|
}
|
|
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>
|
|
/// Refreshes notification metadata from the library (series info may not be available at queue time).
|
|
/// </summary>
|
|
private void RefreshNotificationMetadata(PendingNotification notification)
|
|
{
|
|
if (!Guid.TryParse(notification.JellyfinItemId, out var itemId) || itemId == Guid.Empty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var item = _libraryManager.GetItemById(itemId);
|
|
if (item is not Episode episode)
|
|
{
|
|
_logger.LogInformation(
|
|
"[DEBUG Refresh] Item {Id} is {Type} (not Episode), skipping",
|
|
itemId,
|
|
item?.GetType().Name ?? "NULL");
|
|
return;
|
|
}
|
|
|
|
// Debug: log what Jellyfin returns for this episode at refresh time
|
|
_logger.LogInformation(
|
|
"[DEBUG Refresh] Episode from library: Name={Name}, SeriesName={SeriesName}, SeriesId={SeriesId}, " +
|
|
"Season={Season}, Episode={Episode}, ProviderIds={ProviderIds}",
|
|
episode.Name,
|
|
episode.SeriesName,
|
|
episode.SeriesId,
|
|
episode.ParentIndexNumber,
|
|
episode.IndexNumber,
|
|
episode.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(episode.ProviderIds) : "null");
|
|
|
|
try
|
|
{
|
|
var debugSeries = episode.Series;
|
|
if (debugSeries != null)
|
|
{
|
|
_logger.LogInformation(
|
|
"[DEBUG Refresh] Series object: Name={Name}, Id={Id}, ProviderIds={ProviderIds}",
|
|
debugSeries.Name,
|
|
debugSeries.Id,
|
|
debugSeries.ProviderIds != null ? System.Text.Json.JsonSerializer.Serialize(debugSeries.ProviderIds) : "null");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("[DEBUG Refresh] Series object is STILL NULL for {Name}", episode.Name);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[DEBUG Refresh] Failed to access Series for {Name}", episode.Name);
|
|
}
|
|
|
|
var changed = false;
|
|
|
|
if (string.IsNullOrEmpty(notification.SeriesName) || notification.SeriesName == "Unknown Series")
|
|
{
|
|
notification.SeriesName = episode.SeriesName;
|
|
changed = true;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(notification.SeriesId) || notification.SeriesId == Guid.Empty.ToString())
|
|
{
|
|
if (episode.SeriesId != Guid.Empty)
|
|
{
|
|
notification.SeriesId = episode.SeriesId.ToString();
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (!notification.SeasonNumber.HasValue && episode.ParentIndexNumber.HasValue)
|
|
{
|
|
notification.SeasonNumber = episode.ParentIndexNumber;
|
|
changed = true;
|
|
}
|
|
|
|
if (!notification.EpisodeNumber.HasValue && episode.IndexNumber.HasValue)
|
|
{
|
|
notification.EpisodeNumber = episode.IndexNumber;
|
|
changed = true;
|
|
}
|
|
|
|
// Refresh image if missing
|
|
if (string.IsNullOrEmpty(notification.ImagePath) && episode.SeriesId != Guid.Empty)
|
|
{
|
|
var series = _libraryManager.GetItemById(episode.SeriesId);
|
|
var seriesImage = series?.GetImagePath(ImageType.Primary, 0);
|
|
if (!string.IsNullOrEmpty(seriesImage))
|
|
{
|
|
notification.ImagePath = seriesImage;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
_historyService.UpdateNotification(notification);
|
|
_logger.LogInformation(
|
|
"Refreshed metadata for {Name}: Series={SeriesName}, SeriesId={SeriesId}, S{Season}E{Episode}",
|
|
notification.Name,
|
|
notification.SeriesName,
|
|
notification.SeriesId,
|
|
notification.SeasonNumber,
|
|
notification.EpisodeNumber);
|
|
}
|
|
}
|
|
|
|
/// <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.LogInformation(
|
|
"Processing {Count} pending notifications (delay={Delay}min, grouping={Grouping}min, cutoff={Cutoff})",
|
|
pendingNotifications.Count,
|
|
delayMinutes,
|
|
groupingWindowMinutes,
|
|
cutoff);
|
|
|
|
// Refresh metadata for episodes that were queued before metadata was available
|
|
foreach (var notification in pendingNotifications.Where(n => n.ItemType == "Episode"))
|
|
{
|
|
RefreshNotificationMetadata(notification);
|
|
}
|
|
|
|
// Late known-item detection: re-check now that metadata is fully populated.
|
|
// At queue time, metadata (ProviderIds, Series, Season/Episode) may not have
|
|
// been available, causing GenerateContentKey() to return null and known items
|
|
// to go undetected. By now (after delay + grouping window), metadata is ready.
|
|
// This catches reorganized items (path/metadata changes) and quality upgrades.
|
|
{
|
|
var suppressedIds = new List<int>();
|
|
foreach (var notification in pendingNotifications)
|
|
{
|
|
if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId))
|
|
{
|
|
var revalidateItem = _libraryManager.GetItemById(revalidateId);
|
|
var result = _historyService.RevalidatePendingItem(notification.JellyfinItemId, revalidateItem, _libraryManager);
|
|
|
|
if (result == ItemHistoryService.RevalidationResult.Reorganized)
|
|
{
|
|
// Always suppress reorganized items (same content, path/ID changed)
|
|
suppressedIds.Add(notification.Id);
|
|
_logger.LogInformation(
|
|
"Suppressed {Name}: recognized as reorganized known item at send time",
|
|
notification.Name);
|
|
}
|
|
else if (result == ItemHistoryService.RevalidationResult.Upgrade && config.SuppressUpgrades)
|
|
{
|
|
// Only suppress upgrades when configured to do so
|
|
suppressedIds.Add(notification.Id);
|
|
_logger.LogInformation(
|
|
"Suppressed {Name}: quality upgrade detected at send time",
|
|
notification.Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (suppressedIds.Count > 0)
|
|
{
|
|
_historyService.RemoveNotifications(suppressedIds);
|
|
pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id));
|
|
_logger.LogInformation(
|
|
"Suppressed {Count} notifications at send time ({Reason})",
|
|
suppressedIds.Count,
|
|
"reorganized/upgrade");
|
|
}
|
|
|
|
if (pendingNotifications.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Group episodes by series
|
|
var emptyGuid = Guid.Empty.ToString();
|
|
var episodesBySeries = pendingNotifications
|
|
.Where(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId) && n.SeriesId != emptyGuid)
|
|
.GroupBy(n => n.SeriesId!)
|
|
.ToList();
|
|
|
|
// Handle episodes that still have no series info (send individually as fallback)
|
|
var orphanEpisodes = pendingNotifications
|
|
.Where(n => n.ItemType == "Episode" && (string.IsNullOrEmpty(n.SeriesId) || n.SeriesId == emptyGuid))
|
|
.ToList();
|
|
|
|
if (orphanEpisodes.Count > 0)
|
|
{
|
|
_logger.LogWarning(
|
|
"{Count} episode notifications have no series info even after refresh",
|
|
orphanEpisodes.Count);
|
|
}
|
|
|
|
// 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.LogInformation(
|
|
"Waiting for grouping window for series {SeriesName} ({SeriesId}), oldest queued: {Oldest}, grouping cutoff: {Cutoff}",
|
|
seriesGroup.First().SeriesName,
|
|
seriesGroup.Key,
|
|
oldestInGroup,
|
|
groupingCutoff);
|
|
continue;
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Sending grouped notification for {SeriesName}: {Count} episodes",
|
|
seriesGroup.First().SeriesName,
|
|
seriesGroup.Count());
|
|
|
|
var success = await _discordService.SendGroupedEpisodeNotificationAsync(
|
|
seriesGroup,
|
|
CancellationToken.None);
|
|
|
|
if (success)
|
|
{
|
|
var idsToRemove = seriesGroup.Select(n => n.Id).ToList();
|
|
_historyService.RemoveNotifications(idsToRemove);
|
|
_logger.LogInformation("Removed {Count} processed episode notifications from queue", idsToRemove.Count);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Discord send failed for {SeriesName}, keeping notifications in queue for retry", seriesGroup.First().SeriesName);
|
|
}
|
|
}
|
|
|
|
// Process orphan episodes (no series info - send individually)
|
|
foreach (var orphan in orphanEpisodes)
|
|
{
|
|
var oldAge = DateTime.UtcNow.AddMinutes(-groupingWindowMinutes);
|
|
if (orphan.QueuedAt > oldAge)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_logger.LogInformation("Sending individual episode notification for: {Name} (no series info)", orphan.Name);
|
|
|
|
var success = await _discordService.SendGroupedEpisodeNotificationAsync(
|
|
new[] { orphan },
|
|
CancellationToken.None);
|
|
|
|
if (success)
|
|
{
|
|
_historyService.RemoveNotifications(new[] { orphan.Id });
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Discord send failed for orphan episode {Name}, keeping in queue for retry", orphan.Name);
|
|
}
|
|
}
|
|
|
|
// Process movies
|
|
var movies = pendingNotifications
|
|
.Where(n => n.ItemType == "Movie")
|
|
.ToList();
|
|
|
|
foreach (var movie in movies)
|
|
{
|
|
_logger.LogInformation("Sending movie notification for: {Name}", movie.Name);
|
|
|
|
var success = await _discordService.SendMovieNotificationAsync(movie, CancellationToken.None);
|
|
|
|
if (success)
|
|
{
|
|
_historyService.RemoveNotifications(new[] { movie.Id });
|
|
_logger.LogInformation("Removed processed movie notification from queue: {Name}", movie.Name);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Discord send failed for movie {Name}, keeping in queue for retry", movie.Name);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error processing pending notifications");
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
if (!_disposed)
|
|
{
|
|
_processTimer?.Dispose();
|
|
_disposed = true;
|
|
}
|
|
}
|
|
}
|