Files
jellyfin-plugin-smartnotify/Jellyfin.Plugin.SmartNotify/Services/DiscordNotificationService.cs
TDPI b3304e61bf
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
fix:: Plugin Erste Tests
2026-03-01 16:01:26 +01:00

303 lines
9.4 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SmartNotify.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SmartNotify.Services;
/// <summary>
/// Service for sending Discord notifications with intelligent grouping.
/// </summary>
public class DiscordNotificationService
{
private readonly ILogger<DiscordNotificationService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="DiscordNotificationService"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
public DiscordNotificationService(
ILogger<DiscordNotificationService> logger,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Sends a grouped notification for multiple episodes of the same series.
/// </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(
IEnumerable<PendingNotification> notifications,
CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{
_logger.LogWarning("Discord webhook URL not configured");
return;
}
var notificationList = notifications.ToList();
if (notificationList.Count == 0)
{
return;
}
var first = notificationList.First();
var seriesName = first.SeriesName ?? "Unknown Series";
// Group by season
var bySeason = notificationList
.Where(n => n.SeasonNumber.HasValue && n.EpisodeNumber.HasValue)
.GroupBy(n => n.SeasonNumber!.Value)
.OrderBy(g => g.Key)
.ToList();
var episodeDescription = BuildEpisodeDescription(bySeason);
var title = $"📺 {seriesName}";
var description = $"Neue Episoden hinzugefügt:\n{episodeDescription}";
// Get image from first notification
var imageUrl = first.ImageUrl;
await SendDiscordWebhookAsync(
config,
title,
description,
imageUrl,
BuildExternalLinks(first.ProviderIdsJson),
cancellationToken);
}
/// <summary>
/// Builds an intelligent episode description like "Episode 1-12" or "Episode 1, 3, 5-7".
/// </summary>
private string BuildEpisodeDescription(List<IGrouping<int, PendingNotification>> bySeason)
{
var parts = new List<string>();
foreach (var seasonGroup in bySeason)
{
var season = seasonGroup.Key;
var episodes = seasonGroup
.Select(n => n.EpisodeNumber!.Value)
.Distinct()
.OrderBy(e => e)
.ToList();
var episodeRanges = BuildRangeString(episodes);
parts.Add($"**Staffel {season}:** Episode {episodeRanges}");
}
return string.Join("\n", parts);
}
/// <summary>
/// Builds a range string like "1-12" or "1, 3, 5-7, 10".
/// </summary>
private string BuildRangeString(List<int> numbers)
{
if (numbers.Count == 0)
{
return string.Empty;
}
if (numbers.Count == 1)
{
return numbers[0].ToString();
}
var ranges = new List<string>();
int rangeStart = numbers[0];
int rangeEnd = numbers[0];
for (int i = 1; i < numbers.Count; i++)
{
if (numbers[i] == rangeEnd + 1)
{
// Continue the range
rangeEnd = numbers[i];
}
else
{
// End current range, start new one
ranges.Add(FormatRange(rangeStart, rangeEnd));
rangeStart = numbers[i];
rangeEnd = numbers[i];
}
}
// Add the last range
ranges.Add(FormatRange(rangeStart, rangeEnd));
return string.Join(", ", ranges);
}
private string FormatRange(int start, int end)
{
return start == end ? start.ToString() : $"{start}-{end}";
}
/// <summary>
/// Sends a notification for a single movie.
/// </summary>
/// <param name="notification">The notification.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task SendMovieNotificationAsync(
PendingNotification notification,
CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrEmpty(config.DiscordWebhookUrl))
{
_logger.LogWarning("Discord webhook URL not configured");
return;
}
var title = $"🎬 {notification.Name}";
if (notification.Year.HasValue)
{
title += $" ({notification.Year})";
}
var description = notification.Overview ?? "Neuer Film hinzugefügt!";
if (description.Length > 300)
{
description = description.Substring(0, 297) + "...";
}
await SendDiscordWebhookAsync(
config,
title,
description,
notification.ImageUrl,
BuildExternalLinks(notification.ProviderIdsJson),
cancellationToken);
}
/// <summary>
/// Builds external links from provider IDs.
/// </summary>
private string BuildExternalLinks(string providerIdsJson)
{
try
{
var providerIds = JsonSerializer.Deserialize<Dictionary<string, string>>(providerIdsJson);
if (providerIds == null || providerIds.Count == 0)
{
return string.Empty;
}
var links = new List<string>();
if (providerIds.TryGetValue("Imdb", out var imdb) && !string.IsNullOrEmpty(imdb))
{
links.Add($"[IMDb](https://www.imdb.com/title/{imdb}/)");
}
if (providerIds.TryGetValue("Tmdb", out var tmdb) && !string.IsNullOrEmpty(tmdb))
{
links.Add($"[TMDb](https://www.themoviedb.org/movie/{tmdb})");
}
if (providerIds.TryGetValue("AniDB", out var anidb) && !string.IsNullOrEmpty(anidb))
{
links.Add($"[AniDB](https://anidb.net/anime/{anidb})");
}
if (providerIds.TryGetValue("AniList", out var anilist) && !string.IsNullOrEmpty(anilist))
{
links.Add($"[AniList](https://anilist.co/anime/{anilist})");
}
if (providerIds.TryGetValue("Tvdb", out var tvdb) && !string.IsNullOrEmpty(tvdb))
{
links.Add($"[TVDB](https://thetvdb.com/?id={tvdb}&tab=series)");
}
return links.Count > 0 ? string.Join(" | ", links) : string.Empty;
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Sends the actual Discord webhook request.
/// </summary>
private async Task SendDiscordWebhookAsync(
PluginConfiguration config,
string title,
string description,
string? imageUrl,
string externalLinks,
CancellationToken cancellationToken)
{
var embed = new Dictionary<string, object>
{
["title"] = title,
["description"] = description,
["color"] = config.EmbedColor
};
if (!string.IsNullOrEmpty(imageUrl))
{
embed["thumbnail"] = new Dictionary<string, string> { ["url"] = imageUrl };
}
if (!string.IsNullOrEmpty(externalLinks))
{
embed["footer"] = new Dictionary<string, string> { ["text"] = externalLinks };
}
embed["timestamp"] = DateTime.UtcNow.ToString("o");
var payload = new Dictionary<string, object>
{
["username"] = config.BotUsername,
["embeds"] = new[] { embed }
};
var json = JsonSerializer.Serialize(payload);
_logger.LogDebug("Sending Discord webhook: {Json}", json);
try
{
using var client = _httpClientFactory.CreateClient();
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync(config.DiscordWebhookUrl, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogError(
"Discord webhook failed with status {Status}: {Body}",
response.StatusCode,
responseBody);
}
else
{
_logger.LogInformation("Discord notification sent successfully: {Title}", title);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send Discord webhook");
}
}
}