Files
jellyfin-plugin-smartnotify/Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs
TDPI b1444094ad
All checks were successful
Create Release PR / Create Release PR (push) Successful in 59s
fix: unknown series
2026-04-03 17:40:17 +02:00

700 lines
27 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)
{
// episode.SeriesName / SeriesId are often null with Shokofin VFS,
// but the Series navigation property usually works
var seriesObj = episode.Series;
notification.SeriesName = episode.SeriesName ?? seriesObj?.Name;
notification.SeriesId = (episode.SeriesId != Guid.Empty
? episode.SeriesId
: seriesObj?.Id ?? Guid.Empty).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
var resolvedSeriesId = notification.SeriesId;
if (resolvedSeriesId != Guid.Empty.ToString() && Guid.TryParse(resolvedSeriesId, out var seriesGuid))
{
var series = seriesObj ?? _libraryManager.GetItemById(seriesGuid);
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")
{
// episode.SeriesName is often null (especially with Shokofin VFS),
// but the Series navigation property usually has the correct name
notification.SeriesName = episode.SeriesName ?? episode.Series?.Name;
changed = true;
}
if (string.IsNullOrEmpty(notification.SeriesId) || notification.SeriesId == Guid.Empty.ToString())
{
// Same fallback: SeriesId property may be empty, but Series object may exist
var resolvedSeriesId = episode.SeriesId != Guid.Empty
? episode.SeriesId
: episode.Series?.Id ?? Guid.Empty;
if (resolvedSeriesId != Guid.Empty)
{
notification.SeriesId = resolvedSeriesId.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();
// Episodes without SeriesName or SeriesId must NEVER be sent.
// Wait up to 30 minutes for metadata to resolve, then drop.
var maxMetadataWait = DateTime.UtcNow.AddMinutes(-30);
var incompleteEpisodes = pendingNotifications
.Where(n => n.ItemType == "Episode"
&& (string.IsNullOrEmpty(n.SeriesName)
|| string.IsNullOrEmpty(n.SeriesId)
|| n.SeriesId == emptyGuid))
.ToList();
if (incompleteEpisodes.Count > 0)
{
// Split into: still waiting vs. timed out
var timedOut = incompleteEpisodes.Where(n => n.QueuedAt < maxMetadataWait).ToList();
var stillWaiting = incompleteEpisodes.Where(n => n.QueuedAt >= maxMetadataWait).ToList();
if (timedOut.Count > 0)
{
var dropIds = timedOut.Select(n => n.Id).ToList();
_historyService.RemoveNotifications(dropIds);
pendingNotifications.RemoveAll(n => dropIds.Contains(n.Id));
_logger.LogWarning(
"Dropped {Count} episode notifications after 30min without series metadata: {Names}",
timedOut.Count,
string.Join(", ", timedOut.Select(n => n.Name)));
}
if (stillWaiting.Count > 0)
{
// Remove from this processing cycle, keep in queue for next attempt
var waitIds = stillWaiting.Select(n => n.Id).ToHashSet();
pendingNotifications.RemoveAll(n => waitIds.Contains(n.Id));
_logger.LogInformation(
"Deferring {Count} episode notifications, waiting for series metadata: {Names}",
stillWaiting.Count,
string.Join(", ", stillWaiting.Select(n => n.Name)));
}
}
// 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 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;
}
}
}