From b1444094ad0895fdb6394a5d8256d4f1eb51afe0 Mon Sep 17 00:00:00 2001 From: TDPI Date: Fri, 3 Apr 2026 17:40:17 +0200 Subject: [PATCH] fix: unknown series --- .../Notifiers/SmartNotifyBackgroundService.cs | 93 ++++++++------ .../Services/DiscordNotificationService.cs | 2 +- README.md | 118 +++++------------- 3 files changed, 89 insertions(+), 124 deletions(-) diff --git a/Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs b/Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs index 2bc5512..0fb70c0 100644 --- a/Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs +++ b/Jellyfin.Plugin.SmartNotify/Notifiers/SmartNotifyBackgroundService.cs @@ -318,17 +318,23 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable if (item is Episode episode) { - notification.SeriesName = episode.SeriesName; - notification.SeriesId = episode.SeriesId.ToString(); + // 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 - if (episode.SeriesId != Guid.Empty) + var resolvedSeriesId = notification.SeriesId; + if (resolvedSeriesId != Guid.Empty.ToString() && Guid.TryParse(resolvedSeriesId, out var seriesGuid)) { - var series = _libraryManager.GetItemById(episode.SeriesId); + var series = seriesObj ?? _libraryManager.GetItemById(seriesGuid); if (series?.ProviderIds != null && series.ProviderIds.Count > 0) { notification.ProviderIdsJson = JsonSerializer.Serialize(series.ProviderIds); @@ -426,15 +432,22 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable if (string.IsNullOrEmpty(notification.SeriesName) || notification.SeriesName == "Unknown Series") { - notification.SeriesName = episode.SeriesName; + // 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()) { - if (episode.SeriesId != Guid.Empty) + // 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 = episode.SeriesId.ToString(); + notification.SeriesId = resolvedSeriesId.ToString(); changed = true; } } @@ -568,16 +581,43 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable .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)) + // 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 (orphanEpisodes.Count > 0) + if (incompleteEpisodes.Count > 0) { - _logger.LogWarning( - "{Count} episode notifications have no series info even after refresh", - orphanEpisodes.Count); + // 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 @@ -619,31 +659,6 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable } } - // 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") diff --git a/Jellyfin.Plugin.SmartNotify/Services/DiscordNotificationService.cs b/Jellyfin.Plugin.SmartNotify/Services/DiscordNotificationService.cs index 5eaf34d..6ee3b20 100644 --- a/Jellyfin.Plugin.SmartNotify/Services/DiscordNotificationService.cs +++ b/Jellyfin.Plugin.SmartNotify/Services/DiscordNotificationService.cs @@ -57,7 +57,7 @@ public class DiscordNotificationService } var first = notificationList.First(); - var seriesName = first.SeriesName ?? "Unknown Series"; + var seriesName = first.SeriesName!; // Group by season var bySeason = notificationList diff --git a/README.md b/README.md index 7c2c8fd..9031643 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,65 @@ # SmartNotify - Jellyfin Plugin -**Intelligente Discord-Benachrichtigungen für Jellyfin 10.11+** +**Discord-Benachrichtigungen für neue Filme und Episoden in Jellyfin 10.11+** -## Das Problem +Kennt das Problem: Du tauschst eine Serie gegen bessere Qualität aus und Jellyfin meldet jede Folge als "neu". SmartNotify erkennt den Unterschied zwischen wirklich neuen Inhalten und Qualitäts-Upgrades — und nervt dich nicht mit Spam. -Wenn du eine Serie gegen eine bessere Qualität austauschst, schreit Jellyfin "NEUE EPISODE!" - obwohl es nur ein Upgrade ist. Das nervt. +## Was kann es? -## Die Lösung - -SmartNotify erkennt automatisch: -- **Wirklich neue Inhalte** → Benachrichtigung wird gesendet -- **Qualitäts-Upgrades** → Keine Benachrichtigung (oder optional eigener Typ) - -Zusätzlich gruppiert SmartNotify Episoden intelligent: -- Statt 12 einzelner Nachrichten: **"Staffel 1: Episode 1-12 hinzugefügt"** -- Bei Lücken: **"Episode 1-4, 6, 8-12"** - -## Features - -- ✅ Erkennt Qualitäts-Upgrades automatisch -- ✅ Intelligente Episoden-Gruppierung -- ✅ Unterstützt AniDB, AniList, TMDB, TVDB, IMDB -- ✅ Konfigurierbare Verzögerung (für Metadaten-Updates) -- ✅ Konfigurierbare Gruppierungsfenster -- ✅ Discord Webhook Support -- ✅ Deutsche Oberfläche - -## Wie funktioniert die Erkennung? - -SmartNotify führt eine lokale Datenbank mit allen bekannten Inhalten: - -1. **Neues Item kommt rein** -2. SmartNotify generiert einen "Content Key" basierend auf: - - Bei Episoden: Serie (Provider-IDs) + Staffel + Episode - - Bei Filmen: Provider-IDs (AniDB, TMDB, etc.) -3. **Prüfung 1:** Existiert dieser Content Key schon in der DB? → Upgrade! -4. **Prüfung 2:** Gibt es gerade ein anderes Item mit gleichem Content Key? → Duplikat (neues File vor Löschung des alten) -5. Nur wenn beide Prüfungen negativ sind → Benachrichtigung +- **Upgrade-Erkennung** — Erkennt ob eine Datei neu ist oder nur ein Qualitäts-Upgrade. Keine falschen Benachrichtigungen mehr beim Austausch von Dateien. +- **Episoden-Gruppierung** — Statt 12 einzelner Nachrichten bekommst du eine: *"Staffel 1: Episode 1-12"*. Bei Lücken entsprechend: *"Episode 1-4, 6, 8-12"*. +- **Discord-Embeds** — Benachrichtigungen mit Bild, Beschreibung und direkten Links zu IMDb, TMDb, AniDB, AniList und TVDB. +- **Shokofin/VFS-kompatibel** — Die Erkennung basiert auf Provider-IDs statt Jellyfin-internen IDs, die sich bei VFS ständig ändern. ## Installation -## Manuelle Installation +In Jellyfin unter **Dashboard** > **Plugins** > **Repositories** die folgende URL als Repository hinzufügen: -1. Plugin von Releases herunterladen -2. ZIP entpacken in `plugins/SmartNotify/` -3. Jellyfin neustarten +``` +https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/raw/branch/main/manifest.json +``` -## Konfiguration +Danach im **Katalog** SmartNotify installieren und Jellyfin neustarten. -Nach der Installation im Jellyfin Dashboard: -1. **Dashboard** → **Plugins** → **SmartNotify** -2. Discord Webhook URL eintragen -3. Server URL eintragen (für Bilder in Discord) -4. Optional: Verzögerung und Gruppierungsfenster anpassen +### Manuell -### Einstellungen +Plugin-ZIP von den [Releases](https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/releases) herunterladen, nach `plugins/SmartNotify/` entpacken und Jellyfin neustarten. + +## Einrichtung + +Nach der Installation unter **Dashboard** > **Plugins** > **SmartNotify**: + +1. Discord Webhook URL eintragen +2. Server URL eintragen (wird für Bilder in Discord gebraucht) +3. Fertig — Standardeinstellungen passen für die meisten Setups + +### Optionale Einstellungen | Einstellung | Standard | Beschreibung | |-------------|----------|--------------| -| Discord Webhook URL | - | Die Webhook-URL deines Discord-Kanals | -| Server URL | - | Öffentliche Jellyfin-URL (für Bilder) | -| Verzögerung | 5 min | Wartezeit für Metadaten-Updates | -| Gruppierungsfenster | 30 min | Zeitfenster für Episoden-Gruppierung | -| Upgrades unterdrücken | ✓ | Keine Benachrichtigung bei Ersetzungen | +| Verzögerung | 5 min | Wartezeit damit Jellyfin Metadaten laden kann | +| Gruppierungsfenster | 30 min | Wie lange auf weitere Episoden gewartet wird bevor gesendet wird | +| Upgrades unterdrücken | An | Keine Benachrichtigung wenn nur die Qualität besser wird | +| Film-Benachrichtigungen | An | Benachrichtigungen für neue Filme | +| Episoden-Benachrichtigungen | An | Benachrichtigungen für neue Episoden | +| Bot-Name | Jellyfin SmartNotify | Anzeigename in Discord | +| Embed-Farbe | Blau | Farbe des Discord-Embeds | -## Beispiel-Benachrichtigungen +## Beispiele -### Einzelne Episode -``` -📺 Demon Slayer -Neue Episoden hinzugefügt: -Staffel 1: Episode 5 -``` - -### Mehrere Episoden (gruppiert) +**Mehrere Episoden:** ``` 📺 Attack on Titan Neue Episoden hinzugefügt: Staffel 4: Episode 1-12 ``` -### Episoden mit Lücken -``` -📺 One Piece -Neue Episoden hinzugefügt: -Staffel 1: Episode 1-4, 6, 8-12 -``` - -### Film +**Neuer Film:** ``` 🎬 Your Name (2016) Kimi no Na wa - Ein Junge und ein Mädchen... ``` -## Bekannte Einschränkungen - -- Provider-IDs müssen vorhanden sein (AniDB, TMDB, etc.) -- Bei Items ohne Provider-IDs wird auf Name+Jahr zurückgefallen -- Die Datenbank wächst mit der Zeit (kann bei Bedarf gelöscht werden) - -## Installation via Repository - -In Jellyfin: -1. **Dashboard** → **Plugins** → **Repositories** → **+** -2. URL einfügen: -``` -https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/raw/branch/main/manifest.json -``` -3. **Katalog** → SmartNotify installieren -4. Jellyfin neustarten - ## Lizenz MIT