Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ff65d1546 | |||
|
|
bae22c908f | ||
| 27da8c90f7 | |||
| 96d67a8655 | |||
|
|
62a7547688 | ||
| 047dd82f3f | |||
|
|
76fb874b4d | ||
| 7fadc75c84 | |||
| d595b16573 | |||
| 20f603b4ee | |||
|
|
798cdcaf9c | ||
| af6ddeac0d | |||
| 87f1eff7df | |||
|
|
97a7bc5422 | ||
| f56cd701cb | |||
|
|
d6ebbda8ad | ||
| ec2ddf3728 | |||
| 3389eb254c | |||
|
|
b056e8a199 | ||
| fa52312228 | |||
|
|
07cd31097a | ||
| fe78850872 | |||
| f2903719a3 | |||
|
|
7fcc917ca5 | ||
| 06cbbec97b | |||
|
|
877cf59e44 | ||
| 895aafb987 | |||
|
|
3925e502ec |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.0.13"
|
||||
".": "0.0.19"
|
||||
}
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -1,5 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## 0.0.19 (2026-03-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix: debuging
|
||||
|
||||
### Chores
|
||||
|
||||
* chore: update manifest for v0.0.18
|
||||
|
||||
|
||||
## 0.0.18 (2026-03-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix: build error
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
* fix: timestamps!
|
||||
|
||||
### Chores
|
||||
|
||||
* chore: update manifest for v0.0.14
|
||||
|
||||
|
||||
## 0.0.14 (2026-03-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix: claude hat bugs im Kopf
|
||||
|
||||
### Chores
|
||||
|
||||
* chore: update manifest for v0.0.13
|
||||
|
||||
|
||||
## 0.0.13 (2026-03-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.SmartNotify</RootNamespace>
|
||||
<AssemblyVersion>0.0.13.0</AssemblyVersion>
|
||||
<FileVersion>0.0.13.0</FileVersion>
|
||||
<AssemblyVersion>0.0.19.0</AssemblyVersion>
|
||||
<FileVersion>0.0.19.0</FileVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
@@ -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,50 @@ 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;
|
||||
var alreadyKnown = 0;
|
||||
|
||||
foreach (var item in existingItems)
|
||||
{
|
||||
if (!_historyService.IsKnownItem(item.Id))
|
||||
{
|
||||
_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)
|
||||
{
|
||||
@@ -88,6 +138,68 @@ 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -128,6 +240,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,16 +324,32 @@ 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))
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -231,6 +370,112 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
_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>
|
||||
@@ -254,14 +499,75 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Processing {Count} pending notifications", pendingNotifications.Count);
|
||||
_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 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
|
||||
.Where(n => n.ItemType == "Episode" && !string.IsNullOrEmpty(n.SeriesId))
|
||||
.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)
|
||||
{
|
||||
@@ -271,19 +577,59 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
// 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}",
|
||||
_logger.LogInformation(
|
||||
"Waiting for grouping window for series {SeriesName} ({SeriesId}), oldest queued: {Oldest}, grouping cutoff: {Cutoff}",
|
||||
seriesGroup.First().SeriesName,
|
||||
seriesGroup.Key,
|
||||
oldestInGroup);
|
||||
oldestInGroup,
|
||||
groupingCutoff);
|
||||
continue;
|
||||
}
|
||||
|
||||
await _discordService.SendGroupedEpisodeNotificationAsync(
|
||||
_logger.LogInformation(
|
||||
"Sending grouped notification for {SeriesName}: {Count} episodes",
|
||||
seriesGroup.First().SeriesName,
|
||||
seriesGroup.Count());
|
||||
|
||||
var success = await _discordService.SendGroupedEpisodeNotificationAsync(
|
||||
seriesGroup,
|
||||
CancellationToken.None);
|
||||
|
||||
var idsToRemove = seriesGroup.Select(n => n.Id).ToList();
|
||||
_historyService.RemoveNotifications(idsToRemove);
|
||||
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
|
||||
@@ -293,8 +639,19 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
|
||||
|
||||
foreach (var movie in movies)
|
||||
{
|
||||
await _discordService.SendMovieNotificationAsync(movie, CancellationToken.None);
|
||||
_historyService.RemoveNotifications(new[] { movie.Id });
|
||||
_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)
|
||||
|
||||
@@ -38,8 +38,8 @@ public class DiscordNotificationService
|
||||
/// </summary>
|
||||
/// <param name="notifications">The notifications to group and send.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
public async Task SendGroupedEpisodeNotificationAsync(
|
||||
/// <returns>True if the notification was sent successfully.</returns>
|
||||
public async Task<bool> SendGroupedEpisodeNotificationAsync(
|
||||
IEnumerable<PendingNotification> notifications,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -47,13 +47,13 @@ public class DiscordNotificationService
|
||||
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
|
||||
{
|
||||
_logger.LogWarning("Discord webhook URL not configured");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var notificationList = notifications.ToList();
|
||||
if (notificationList.Count == 0)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var first = notificationList.First();
|
||||
@@ -70,7 +70,7 @@ public class DiscordNotificationService
|
||||
var title = $"📺 {seriesName}";
|
||||
var description = $"Neue Episoden hinzugefügt:\n{episodeDescription}";
|
||||
|
||||
await SendDiscordWebhookAsync(
|
||||
return await SendDiscordWebhookAsync(
|
||||
config,
|
||||
title,
|
||||
description,
|
||||
@@ -153,7 +153,8 @@ public class DiscordNotificationService
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public async Task SendMovieNotificationAsync(
|
||||
/// <returns>True if the notification was sent successfully.</returns>
|
||||
public async Task<bool> SendMovieNotificationAsync(
|
||||
PendingNotification notification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -161,7 +162,7 @@ public class DiscordNotificationService
|
||||
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
|
||||
{
|
||||
_logger.LogWarning("Discord webhook URL not configured");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var title = $"🎬 {notification.Name}";
|
||||
@@ -176,7 +177,7 @@ public class DiscordNotificationService
|
||||
description = description.Substring(0, 297) + "...";
|
||||
}
|
||||
|
||||
await SendDiscordWebhookAsync(
|
||||
return await SendDiscordWebhookAsync(
|
||||
config,
|
||||
title,
|
||||
description,
|
||||
@@ -236,7 +237,8 @@ public class DiscordNotificationService
|
||||
/// <summary>
|
||||
/// Sends the actual Discord webhook request, with optional image attachment.
|
||||
/// </summary>
|
||||
private async Task SendDiscordWebhookAsync(
|
||||
/// <returns>True if the webhook was sent successfully.</returns>
|
||||
private async Task<bool> SendDiscordWebhookAsync(
|
||||
PluginConfiguration config,
|
||||
string title,
|
||||
string description,
|
||||
@@ -260,7 +262,16 @@ public class DiscordNotificationService
|
||||
|
||||
if (!string.IsNullOrEmpty(externalLinks))
|
||||
{
|
||||
embed["footer"] = new Dictionary<string, string> { ["text"] = externalLinks };
|
||||
var fields = new List<Dictionary<string, object>>
|
||||
{
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["name"] = "Links",
|
||||
["value"] = externalLinks,
|
||||
["inline"] = false
|
||||
}
|
||||
};
|
||||
embed["fields"] = fields;
|
||||
}
|
||||
|
||||
embed["timestamp"] = DateTime.UtcNow.ToString("o");
|
||||
@@ -272,7 +283,7 @@ public class DiscordNotificationService
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
_logger.LogDebug("Sending Discord webhook: {Json}", json);
|
||||
_logger.LogInformation("Sending Discord webhook for: {Title}", title);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -281,6 +292,7 @@ public class DiscordNotificationService
|
||||
|
||||
if (hasImage)
|
||||
{
|
||||
_logger.LogInformation("Attaching image from: {Path}", imagePath);
|
||||
// Send as multipart/form-data with image attachment
|
||||
using var form = new MultipartFormDataContent();
|
||||
form.Add(new StringContent(json, Encoding.UTF8, "application/json"), "payload_json");
|
||||
@@ -306,15 +318,16 @@ public class DiscordNotificationService
|
||||
"Discord webhook failed with status {Status}: {Body}",
|
||||
response.StatusCode,
|
||||
responseBody);
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Discord notification sent successfully: {Title}", title);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Discord notification sent successfully: {Title}", title);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send Discord webhook");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,13 @@ public class ItemHistoryService : IDisposable
|
||||
var dbPath = Path.Combine(pluginDataPath, "smartnotify.db");
|
||||
_logger.LogInformation("SmartNotify database path: {Path}", dbPath);
|
||||
|
||||
_database = new LiteDatabase(dbPath);
|
||||
// Use UTC for all DateTime to avoid container timezone vs Jellyfin time mismatches
|
||||
var mapper = new BsonMapper { SerializeNullValues = false };
|
||||
mapper.RegisterType<DateTime>(
|
||||
serialize: value => new BsonValue(value.ToUniversalTime()),
|
||||
deserialize: bson => bson.AsDateTime.ToUniversalTime());
|
||||
|
||||
_database = new LiteDatabase(dbPath, mapper);
|
||||
_knownItems = _database.GetCollection<KnownMediaItem>("known_items");
|
||||
_pendingNotifications = _database.GetCollection<PendingNotification>("pending_notifications");
|
||||
|
||||
@@ -58,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";
|
||||
}
|
||||
|
||||
@@ -78,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;
|
||||
}
|
||||
|
||||
@@ -201,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,
|
||||
@@ -263,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>
|
||||
@@ -295,6 +374,15 @@ public class ItemHistoryService : IDisposable
|
||||
return _pendingNotifications.Find(x => x.QueuedAt <= olderThan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing notification in the queue (e.g. after metadata refresh).
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification to update.</param>
|
||||
public void UpdateNotification(PendingNotification notification)
|
||||
{
|
||||
_pendingNotifications.Update(notification);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes processed notifications.
|
||||
/// </summary>
|
||||
|
||||
@@ -8,7 +8,46 @@
|
||||
"category": "Notifications",
|
||||
"imageUrl": "",
|
||||
"versions": [
|
||||
|
||||
{
|
||||
"version": "0.0.18.0",
|
||||
"changelog": "### Bug Fixes\n\n* fix: build error",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.18/smartnotify_0.0.18.zip",
|
||||
"checksum": "4b5857ce309974f8e64ab81885d9b67d",
|
||||
"timestamp": "2026-03-03T13:53:53Z"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.14/smartnotify_0.0.14.zip",
|
||||
"checksum": "428e0cc3a00c873381dc2f2f198f3907",
|
||||
"timestamp": "2026-03-01T17:36:26Z"
|
||||
},
|
||||
{
|
||||
"version": "0.0.13.0",
|
||||
"changelog": "### Bug Fixes\n\n* fix: removed drecks versionen\n* fix: Bilder und dumme min TImings!\n\n### Chores\n\n* chore: update manifest for v0.0.12",
|
||||
"targetAbi": "10.11.0.0",
|
||||
"sourceUrl": "https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases/download/v0.0.13/smartnotify_0.0.13.zip",
|
||||
"checksum": "2d303e8dc214a58e038f516076840d5b",
|
||||
"timestamp": "2026-03-01T17:21:50Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user