diff options
| author | Cody Robibero <cody@robibe.ro> | 2025-12-08 21:01:32 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-08 21:01:32 -0700 |
| commit | 0b3d6676d1dc78f38cd17c04ecafe2196a291199 (patch) | |
| tree | 347ebcd63761b28a648b402b5fc4bdc91bb23865 | |
| parent | c3a8734adf00c85ff7676d2a7caad1f5aa8cd01a (diff) | |
Add ability to sort and filter activity log entries (#15583)
| -rw-r--r-- | Jellyfin.Api/Controllers/ActivityLogController.cs | 76 | ||||
| -rw-r--r-- | Jellyfin.Data/Enums/ActivityLogSortBy.cs | 49 | ||||
| -rw-r--r-- | Jellyfin.Data/Queries/ActivityLogQuery.cs | 69 | ||||
| -rw-r--r-- | Jellyfin.Server.Implementations/Activity/ActivityManager.cs | 229 | ||||
| -rw-r--r-- | MediaBrowser.Model/Activity/IActivityManager.cs | 43 |
5 files changed, 362 insertions, 104 deletions
diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs index a19a203b5..d5f262773 100644 --- a/Jellyfin.Api/Controllers/ActivityLogController.cs +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -1,13 +1,16 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; -using Jellyfin.Api.Constants; +using Jellyfin.Data.Enums; using Jellyfin.Data.Queries; +using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Api; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Querying; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace Jellyfin.Api.Controllers; @@ -32,10 +35,19 @@ public class ActivityLogController : BaseJellyfinApiController /// <summary> /// Gets activity log entries. /// </summary> - /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param> - /// <param name="limit">Optional. The maximum number of records to return.</param> - /// <param name="minDate">Optional. The minimum date. Format = ISO.</param> - /// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param> + /// <param name="startIndex">The record index to start at. All items with a lower index will be dropped from the results.</param> + /// <param name="limit">The maximum number of records to return.</param> + /// <param name="minDate">The minimum date.</param> + /// <param name="hasUserId">Filter log entries if it has user id, or not.</param> + /// <param name="name">Filter by name.</param> + /// <param name="overview">Filter by overview.</param> + /// <param name="shortOverview">Filter by short overview.</param> + /// <param name="type">Filter by type.</param> + /// <param name="itemId">Filter by item id.</param> + /// <param name="username">Filter by username.</param> + /// <param name="severity">Filter by log severity.</param> + /// <param name="sortBy">Specify one or more sort orders. Format: SortBy=Name,Type.</param> + /// <param name="sortOrder">Sort Order..</param> /// <response code="200">Activity log returned.</response> /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns> [HttpGet("Entries")] @@ -44,14 +56,60 @@ public class ActivityLogController : BaseJellyfinApiController [FromQuery] int? startIndex, [FromQuery] int? limit, [FromQuery] DateTime? minDate, - [FromQuery] bool? hasUserId) + [FromQuery] bool? hasUserId, + [FromQuery] string? name, + [FromQuery] string? overview, + [FromQuery] string? shortOverview, + [FromQuery] string? type, + [FromQuery] Guid? itemId, + [FromQuery] string? username, + [FromQuery] LogLevel? severity, + [FromQuery] ActivityLogSortBy[]? sortBy, + [FromQuery] SortOrder[]? sortOrder) { - return await _activityManager.GetPagedResultAsync(new ActivityLogQuery + var query = new ActivityLogQuery { Skip = startIndex, Limit = limit, MinDate = minDate, - HasUserId = hasUserId - }).ConfigureAwait(false); + HasUserId = hasUserId, + Name = name, + Overview = overview, + ShortOverview = shortOverview, + Type = type, + ItemId = itemId, + Username = username, + Severity = severity, + OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []), + }; + + return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false); + } + + private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy( + IReadOnlyList<ActivityLogSortBy> sortBy, + IReadOnlyList<SortOrder> requestedSortOrder) + { + if (sortBy.Count == 0) + { + return []; + } + + var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count]; + var i = 0; + for (; i < requestedSortOrder.Count; i++) + { + result[i] = (sortBy[i], requestedSortOrder[i]); + } + + // Add remaining elements with the first specified SortOrder + // or the default one if no SortOrders are specified + var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending; + for (; i < sortBy.Count; i++) + { + result[i] = (sortBy[i], order); + } + + return result; } } diff --git a/Jellyfin.Data/Enums/ActivityLogSortBy.cs b/Jellyfin.Data/Enums/ActivityLogSortBy.cs new file mode 100644 index 000000000..d6d44e8c0 --- /dev/null +++ b/Jellyfin.Data/Enums/ActivityLogSortBy.cs @@ -0,0 +1,49 @@ +namespace Jellyfin.Data.Enums; + +/// <summary> +/// Activity log sorting options. +/// </summary> +public enum ActivityLogSortBy +{ + /// <summary> + /// Sort by name. + /// </summary> + Name = 0, + + /// <summary> + /// Sort by overview. + /// </summary> + Overiew = 1, + + /// <summary> + /// Sort by short overview. + /// </summary> + ShortOverview = 2, + + /// <summary> + /// Sort by type. + /// </summary> + Type = 3, + + /* + /// <summary> + /// Sort by item name. + /// </summary> + Item = 4, + */ + + /// <summary> + /// Sort by date. + /// </summary> + DateCreated = 5, + + /// <summary> + /// Sort by username. + /// </summary> + Username = 6, + + /// <summary> + /// Sort by severity. + /// </summary> + LogSeverity = 7 +} diff --git a/Jellyfin.Data/Queries/ActivityLogQuery.cs b/Jellyfin.Data/Queries/ActivityLogQuery.cs index f1af099d3..95c52f870 100644 --- a/Jellyfin.Data/Queries/ActivityLogQuery.cs +++ b/Jellyfin.Data/Queries/ActivityLogQuery.cs @@ -1,20 +1,63 @@ using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Enums; +using Microsoft.Extensions.Logging; -namespace Jellyfin.Data.Queries +namespace Jellyfin.Data.Queries; + +/// <summary> +/// A class representing a query to the activity logs. +/// </summary> +public class ActivityLogQuery : PaginatedQuery { /// <summary> - /// A class representing a query to the activity logs. + /// Gets or sets a value indicating whether to take entries with a user id. + /// </summary> + public bool? HasUserId { get; set; } + + /// <summary> + /// Gets or sets the minimum date to query for. + /// </summary> + public DateTime? MinDate { get; set; } + + /// <summary> + /// Gets or sets the name filter. /// </summary> - public class ActivityLogQuery : PaginatedQuery - { - /// <summary> - /// Gets or sets a value indicating whether to take entries with a user id. - /// </summary> - public bool? HasUserId { get; set; } + public string? Name { get; set; } - /// <summary> - /// Gets or sets the minimum date to query for. - /// </summary> - public DateTime? MinDate { get; set; } - } + /// <summary> + /// Gets or sets the overview filter. + /// </summary> + public string? Overview { get; set; } + + /// <summary> + /// Gets or sets the short overview filter. + /// </summary> + public string? ShortOverview { get; set; } + + /// <summary> + /// Gets or sets the type filter. + /// </summary> + public string? Type { get; set; } + + /// <summary> + /// Gets or sets the item filter. + /// </summary> + public Guid? ItemId { get; set; } + + /// <summary> + /// Gets or sets the username filter. + /// </summary> + public string? Username { get; set; } + + /// <summary> + /// Gets or sets the log level filter. + /// </summary> + public LogLevel? Severity { get; set; } + + /// <summary> + /// Gets or sets the result ordering. + /// </summary> + public IReadOnlyCollection<(ActivityLogSortBy, SortOrder)>? OrderBy { get; set; } } diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 8d492f7cd..7ee573f53 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -1,103 +1,198 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Data.Queries; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Extensions; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Querying; using Microsoft.EntityFrameworkCore; -namespace Jellyfin.Server.Implementations.Activity +namespace Jellyfin.Server.Implementations.Activity; + +/// <summary> +/// Manages the storage and retrieval of <see cref="ActivityLog"/> instances. +/// </summary> +public class ActivityManager : IActivityManager { + private readonly IDbContextFactory<JellyfinDbContext> _provider; + /// <summary> - /// Manages the storage and retrieval of <see cref="ActivityLog"/> instances. + /// Initializes a new instance of the <see cref="ActivityManager"/> class. /// </summary> - public class ActivityManager : IActivityManager + /// <param name="provider">The Jellyfin database provider.</param> + public ActivityManager(IDbContextFactory<JellyfinDbContext> provider) { - private readonly IDbContextFactory<JellyfinDbContext> _provider; + _provider = provider; + } + + /// <inheritdoc/> + public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated; - /// <summary> - /// Initializes a new instance of the <see cref="ActivityManager"/> class. - /// </summary> - /// <param name="provider">The Jellyfin database provider.</param> - public ActivityManager(IDbContextFactory<JellyfinDbContext> provider) + /// <inheritdoc/> + public async Task CreateAsync(ActivityLog entry) + { + var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - _provider = provider; + dbContext.ActivityLogs.Add(entry); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } - /// <inheritdoc/> - public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated; + EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry))); + } + + /// <inheritdoc/> + public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query) + { + // TODO allow sorting and filtering by item id. Currently not possible because ActivityLog stores the item id as a string. - /// <inheritdoc/> - public async Task CreateAsync(ActivityLog entry) + var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + // TODO switch to LeftJoin in .NET 10. + var entries = from a in dbContext.ActivityLogs + join u in dbContext.Users on a.UserId equals u.Id into ugj + from u in ugj.DefaultIfEmpty() + select new ExpandedActivityLog { ActivityLog = a, Username = u.Username }; + + if (query.HasUserId is not null) { - dbContext.ActivityLogs.Add(entry); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + entries = entries.Where(e => e.ActivityLog.UserId.Equals(default) != query.HasUserId.Value); } - EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry))); - } + if (query.MinDate is not null) + { + entries = entries.Where(e => e.ActivityLog.DateCreated >= query.MinDate.Value); + } - /// <inheritdoc/> - public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query) - { - var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + if (!string.IsNullOrEmpty(query.Name)) { - var entries = dbContext.ActivityLogs - .OrderByDescending(entry => entry.DateCreated) - .Where(entry => query.MinDate == null || entry.DateCreated >= query.MinDate) - .Where(entry => !query.HasUserId.HasValue || entry.UserId.Equals(default) != query.HasUserId.Value); - - return new QueryResult<ActivityLogEntry>( - query.Skip, - await entries.CountAsync().ConfigureAwait(false), - await entries - .Skip(query.Skip ?? 0) - .Take(query.Limit ?? 100) - .Select(entity => new ActivityLogEntry(entity.Name, entity.Type, entity.UserId) - { - Id = entity.Id, - Overview = entity.Overview, - ShortOverview = entity.ShortOverview, - ItemId = entity.ItemId, - Date = entity.DateCreated, - Severity = entity.LogSeverity - }) - .ToListAsync() - .ConfigureAwait(false)); + entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Name, $"%{query.Name}%")); } - } - /// <inheritdoc /> - public async Task CleanAsync(DateTime startDate) - { - var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + if (!string.IsNullOrEmpty(query.Overview)) + { + entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Overview, $"%{query.Overview}%")); + } + + if (!string.IsNullOrEmpty(query.ShortOverview)) + { + entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.ShortOverview, $"%{query.ShortOverview}%")); + } + + if (!string.IsNullOrEmpty(query.Type)) + { + entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Type, $"%{query.Type}%")); + } + + if (!query.ItemId.IsNullOrEmpty()) + { + var itemId = query.ItemId.Value.ToString("N"); + entries = entries.Where(e => e.ActivityLog.ItemId == itemId); + } + + if (!string.IsNullOrEmpty(query.Username)) + { + entries = entries.Where(e => EF.Functions.Like(e.Username, $"%{query.Username}%")); + } + + if (query.Severity is not null) { - await dbContext.ActivityLogs - .Where(entry => entry.DateCreated <= startDate) - .ExecuteDeleteAsync() - .ConfigureAwait(false); + entries = entries.Where(e => e.ActivityLog.LogSeverity == query.Severity); } + + return new QueryResult<ActivityLogEntry>( + query.Skip, + await entries.CountAsync().ConfigureAwait(false), + await ApplyOrdering(entries, query.OrderBy) + .Skip(query.Skip ?? 0) + .Take(query.Limit ?? 100) + .Select(entity => new ActivityLogEntry(entity.ActivityLog.Name, entity.ActivityLog.Type, entity.ActivityLog.UserId) + { + Id = entity.ActivityLog.Id, + Overview = entity.ActivityLog.Overview, + ShortOverview = entity.ActivityLog.ShortOverview, + ItemId = entity.ActivityLog.ItemId, + Date = entity.ActivityLog.DateCreated, + Severity = entity.ActivityLog.LogSeverity + }) + .ToListAsync() + .ConfigureAwait(false)); } + } - private static ActivityLogEntry ConvertToOldModel(ActivityLog entry) + /// <inheritdoc /> + public async Task CleanAsync(DateTime startDate) + { + var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId) - { - Id = entry.Id, - Overview = entry.Overview, - ShortOverview = entry.ShortOverview, - ItemId = entry.ItemId, - Date = entry.DateCreated, - Severity = entry.LogSeverity - }; + await dbContext.ActivityLogs + .Where(entry => entry.DateCreated <= startDate) + .ExecuteDeleteAsync() + .ConfigureAwait(false); + } + } + + private static ActivityLogEntry ConvertToOldModel(ActivityLog entry) + { + return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId) + { + Id = entry.Id, + Overview = entry.Overview, + ShortOverview = entry.ShortOverview, + ItemId = entry.ItemId, + Date = entry.DateCreated, + Severity = entry.LogSeverity + }; + } + + private IOrderedQueryable<ExpandedActivityLog> ApplyOrdering(IQueryable<ExpandedActivityLog> query, IReadOnlyCollection<(ActivityLogSortBy, SortOrder)>? sorting) + { + if (sorting is null || sorting.Count == 0) + { + return query.OrderByDescending(e => e.ActivityLog.DateCreated); } + + IOrderedQueryable<ExpandedActivityLog> ordered = null!; + + foreach (var (sortBy, sortOrder) in sorting) + { + var orderBy = MapOrderBy(sortBy); + ordered = sortOrder == SortOrder.Ascending + ? (ordered ?? query).OrderBy(orderBy) + : (ordered ?? query).OrderByDescending(orderBy); + } + + return ordered; + } + + private Expression<Func<ExpandedActivityLog, object?>> MapOrderBy(ActivityLogSortBy sortBy) + { + return sortBy switch + { + ActivityLogSortBy.Name => e => e.ActivityLog.Name, + ActivityLogSortBy.Overiew => e => e.ActivityLog.Overview, + ActivityLogSortBy.ShortOverview => e => e.ActivityLog.ShortOverview, + ActivityLogSortBy.Type => e => e.ActivityLog.Type, + ActivityLogSortBy.DateCreated => e => e.ActivityLog.DateCreated, + ActivityLogSortBy.Username => e => e.Username, + ActivityLogSortBy.LogSeverity => e => e.ActivityLog.LogSeverity, + _ => throw new ArgumentOutOfRangeException(nameof(sortBy), sortBy, "Unhandled ActivityLogSortBy") + }; + } + + private class ExpandedActivityLog + { + public ActivityLog ActivityLog { get; set; } = null!; + + public string? Username { get; set; } } } diff --git a/MediaBrowser.Model/Activity/IActivityManager.cs b/MediaBrowser.Model/Activity/IActivityManager.cs index 95aa567ad..96958e9a7 100644 --- a/MediaBrowser.Model/Activity/IActivityManager.cs +++ b/MediaBrowser.Model/Activity/IActivityManager.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Threading.Tasks; using Jellyfin.Data.Events; @@ -7,21 +5,36 @@ using Jellyfin.Data.Queries; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Model.Querying; -namespace MediaBrowser.Model.Activity +namespace MediaBrowser.Model.Activity; + +/// <summary> +/// Interface for the activity manager. +/// </summary> +public interface IActivityManager { - public interface IActivityManager - { - event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated; + /// <summary> + /// The event that is triggered when an entity is created. + /// </summary> + event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated; - Task CreateAsync(ActivityLog entry); + /// <summary> + /// Create a new activity log entry. + /// </summary> + /// <param name="entry">The entry to create.</param> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + Task CreateAsync(ActivityLog entry); - Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query); + /// <summary> + /// Get a paged list of activity log entries. + /// </summary> + /// <param name="query">The activity log query.</param> + /// <returns>The page of entries.</returns> + Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query); - /// <summary> - /// Remove all activity logs before the specified date. - /// </summary> - /// <param name="startDate">Activity log start date.</param> - /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> - Task CleanAsync(DateTime startDate); - } + /// <summary> + /// Remove all activity logs before the specified date. + /// </summary> + /// <param name="startDate">Activity log start date.</param> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + Task CleanAsync(DateTime startDate); } |
