12 Commits

Author SHA1 Message Date
7fadc75c84 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
2026-03-03 14:51:20 +01:00
d595b16573 fix: build error 2026-03-03 14:51:18 +01:00
20f603b4ee Merge pull request 'chore(main): release 0.0.17' (#18) from release-please--branches--main into main
Some checks failed
Create Release PR / Create Release PR (push) Has been skipped
Build and Publish Plugin / Build Plugin + Update Manifest (release) Failing after 43s
Reviewed-on: #18
2026-03-03 12:42:15 +01:00
Gitea Actions
798cdcaf9c chore(main): release 0.0.17
All checks were successful
Create Release / Publish Release (pull_request) Successful in 6s
2026-03-03 11:41:58 +00:00
af6ddeac0d Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
2026-03-03 12:41:36 +01:00
87f1eff7df fix: removed notifications on reorganization 2026-03-03 12:41:34 +01:00
Gitea Actions
97a7bc5422 chore: update manifest for v0.0.16
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-02 18:58:41 +00:00
f56cd701cb Merge pull request 'chore(main): release 0.0.16' (#17) from release-please--branches--main into main
All checks were successful
Build and Publish Plugin / Build Plugin + Update Manifest (release) Successful in 50s
Create Release PR / Create Release PR (push) Has been skipped
Reviewed-on: #17
2026-03-02 19:57:42 +01:00
Gitea Actions
d6ebbda8ad chore(main): release 0.0.16
All checks were successful
Create Release / Publish Release (pull_request) Successful in 7s
2026-03-02 18:57:28 +00:00
ec2ddf3728 Merge branch 'main' of https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify
All checks were successful
Create Release PR / Create Release PR (push) Successful in 16s
2026-03-02 19:57:09 +01:00
3389eb254c fix: ist das wirklich ein Fix und kein defix? 2026-03-02 19:57:07 +01:00
Gitea Actions
b056e8a199 chore: update manifest for v0.0.15
All checks were successful
Create Release PR / Create Release PR (push) Has been skipped
2026-03-01 17:49:47 +00:00
6 changed files with 243 additions and 26 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.0.15"
".": "0.0.17"
}

View File

@@ -1,5 +1,27 @@
# Changelog
## 0.0.17 (2026-03-03)
### Bug Fixes
* fix: removed notifications on reorganization
### Chores
* chore: update manifest for v0.0.16
## 0.0.16 (2026-03-02)
### Bug Fixes
* fix: ist das wirklich ein Fix und kein defix?
### Chores
* chore: update manifest for v0.0.15
## 0.0.15 (2026-03-01)
### Bug Fixes

View File

@@ -3,8 +3,8 @@
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.SmartNotify</RootNamespace>
<AssemblyVersion>0.0.15.0</AssemblyVersion>
<FileVersion>0.0.15.0</FileVersion>
<AssemblyVersion>0.0.17.0</AssemblyVersion>
<FileVersion>0.0.17.0</FileVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>

View File

@@ -1,8 +1,10 @@
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;
@@ -47,6 +49,10 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
{
_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;
@@ -62,6 +68,44 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
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;
foreach (var item in existingItems)
{
if (!_historyService.IsKnownItem(item.Id))
{
_historyService.RecordItem(item);
seeded++;
}
}
_logger.LogInformation(
"Seeded {Count} existing library items into SmartNotify DB (total in library: {Total})",
seeded,
existingItems.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error seeding existing library items");
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
@@ -88,6 +132,14 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
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;
}
_logger.LogDebug("Item added: {Name} (Type: {Type}, ID: {Id})", item.Name, item.GetType().Name, item.Id);
var config = Plugin.Instance?.Configuration;
@@ -128,6 +180,17 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
return;
}
// Check 0: Is this exact item (same Jellyfin ID) already known in our DB?
if (_historyService.IsKnownItem(item.Id))
{
_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);
@@ -201,10 +264,16 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
notification.EpisodeNumber = episode.IndexNumber;
notification.Year = episode.ProductionYear;
// Use series image if episode doesn't have its own
// 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))
{
@@ -337,6 +406,43 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
RefreshNotificationMetadata(notification);
}
// Late upgrade 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 upgrades
// to go undetected. By now (after delay + grouping window), metadata is ready.
if (config.SuppressUpgrades)
{
var suppressedIds = new List<int>();
foreach (var notification in pendingNotifications)
{
if (Guid.TryParse(notification.JellyfinItemId, out var revalidateId))
{
var revalidateItem = _libraryManager.GetItemById(revalidateId);
if (_historyService.RevalidatePendingItem(notification.JellyfinItemId, revalidateItem))
{
suppressedIds.Add(notification.Id);
_logger.LogInformation(
"Late suppression: {Name} detected as upgrade at send time",
notification.Name);
}
}
}
if (suppressedIds.Count > 0)
{
_historyService.RemoveNotifications(suppressedIds);
pendingNotifications.RemoveAll(n => suppressedIds.Contains(n.Id));
_logger.LogInformation(
"Suppressed {Count} upgrade notifications at send time",
suppressedIds.Count);
}
if (pendingNotifications.Count == 0)
{
return;
}
}
// Group episodes by series
var emptyGuid = Guid.Empty.ToString();
var episodesBySeries = pendingNotifications

View File

@@ -64,18 +64,25 @@ public class ItemHistoryService : IDisposable
{
if (item is Episode episode)
{
// For episodes: use series provider IDs + season + episode number
// Prefer episode's own provider ID (e.g. AniDB episode ID).
// This is stable even when seasons are reorganized in Jellyfin.
var episodeKey = GetProviderKey(episode.ProviderIds);
if (!string.IsNullOrEmpty(episodeKey))
{
return $"episode|{episodeKey}";
}
// Fallback: series provider key + season + episode number
var series = episode.Series;
if (series == null)
{
_logger.LogDebug("Episode {Name} has no series, cannot generate content key", episode.Name);
_logger.LogDebug("Episode {Name} has no series and no provider IDs, cannot generate content key", episode.Name);
return null;
}
var seriesKey = GetProviderKey(series.ProviderIds);
if (string.IsNullOrEmpty(seriesKey))
{
// Fallback to series name if no provider IDs
seriesKey = series.Name?.ToLowerInvariant().Trim() ?? "unknown";
}
@@ -84,7 +91,7 @@ public class ItemHistoryService : IDisposable
if (episodeNum == 0)
{
_logger.LogDebug("Episode {Name} has no episode number", episode.Name);
_logger.LogDebug("Episode {Name} has no episode number and no provider IDs", episode.Name);
return null;
}
@@ -207,48 +214,64 @@ public class ItemHistoryService : IDisposable
return false;
}
/// <summary>
/// Checks if an item with the given Jellyfin ID is already tracked in the database.
/// </summary>
/// <param name="itemId">The Jellyfin item ID.</param>
/// <returns>True if the item is already known.</returns>
public bool IsKnownItem(Guid itemId)
{
var jellyfinId = itemId.ToString();
return _knownItems.Exists(x => x.JellyfinItemId == jellyfinId);
}
/// <summary>
/// Records a known item in the database.
/// Always records the JellyfinItemId even if metadata is not yet available,
/// using a placeholder ContentKey that gets updated later.
/// </summary>
/// <param name="item">The item to record.</param>
public void RecordItem(BaseItem item)
{
var contentKey = GenerateContentKey(item);
if (contentKey == null)
{
return;
}
var jellyfinId = item.Id.ToString();
// Check if we already have this exact Jellyfin item
var existing = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinId);
if (existing != null)
{
// Update the record
existing.ContentKey = contentKey;
// Update the record — only update ContentKey if we have a real one
if (contentKey != null)
{
existing.ContentKey = contentKey;
}
existing.FilePath = item.Path;
existing.Name = item.Name;
_knownItems.Update(existing);
return;
}
// Check if we have this content key already (upgrade scenario)
var byContentKey = _knownItems.FindOne(x => x.ContentKey == contentKey);
if (byContentKey != null)
if (contentKey != null)
{
// Content exists, this is an upgrade - update the record
byContentKey.JellyfinItemId = jellyfinId;
byContentKey.FilePath = item.Path;
_knownItems.Update(byContentKey);
_logger.LogDebug("Updated existing content record for {Key} with new file", contentKey);
return;
var byContentKey = _knownItems.FindOne(x => x.ContentKey == contentKey);
if (byContentKey != null)
{
// Content exists, this is an upgrade - update the record
byContentKey.JellyfinItemId = jellyfinId;
byContentKey.FilePath = item.Path;
_knownItems.Update(byContentKey);
_logger.LogDebug("Updated existing content record for {Key} with new file", contentKey);
return;
}
}
// New content - create record
// New content - create record (use placeholder key if metadata not yet available)
var record = new KnownMediaItem
{
JellyfinItemId = jellyfinId,
ContentKey = contentKey,
ContentKey = contentKey ?? $"unresolved|{jellyfinId}",
ItemType = item.GetType().Name,
Name = item.Name,
FirstSeen = DateTime.UtcNow,
@@ -269,7 +292,57 @@ public class ItemHistoryService : IDisposable
}
_knownItems.Insert(record);
_logger.LogDebug("Recorded new content: {Key}", contentKey);
_logger.LogDebug("Recorded new content: {Key}", record.ContentKey);
}
/// <summary>
/// Re-checks if a pending notification is actually a quality upgrade.
/// Called at send time when metadata is fully populated.
/// </summary>
/// <param name="jellyfinItemId">The Jellyfin item ID string.</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>
public bool RevalidatePendingItem(string jellyfinItemId, BaseItem? item)
{
if (item == null)
{
return false;
}
var contentKey = GenerateContentKey(item);
if (contentKey == null)
{
return false;
}
// Check if this content was already known under a different item ID
var existing = _knownItems.FindOne(x => x.ContentKey == contentKey && x.JellyfinItemId != jellyfinItemId);
if (existing != null)
{
_logger.LogInformation(
"Late upgrade detection for {Name}: content key {Key} already known (first seen: {FirstSeen})",
item.Name,
contentKey,
existing.FirstSeen);
// Update the existing record to point to the new item
existing.JellyfinItemId = jellyfinItemId;
existing.FilePath = item.Path;
_knownItems.Update(existing);
// Clean up any duplicate record that was created for this item
var duplicate = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinItemId && x.Id != existing.Id);
if (duplicate != null)
{
_knownItems.Delete(duplicate.Id);
}
return true;
}
// Ensure the item is properly recorded (might have been missed at queue time due to missing metadata)
RecordItem(item);
return false;
}
/// <summary>

View File

@@ -8,6 +8,22 @@
"category": "Notifications",
"imageUrl": "",
"versions": [
{
"version": "0.0.16.0",
"changelog": "### Bug Fixes\n\n* fix: ist das wirklich ein Fix und kein defix?\n\n### Chores\n\n* chore: update manifest for v0.0.15",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.16/smartnotify_0.0.16.zip",
"checksum": "fbe2b3ce339c92204961df605bfe276b",
"timestamp": "2026-03-02T18:58:41Z"
},
{
"version": "0.0.15.0",
"changelog": "### Bug Fixes\n\n* fix: timestamps!\n\n### Chores\n\n* chore: update manifest for v0.0.14",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.15/smartnotify_0.0.15.zip",
"checksum": "c3dc638240b5688de030f77eccaf9c50",
"timestamp": "2026-03-01T17:49:47Z"
},
{
"version": "0.0.14.0",
"changelog": "### Bug Fixes\n\n* fix: claude hat bugs im Kopf\n\n### Chores\n\n* chore: update manifest for v0.0.13",