fix: double notifications
All checks were successful
Create Release PR / Create Release PR (push) Successful in 18s

This commit is contained in:
2026-03-05 16:40:03 +01:00
parent 562bfbec54
commit 9863778d8b
3 changed files with 70 additions and 17 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
.claude .claude
buildedplugin buildedplugin
MEMORY.md

View File

@@ -512,11 +512,11 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
RefreshNotificationMetadata(notification); RefreshNotificationMetadata(notification);
} }
// Late upgrade detection: re-check now that metadata is fully populated. // Late known-item detection: re-check now that metadata is fully populated.
// At queue time, metadata (ProviderIds, Series, Season/Episode) may not have // At queue time, metadata (ProviderIds, Series, Season/Episode) may not have
// been available, causing GenerateContentKey() to return null and upgrades // been available, causing GenerateContentKey() to return null and known items
// to go undetected. By now (after delay + grouping window), metadata is ready. // to go undetected. By now (after delay + grouping window), metadata is ready.
if (config.SuppressUpgrades) // This catches reorganized items (path/metadata changes) and quality upgrades.
{ {
var suppressedIds = new List<int>(); var suppressedIds = new List<int>();
foreach (var notification in pendingNotifications) foreach (var notification in pendingNotifications)
@@ -524,11 +524,22 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId)) if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId))
{ {
var revalidateItem = _libraryManager.GetItemById(revalidateId); var revalidateItem = _libraryManager.GetItemById(revalidateId);
if (_historyService.RevalidatePendingItem(notification.JellyfinItemId, revalidateItem)) 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); suppressedIds.Add(notification.Id);
_logger.LogInformation( _logger.LogInformation(
"Late suppression: {Name} detected as upgrade at send time", "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); notification.Name);
} }
} }
@@ -539,8 +550,9 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
_historyService.RemoveNotifications(suppressedIds); _historyService.RemoveNotifications(suppressedIds);
pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id)); pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id));
_logger.LogInformation( _logger.LogInformation(
"Suppressed {Count} upgrade notifications at send time", "Suppressed {Count} notifications at send time ({Reason})",
suppressedIds.Count); suppressedIds.Count,
"reorganized/upgrade");
} }
if (pendingNotifications.Count == 0) if (pendingNotifications.Count == 0)

View File

@@ -319,34 +319,74 @@ public class ItemHistoryService : IDisposable
} }
/// <summary> /// <summary>
/// Re-checks if a pending notification is actually a quality upgrade. /// Result of revalidating a pending notification at send time.
/// </summary>
public enum RevalidationResult
{
/// <summary>Item is genuinely new content.</summary>
New,
/// <summary>Item is a known item that was reorganized (path/metadata change, old ID gone).</summary>
Reorganized,
/// <summary>Item is a quality upgrade (same content, old file still exists).</summary>
Upgrade
}
/// <summary>
/// Re-checks if a pending notification is actually a known item (reorganized or upgraded).
/// Called at send time when metadata is fully populated. /// Called at send time when metadata is fully populated.
/// At queue time, items often have empty ProviderIds and no season/episode numbers,
/// so the content key couldn't be generated. Now that metadata is available, we can
/// properly identify known items.
/// </summary> /// </summary>
/// <param name="jellyfinItemId">The Jellyfin item ID string.</param> /// <param name="jellyfinItemId">The Jellyfin item ID string.</param>
/// <param name="item">The resolved library item (may have updated metadata).</param> /// <param name="item">The resolved library item (may have updated metadata).</param>
/// <returns>True if this item is a late-detected upgrade that should be suppressed.</returns> /// <param name="libraryManager">The library manager to check if old items still exist.</param>
public bool RevalidatePendingItem(string jellyfinItemId, BaseItem? item) /// <returns>The revalidation result indicating if this is new, reorganized, or an upgrade.</returns>
public RevalidationResult RevalidatePendingItem(string jellyfinItemId, BaseItem? item, MediaBrowser.Controller.Library.ILibraryManager libraryManager)
{ {
if (item == null) if (item == null)
{ {
return false; return RevalidationResult.New;
} }
var contentKey = GenerateContentKey(item); var contentKey = GenerateContentKey(item);
if (contentKey == null) if (contentKey == null)
{ {
return false; return RevalidationResult.New;
}
// Update unresolved content key on the item's own record
var ownRecord = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinItemId);
if (ownRecord != null && ownRecord.ContentKey.StartsWith("unresolved|", StringComparison.Ordinal))
{
ownRecord.ContentKey = contentKey;
_knownItems.Update(ownRecord);
_logger.LogDebug("Resolved content key for {Name}: {Key}", item.Name, contentKey);
} }
// Check if this content was already known under a different item ID // Check if this content was already known under a different item ID
var existing = _knownItems.FindOne(x => x.ContentKey == contentKey && x.JellyfinItemId != jellyfinItemId); var existing = _knownItems.FindOne(x => x.ContentKey == contentKey && x.JellyfinItemId != jellyfinItemId);
if (existing != null) if (existing != null)
{ {
// Determine if the old item still exists in the library
var oldItemExists = false;
if (Guid.TryParse(existing.JellyfinItemId, out var oldId))
{
oldItemExists = libraryManager.GetItemById(oldId) != null;
}
var resultType = oldItemExists
? RevalidationResult.Upgrade
: RevalidationResult.Reorganized;
_logger.LogInformation( _logger.LogInformation(
"Late upgrade detection for {Name}: content key {Key} already known (first seen: {FirstSeen})", "Late detection for {Name}: content key {Key} already known (first seen: {FirstSeen}, result: {Result})",
item.Name, item.Name,
contentKey, contentKey,
existing.FirstSeen); existing.FirstSeen,
resultType);
// Update the existing record to point to the new item // Update the existing record to point to the new item
existing.JellyfinItemId = jellyfinItemId; existing.JellyfinItemId = jellyfinItemId;
@@ -360,12 +400,12 @@ public class ItemHistoryService : IDisposable
_knownItems.Delete(duplicate.Id); _knownItems.Delete(duplicate.Id);
} }
return true; return resultType;
} }
// Ensure the item is properly recorded (might have been missed at queue time due to missing metadata) // Ensure the item is properly recorded (might have been missed at queue time due to missing metadata)
RecordItem(item); RecordItem(item);
return false; return RevalidationResult.New;
} }
/// <summary> /// <summary>