Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa52312228 | |||
|
|
07cd31097a | ||
| fe78850872 | |||
| f2903719a3 | |||
|
|
7fcc917ca5 | ||
| 06cbbec97b | |||
|
|
877cf59e44 | ||
| 895aafb987 | |||
|
|
3925e502ec |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.0.13"
|
||||
".": "0.0.15"
|
||||
}
|
||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## 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.15.0</AssemblyVersion>
|
||||
<FileVersion>0.0.15.0</FileVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
@@ -231,6 +231,76 @@ 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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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 +324,38 @@ 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);
|
||||
}
|
||||
|
||||
// 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 +365,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 +427,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");
|
||||
|
||||
@@ -295,6 +301,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,22 @@
|
||||
"category": "Notifications",
|
||||
"imageUrl": "",
|
||||
"versions": [
|
||||
|
||||
{
|
||||
"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