All checks were successful
Create Release PR / Create Release PR (push) Successful in 59s
334 lines
11 KiB
C#
334 lines
11 KiB
C#
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;
|
|
|
|
/// <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>True if the notification was sent successfully.</returns>
|
|
public async Task<bool> 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 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);
|
|
}
|
|
|
|
/// <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>
|
|
/// <returns>True if the notification was sent successfully.</returns>
|
|
public async Task<bool> 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);
|
|
}
|
|
|
|
/// <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, with optional image attachment.
|
|
/// </summary>
|
|
/// <returns>True if the webhook was sent successfully.</returns>
|
|
private async Task<bool> SendDiscordWebhookAsync(
|
|
PluginConfiguration config,
|
|
string title,
|
|
string description,
|
|
string? imagePath,
|
|
string externalLinks,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var embed = new Dictionary<string, object>
|
|
{
|
|
["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<string, string> { ["url"] = "attachment://poster.jpg" };
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(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");
|
|
|
|
var payload = new Dictionary<string, object>
|
|
{
|
|
["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;
|
|
}
|
|
}
|
|
}
|