fix:: Plugin Erste Tests
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
All checks were successful
Create Release PR / Create Release PR (push) Successful in 17s
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
329
Jellyfin.Plugin.SmartNotify/Services/ItemHistoryService.cs
Normal file
329
Jellyfin.Plugin.SmartNotify/Services/ItemHistoryService.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using LiteDB;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SmartNotify.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing the local database of known media items.
|
||||
/// </summary>
|
||||
public class ItemHistoryService : IDisposable
|
||||
{
|
||||
private readonly ILogger<ItemHistoryService> _logger;
|
||||
private readonly LiteDatabase _database;
|
||||
private readonly ILiteCollection<KnownMediaItem> _knownItems;
|
||||
private readonly ILiteCollection<PendingNotification> _pendingNotifications;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ItemHistoryService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">The application paths.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public ItemHistoryService(
|
||||
IApplicationPaths applicationPaths,
|
||||
ILogger<ItemHistoryService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var pluginDataPath = Path.Combine(applicationPaths.PluginConfigurationsPath, "SmartNotify");
|
||||
Directory.CreateDirectory(pluginDataPath);
|
||||
|
||||
var dbPath = Path.Combine(pluginDataPath, "smartnotify.db");
|
||||
_logger.LogInformation("SmartNotify database path: {Path}", dbPath);
|
||||
|
||||
_database = new LiteDatabase(dbPath);
|
||||
_knownItems = _database.GetCollection<KnownMediaItem>("known_items");
|
||||
_pendingNotifications = _database.GetCollection<PendingNotification>("pending_notifications");
|
||||
|
||||
// Create indexes for efficient queries
|
||||
_knownItems.EnsureIndex(x => x.ContentKey);
|
||||
_knownItems.EnsureIndex(x => x.JellyfinItemId);
|
||||
_pendingNotifications.EnsureIndex(x => x.SeriesId);
|
||||
_pendingNotifications.EnsureIndex(x => x.QueuedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a content key for uniquely identifying media content (regardless of file).
|
||||
/// </summary>
|
||||
/// <param name="item">The base item.</param>
|
||||
/// <returns>A unique content key, or null if insufficient metadata.</returns>
|
||||
public string? GenerateContentKey(BaseItem item)
|
||||
{
|
||||
if (item is Episode episode)
|
||||
{
|
||||
// For episodes: use series provider IDs + season + episode number
|
||||
var series = episode.Series;
|
||||
if (series == null)
|
||||
{
|
||||
_logger.LogDebug("Episode {Name} has no series, cannot generate content key", episode.Name);
|
||||
return null;
|
||||
}
|
||||
|
||||
var seriesKey = GetProviderKey(series.ProviderIds);
|
||||
if (string.IsNullOrEmpty(seriesKey))
|
||||
{
|
||||
// Fallback to series name if no provider IDs
|
||||
seriesKey = series.Name?.ToLowerInvariant().Trim() ?? "unknown";
|
||||
}
|
||||
|
||||
var seasonNum = episode.ParentIndexNumber ?? 0;
|
||||
var episodeNum = episode.IndexNumber ?? 0;
|
||||
|
||||
if (episodeNum == 0)
|
||||
{
|
||||
_logger.LogDebug("Episode {Name} has no episode number", episode.Name);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"episode|{seriesKey}|S{seasonNum:D2}E{episodeNum:D3}";
|
||||
}
|
||||
|
||||
if (item is Movie movie)
|
||||
{
|
||||
// For movies: use provider IDs
|
||||
var movieKey = GetProviderKey(movie.ProviderIds);
|
||||
if (string.IsNullOrEmpty(movieKey))
|
||||
{
|
||||
// Fallback to name + year
|
||||
var year = movie.ProductionYear ?? 0;
|
||||
movieKey = $"{movie.Name?.ToLowerInvariant().Trim()}|{year}";
|
||||
}
|
||||
|
||||
return $"movie|{movieKey}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Item {Name} is not an Episode or Movie, type: {Type}", item.Name, item.GetType().Name);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a consistent provider key from provider IDs.
|
||||
/// </summary>
|
||||
private string? GetProviderKey(Dictionary<string, string>? providerIds)
|
||||
{
|
||||
if (providerIds == null || providerIds.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Priority order for provider IDs
|
||||
string[] priorityOrder = { "AniDB", "AniList", "Tmdb", "Tvdb", "Imdb" };
|
||||
|
||||
foreach (var provider in priorityOrder)
|
||||
{
|
||||
if (providerIds.TryGetValue(provider, out var id) && !string.IsNullOrEmpty(id))
|
||||
{
|
||||
return $"{provider.ToLowerInvariant()}:{id}";
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use first available
|
||||
foreach (var kvp in providerIds)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(kvp.Value))
|
||||
{
|
||||
return $"{kvp.Key.ToLowerInvariant()}:{kvp.Value}";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an item is a quality upgrade (content already known).
|
||||
/// </summary>
|
||||
/// <param name="item">The item to check.</param>
|
||||
/// <returns>True if this is an upgrade of existing content.</returns>
|
||||
public bool IsQualityUpgrade(BaseItem item)
|
||||
{
|
||||
var contentKey = GenerateContentKey(item);
|
||||
if (contentKey == null)
|
||||
{
|
||||
_logger.LogDebug("Could not generate content key for {Name}, treating as new", item.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
var existing = _knownItems.FindOne(x => x.ContentKey == contentKey);
|
||||
if (existing != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Item {Name} is a quality upgrade (content key: {Key}, first seen: {FirstSeen})",
|
||||
item.Name,
|
||||
contentKey,
|
||||
existing.FirstSeen);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there's another item with the same content currently in the library.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to check.</param>
|
||||
/// <param name="libraryManager">The library manager to query current items.</param>
|
||||
/// <returns>True if duplicate content exists.</returns>
|
||||
public bool HasDuplicateContent(BaseItem item, MediaBrowser.Controller.Library.ILibraryManager libraryManager)
|
||||
{
|
||||
var contentKey = GenerateContentKey(item);
|
||||
if (contentKey == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we have another item with the same content key but different Jellyfin ID
|
||||
var existing = _knownItems.FindOne(x =>
|
||||
x.ContentKey == contentKey &&
|
||||
x.JellyfinItemId != item.Id.ToString());
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
// Verify the other item still exists in the library
|
||||
var otherId = Guid.Parse(existing.JellyfinItemId);
|
||||
var otherItem = libraryManager.GetItemById(otherId);
|
||||
if (otherItem != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Item {Name} has duplicate content already in library: {OtherName}",
|
||||
item.Name,
|
||||
otherItem.Name);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a known item in the database.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to record.</param>
|
||||
public void RecordItem(BaseItem item)
|
||||
{
|
||||
var contentKey = GenerateContentKey(item);
|
||||
if (contentKey == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var jellyfinId = item.Id.ToString();
|
||||
|
||||
// Check if we already have this exact Jellyfin item
|
||||
var existing = _knownItems.FindOne(x => x.JellyfinItemId == jellyfinId);
|
||||
if (existing != null)
|
||||
{
|
||||
// Update the record
|
||||
existing.ContentKey = contentKey;
|
||||
existing.FilePath = item.Path;
|
||||
_knownItems.Update(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have this content key already (upgrade scenario)
|
||||
var byContentKey = _knownItems.FindOne(x => x.ContentKey == contentKey);
|
||||
if (byContentKey != null)
|
||||
{
|
||||
// Content exists, this is an upgrade - update the record
|
||||
byContentKey.JellyfinItemId = jellyfinId;
|
||||
byContentKey.FilePath = item.Path;
|
||||
_knownItems.Update(byContentKey);
|
||||
_logger.LogDebug("Updated existing content record for {Key} with new file", contentKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// New content - create record
|
||||
var record = new KnownMediaItem
|
||||
{
|
||||
JellyfinItemId = jellyfinId,
|
||||
ContentKey = contentKey,
|
||||
ItemType = item.GetType().Name,
|
||||
Name = item.Name,
|
||||
FirstSeen = DateTime.UtcNow,
|
||||
FilePath = item.Path,
|
||||
ProviderIdsJson = JsonSerializer.Serialize(item.ProviderIds ?? new Dictionary<string, string>())
|
||||
};
|
||||
|
||||
if (item is Episode ep)
|
||||
{
|
||||
record.SeriesName = ep.SeriesName;
|
||||
record.SeasonNumber = ep.ParentIndexNumber;
|
||||
record.EpisodeNumber = ep.IndexNumber;
|
||||
record.Year = ep.ProductionYear;
|
||||
}
|
||||
else if (item is Movie movie)
|
||||
{
|
||||
record.Year = movie.ProductionYear;
|
||||
}
|
||||
|
||||
_knownItems.Insert(record);
|
||||
_logger.LogDebug("Recorded new content: {Key}", contentKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the known items database.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The Jellyfin item ID.</param>
|
||||
public void RemoveItem(Guid itemId)
|
||||
{
|
||||
var jellyfinId = itemId.ToString();
|
||||
_knownItems.DeleteMany(x => x.JellyfinItemId == jellyfinId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a notification for later processing.
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification to queue.</param>
|
||||
public void QueueNotification(PendingNotification notification)
|
||||
{
|
||||
_pendingNotifications.Insert(notification);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all pending notifications older than the specified delay.
|
||||
/// </summary>
|
||||
/// <param name="olderThan">The cutoff time.</param>
|
||||
/// <returns>The pending notifications.</returns>
|
||||
public IEnumerable<PendingNotification> GetPendingNotifications(DateTime olderThan)
|
||||
{
|
||||
return _pendingNotifications.Find(x => x.QueuedAt <= olderThan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes processed notifications.
|
||||
/// </summary>
|
||||
/// <param name="ids">The notification IDs to remove.</param>
|
||||
public void RemoveNotifications(IEnumerable<int> ids)
|
||||
{
|
||||
foreach (var id in ids)
|
||||
{
|
||||
_pendingNotifications.Delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets pending notifications grouped by series.
|
||||
/// </summary>
|
||||
/// <param name="seriesId">The series ID.</param>
|
||||
/// <returns>The notifications for the series.</returns>
|
||||
public IEnumerable<PendingNotification> GetNotificationsForSeries(string seriesId)
|
||||
{
|
||||
return _pendingNotifications.Find(x => x.SeriesId == seriesId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_database.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
171
Jellyfin.Plugin.SmartNotify/Services/Models.cs
Normal file
171
Jellyfin.Plugin.SmartNotify/Services/Models.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace Jellyfin.Plugin.SmartNotify.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a known media item in the database.
|
||||
/// Used to detect if an "added" item is actually a quality upgrade.
|
||||
/// </summary>
|
||||
public class KnownMediaItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the database ID.
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Jellyfin Item ID (GUID as string).
|
||||
/// </summary>
|
||||
public string JellyfinItemId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the unique content identifier.
|
||||
/// For episodes: "{SeriesProviderIds}|S{Season}E{Episode}"
|
||||
/// For movies: "{ProviderIds}"
|
||||
/// </summary>
|
||||
public string ContentKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item type (Episode, Movie, etc.).
|
||||
/// </summary>
|
||||
public string ItemType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the series name (for episodes).
|
||||
/// </summary>
|
||||
public string? SeriesName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the season number (for episodes).
|
||||
/// </summary>
|
||||
public int? SeasonNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the episode number (for episodes).
|
||||
/// </summary>
|
||||
public int? EpisodeNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item name.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the year.
|
||||
/// </summary>
|
||||
public int? Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when this item was first seen.
|
||||
/// </summary>
|
||||
public DateTime FirstSeen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the file path (for detecting file changes).
|
||||
/// </summary>
|
||||
public string? FilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the file size in bytes.
|
||||
/// </summary>
|
||||
public long? FileSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the provider IDs as JSON string.
|
||||
/// </summary>
|
||||
public string ProviderIdsJson { get; set; } = "{}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a pending notification in the queue.
|
||||
/// </summary>
|
||||
public class PendingNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the database ID.
|
||||
/// </summary>
|
||||
[BsonId]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Jellyfin Item ID.
|
||||
/// </summary>
|
||||
public string JellyfinItemId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item type.
|
||||
/// </summary>
|
||||
public string ItemType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the series name (for grouping episodes).
|
||||
/// </summary>
|
||||
public string? SeriesName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the series ID (for grouping).
|
||||
/// </summary>
|
||||
public string? SeriesId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the season number.
|
||||
/// </summary>
|
||||
public int? SeasonNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the episode number.
|
||||
/// </summary>
|
||||
public int? EpisodeNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item name.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the year.
|
||||
/// </summary>
|
||||
public int? Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the notification was queued.
|
||||
/// </summary>
|
||||
public DateTime QueuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the notification type.
|
||||
/// </summary>
|
||||
public NotificationType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the image URL.
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the provider IDs JSON.
|
||||
/// </summary>
|
||||
public string ProviderIdsJson { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the overview/description.
|
||||
/// </summary>
|
||||
public string? Overview { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of notification.
|
||||
/// </summary>
|
||||
public enum NotificationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Truly new content.
|
||||
/// </summary>
|
||||
NewContent,
|
||||
|
||||
/// <summary>
|
||||
/// Quality upgrade of existing content.
|
||||
/// </summary>
|
||||
QualityUpgrade
|
||||
}
|
||||
Reference in New Issue
Block a user