using System; using System.Collections.Generic; using System.IO; 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. /// True if the notification was sent successfully. 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 false; } var notificationList = notifications.ToList(); if (notificationList.Count == 0) { return false; } var first = notificationList.First(); var seriesName = first.SeriesName!; // 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}"; return await SendDiscordWebhookAsync( config, title, description, first.ImagePath, 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. /// True if the notification was sent successfully. 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 false; } 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) + "..."; } return await SendDiscordWebhookAsync( config, title, description, notification.ImagePath, 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, with optional image attachment. /// /// True if the webhook was sent successfully. private async Task SendDiscordWebhookAsync( PluginConfiguration config, string title, string description, string? imagePath, string externalLinks, CancellationToken cancellationToken) { var embed = new Dictionary { ["title"] = title, ["description"] = description, ["color"] = config.EmbedColor }; // If we have a local image file, reference it as attachment var hasImage = !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath); if (hasImage) { embed["thumbnail"] = new Dictionary { ["url"] = "attachment://poster.jpg" }; } if (!string.IsNullOrEmpty(externalLinks)) { var fields = new List> { new Dictionary { ["name"] = "Links", ["value"] = externalLinks, ["inline"] = false } }; embed["fields"] = fields; } embed["timestamp"] = DateTime.UtcNow.ToString("o"); var payload = new Dictionary { ["username"] = config.BotUsername, ["embeds"] = new[] { embed } }; var json = JsonSerializer.Serialize(payload); _logger.LogInformation("Sending Discord webhook for: {Title}", title); try { using var client = _httpClientFactory.CreateClient(); HttpResponseMessage response; 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"); var imageBytes = await File.ReadAllBytesAsync(imagePath!, cancellationToken); var imageContent = new ByteArrayContent(imageBytes); imageContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/jpeg"); form.Add(imageContent, "files[0]", "poster.jpg"); response = await client.PostAsync(config.DiscordWebhookUrl, form, cancellationToken); } else { // Send as plain JSON (no image) using var content = new StringContent(json, Encoding.UTF8, "application/json"); 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); return false; } _logger.LogInformation("Discord notification sent successfully: {Title}", title); return true; } catch (Exception ex) { _logger.LogError(ex, "Failed to send Discord webhook"); return false; } } }