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; /// /// Service for sending Discord notifications with intelligent grouping. /// public class DiscordNotificationService { private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; /// /// Initializes a new instance of the class. /// /// The logger. /// The HTTP client factory. public DiscordNotificationService( ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; _httpClientFactory = httpClientFactory; } /// /// Sends a grouped notification for multiple episodes of the same series. /// /// The notifications to group and send. /// The cancellation token. /// A task representing the async operation. public async Task SendGroupedEpisodeNotificationAsync( IEnumerable 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); } /// /// Builds an intelligent episode description like "Episode 1-12" or "Episode 1, 3, 5-7". /// private string BuildEpisodeDescription(List> bySeason) { var parts = new List(); 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); } /// /// Builds a range string like "1-12" or "1, 3, 5-7, 10". /// private string BuildRangeString(List numbers) { if (numbers.Count == 0) { return string.Empty; } if (numbers.Count == 1) { return numbers[0].ToString(); } var ranges = new List(); 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}"; } /// /// Sends a notification for a single movie. /// /// The notification. /// The cancellation token. 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); } /// /// Builds external links from provider IDs. /// private string BuildExternalLinks(string providerIdsJson) { try { var providerIds = JsonSerializer.Deserialize>(providerIdsJson); if (providerIds == null || providerIds.Count == 0) { return string.Empty; } var links = new List(); 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; } } /// /// Sends the actual Discord webhook request. /// private async Task SendDiscordWebhookAsync( PluginConfiguration config, string title, string description, string? imageUrl, string externalLinks, CancellationToken cancellationToken) { var embed = new Dictionary { ["title"] = title, ["description"] = description, ["color"] = config.EmbedColor }; if (!string.IsNullOrEmpty(imageUrl)) { embed["thumbnail"] = new Dictionary { ["url"] = imageUrl }; } if (!string.IsNullOrEmpty(externalLinks)) { embed["footer"] = new Dictionary { ["text"] = externalLinks }; } embed["timestamp"] = DateTime.UtcNow.ToString("o"); var payload = new Dictionary { ["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"); } } }