fix: unknown series
All checks were successful
Create Release PR / Create Release PR (push) Successful in 59s

This commit is contained in:
2026-04-03 17:40:17 +02:00
parent cc2d02983f
commit b1444094ad
3 changed files with 89 additions and 124 deletions

View File

@@ -318,17 +318,23 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
if (item is Episode episode) if (item is Episode episode)
{ {
notification.SeriesName = episode.SeriesName; // episode.SeriesName / SeriesId are often null with Shokofin VFS,
notification.SeriesId = episode.SeriesId.ToString(); // 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.SeasonNumber = episode.ParentIndexNumber;
notification.EpisodeNumber = episode.IndexNumber; notification.EpisodeNumber = episode.IndexNumber;
notification.Year = episode.ProductionYear; notification.Year = episode.ProductionYear;
// Use series provider IDs for external links — episode provider IDs // Use series provider IDs for external links — episode provider IDs
// (e.g. AniDB episode ID) lead to wrong URLs when used with /anime/ paths // (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) if (series?.ProviderIds != null && series.ProviderIds.Count > 0)
{ {
notification.ProviderIdsJson = JsonSerializer.Serialize(series.ProviderIds); notification.ProviderIdsJson = JsonSerializer.Serialize(series.ProviderIds);
@@ -426,15 +432,22 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
if (string.IsNullOrEmpty(notification.SeriesName) || notification.SeriesName == "Unknown Series") 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; changed = true;
} }
if (string.IsNullOrEmpty(notification.SeriesId) || notification.SeriesId == Guid.Empty.ToString()) 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; changed = true;
} }
} }
@@ -568,16 +581,43 @@ public class SmartNotifyBackgroundService : IHostedService, IDisposable
.GroupBy(n => n.SeriesId!) .GroupBy(n => n.SeriesId!)
.ToList(); .ToList();
// Handle episodes that still have no series info (send individually as fallback) // Episodes without SeriesName or SeriesId must NEVER be sent.
var orphanEpisodes = pendingNotifications // Wait up to 30 minutes for metadata to resolve, then drop.
.Where(n => n.ItemType == "Episode" && (string.IsNullOrEmpty(n.SeriesId) || n.SeriesId == emptyGuid)) 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(); .ToList();
if (orphanEpisodes.Count > 0) if (incompleteEpisodes.Count > 0)
{ {
_logger.LogWarning( // Split into: still waiting vs. timed out
"{Count} episode notifications have no series info even after refresh", var timedOut = incompleteEpisodes.Where(n => n.QueuedAt < maxMetadataWait).ToList();
orphanEpisodes.Count); 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 // 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 // Process movies
var movies = pendingNotifications var movies = pendingNotifications
.Where(n => n.ItemType == "Movie") .Where(n => n.ItemType == "Movie")

View File

@@ -57,7 +57,7 @@ public class DiscordNotificationService
} }
var first = notificationList.First(); var first = notificationList.First();
var seriesName = first.SeriesName ?? "Unknown Series"; var seriesName = first.SeriesName!;
// Group by season // Group by season
var bySeason = notificationList var bySeason = notificationList

118
README.md
View File

@@ -1,115 +1,65 @@
# SmartNotify - Jellyfin Plugin # 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 - **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"*.
SmartNotify erkennt automatisch: - **Discord-Embeds** — Benachrichtigungen mit Bild, Beschreibung und direkten Links zu IMDb, TMDb, AniDB, AniList und TVDB.
- **Wirklich neue Inhalte** → Benachrichtigung wird gesendet - **Shokofin/VFS-kompatibel** — Die Erkennung basiert auf Provider-IDs statt Jellyfin-internen IDs, die sich bei VFS ständig ändern.
- **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
## Installation ## 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/` https://git.tdpi.dev/TDPI/jellyfin-plugin-smartnotify/raw/branch/main/manifest.json
3. Jellyfin neustarten ```
## Konfiguration Danach im **Katalog** SmartNotify installieren und Jellyfin neustarten.
Nach der Installation im Jellyfin Dashboard: ### Manuell
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
### 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 | | Einstellung | Standard | Beschreibung |
|-------------|----------|--------------| |-------------|----------|--------------|
| Discord Webhook URL | - | Die Webhook-URL deines Discord-Kanals | | Verzögerung | 5 min | Wartezeit damit Jellyfin Metadaten laden kann |
| Server URL | - | Öffentliche Jellyfin-URL (für Bilder) | | Gruppierungsfenster | 30 min | Wie lange auf weitere Episoden gewartet wird bevor gesendet wird |
| Verzögerung | 5 min | Wartezeit für Metadaten-Updates | | Upgrades unterdrücken | An | Keine Benachrichtigung wenn nur die Qualität besser wird |
| Gruppierungsfenster | 30 min | Zeitfenster für Episoden-Gruppierung | | Film-Benachrichtigungen | An | Benachrichtigungen für neue Filme |
| Upgrades unterdrücken | ✓ | Keine Benachrichtigung bei Ersetzungen | | 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 **Mehrere Episoden:**
```
📺 Demon Slayer
Neue Episoden hinzugefügt:
Staffel 1: Episode 5
```
### Mehrere Episoden (gruppiert)
``` ```
📺 Attack on Titan 📺 Attack on Titan
Neue Episoden hinzugefügt: Neue Episoden hinzugefügt:
Staffel 4: Episode 1-12 Staffel 4: Episode 1-12
``` ```
### Episoden mit Lücken **Neuer Film:**
```
📺 One Piece
Neue Episoden hinzugefügt:
Staffel 1: Episode 1-4, 6, 8-12
```
### Film
``` ```
🎬 Your Name (2016) 🎬 Your Name (2016)
Kimi no Na wa - Ein Junge und ein Mädchen... 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 ## Lizenz
MIT MIT