aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations
diff options
context:
space:
mode:
authornyanmisaka <nst799610810@gmail.com>2020-11-08 19:11:54 +0800
committernyanmisaka <nst799610810@gmail.com>2020-11-08 19:11:54 +0800
commit737cb727f9f943f8bb55fca8a5c5023a98aca2d7 (patch)
tree8858a423545a03d0f7dfe5c1d1b0ae1157d62c5e /Jellyfin.Server.Implementations
parent05e78ee78c56364971956507f6239ded61f0af87 (diff)
parent96dcd9c87e2eb4b14004368856949e9fde2db261 (diff)
Merge remote-tracking branch 'upstream/master' into fonts
Diffstat (limited to 'Jellyfin.Server.Implementations')
-rw-r--r--Jellyfin.Server.Implementations/Activity/ActivityManager.cs58
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs102
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs52
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs49
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs104
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs106
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs54
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs54
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/System/PendingRestartNotifier.cs31
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs158
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedNotifier.cs32
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationCancelledNotifier.cs32
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs51
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedNotifier.cs32
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs50
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledNotifier.cs32
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallingNotifier.cs32
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs45
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledNotifier.cs32
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs51
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs43
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs44
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedNotifier.cs39
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs47
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs43
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Users/UserUpdatedNotifier.cs42
-rw-r--r--Jellyfin.Server.Implementations/Events/EventManager.cs60
-rw-r--r--Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs72
-rw-r--r--Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj5
-rw-r--r--Jellyfin.Server.Implementations/JellyfinDb.cs46
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs461
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.cs51
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs464
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.cs28
-rw-r--r--Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs9
-rw-r--r--Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs2
-rw-r--r--Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs37
-rw-r--r--Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs2
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs170
39 files changed, 2624 insertions, 198 deletions
diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
index 2deefbe81..7bde4f35b 100644
--- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
+++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
@@ -2,9 +2,11 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data.Entities;
+using Jellyfin.Data.Events;
+using Jellyfin.Data.Queries;
using MediaBrowser.Model.Activity;
-using MediaBrowser.Model.Events;
using MediaBrowser.Model.Querying;
+using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Activity
{
@@ -28,16 +30,6 @@ namespace Jellyfin.Server.Implementations.Activity
public event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated;
/// <inheritdoc/>
- public void Create(ActivityLog entry)
- {
- using var dbContext = _provider.CreateContext();
- dbContext.ActivityLogs.Add(entry);
- dbContext.SaveChanges();
-
- EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry)));
- }
-
- /// <inheritdoc/>
public async Task CreateAsync(ActivityLog entry)
{
await using var dbContext = _provider.CreateContext();
@@ -49,39 +41,47 @@ namespace Jellyfin.Server.Implementations.Activity
}
/// <inheritdoc/>
- public QueryResult<ActivityLogEntry> GetPagedResult(
- Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>> func,
- int? startIndex,
- int? limit)
+ public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query)
{
- using var dbContext = _provider.CreateContext();
+ await using var dbContext = _provider.CreateContext();
- var query = func(dbContext.ActivityLogs.OrderByDescending(entry => entry.DateCreated));
+ IQueryable<ActivityLog> entries = dbContext.ActivityLogs
+ .AsQueryable()
+ .OrderByDescending(entry => entry.DateCreated);
- if (startIndex.HasValue)
+ if (query.MinDate.HasValue)
{
- query = query.Skip(startIndex.Value);
+ entries = entries.Where(entry => entry.DateCreated >= query.MinDate);
}
- if (limit.HasValue)
+ if (query.HasUserId.HasValue)
{
- query = query.Take(limit.Value);
+ entries = entries.Where(entry => entry.UserId != Guid.Empty == query.HasUserId.Value );
}
- // This converts the objects from the new database model to the old for compatibility with the existing API.
- var list = query.Select(ConvertToOldModel).ToList();
-
return new QueryResult<ActivityLogEntry>
{
- Items = list,
- TotalRecordCount = func(dbContext.ActivityLogs).Count()
+ Items = await entries
+ .Skip(query.StartIndex ?? 0)
+ .Take(query.Limit ?? 100)
+ .AsAsyncEnumerable()
+ .Select(ConvertToOldModel)
+ .ToListAsync()
+ .ConfigureAwait(false),
+ TotalRecordCount = await entries.CountAsync().ConfigureAwait(false)
};
}
- /// <inheritdoc/>
- public QueryResult<ActivityLogEntry> GetPagedResult(int? startIndex, int? limit)
+ /// <inheritdoc />
+ public async Task CleanAsync(DateTime startDate)
{
- return GetPagedResult(logs => logs, startIndex, limit);
+ await using var dbContext = _provider.CreateContext();
+ var entries = dbContext.ActivityLogs
+ .AsQueryable()
+ .Where(entry => entry.DateCreated <= startDate);
+
+ dbContext.RemoveRange(entries);
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
private static ActivityLogEntry ConvertToOldModel(ActivityLog entry)
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs
new file mode 100644
index 000000000..449f27be2
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Library/SubtitleDownloadFailureLogger.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using Episode = MediaBrowser.Controller.Entities.TV.Episode;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Library
+{
+ /// <summary>
+ /// Creates an entry in the activity log whenever a subtitle download fails.
+ /// </summary>
+ public class SubtitleDownloadFailureLogger : IEventConsumer<SubtitleDownloadFailureEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SubtitleDownloadFailureLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public SubtitleDownloadFailureLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(SubtitleDownloadFailureEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("SubtitleDownloadFailureFromForItem"),
+ eventArgs.Provider,
+ GetItemName(eventArgs.Item)),
+ "SubtitleDownloadFailure",
+ Guid.Empty)
+ {
+ ItemId = eventArgs.Item.Id.ToString("N", CultureInfo.InvariantCulture),
+ ShortOverview = eventArgs.Exception.Message
+ }).ConfigureAwait(false);
+ }
+
+ private static string GetItemName(BaseItem item)
+ {
+ var name = item.Name;
+ if (item is Episode episode)
+ {
+ if (episode.IndexNumber.HasValue)
+ {
+ name = string.Format(
+ CultureInfo.InvariantCulture,
+ "Ep{0} - {1}",
+ episode.IndexNumber.Value,
+ name);
+ }
+
+ if (episode.ParentIndexNumber.HasValue)
+ {
+ name = string.Format(
+ CultureInfo.InvariantCulture,
+ "S{0}, {1}",
+ episode.ParentIndexNumber.Value,
+ name);
+ }
+ }
+
+ if (item is IHasSeries hasSeries)
+ {
+ name = hasSeries.SeriesName + " - " + name;
+ }
+
+ if (item is IHasAlbumArtist hasAlbumArtist)
+ {
+ var artists = hasAlbumArtist.AlbumArtists;
+
+ if (artists.Count > 0)
+ {
+ name = artists[0] + " - " + name;
+ }
+ }
+ else if (item is IHasArtist hasArtist)
+ {
+ var artists = hasArtist.Artists;
+
+ if (artists.Count > 0)
+ {
+ name = artists[0] + " - " + name;
+ }
+ }
+
+ return name;
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs
new file mode 100644
index 000000000..f899b4497
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Events;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Security
+{
+ /// <summary>
+ /// Creates an entry in the activity log when there is a failed login attempt.
+ /// </summary>
+ public class AuthenticationFailedLogger : IEventConsumer<GenericEventArgs<AuthenticationRequest>>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AuthenticationFailedLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public AuthenticationFailedLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(GenericEventArgs<AuthenticationRequest> eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("FailedLoginAttemptWithUserName"),
+ eventArgs.Argument.Username),
+ "AuthenticationFailed",
+ Guid.Empty)
+ {
+ LogSeverity = LogLevel.Error,
+ ShortOverview = string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("LabelIpAddressValue"),
+ eventArgs.Argument.RemoteEndPoint),
+ }).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs
new file mode 100644
index 000000000..2f9f44ed6
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs
@@ -0,0 +1,49 @@
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Events;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Security
+{
+ /// <summary>
+ /// Creates an entry in the activity log when there is a successful login attempt.
+ /// </summary>
+ public class AuthenticationSucceededLogger : IEventConsumer<GenericEventArgs<AuthenticationResult>>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AuthenticationSucceededLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public AuthenticationSucceededLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(GenericEventArgs<AuthenticationResult> e)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("AuthenticationSucceededWithUserName"),
+ e.Argument.User.Name),
+ "AuthenticationSucceeded",
+ e.Argument.User.Id)
+ {
+ ShortOverview = string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("LabelIpAddressValue"),
+ e.Argument.SessionInfo.RemoteEndPoint),
+ }).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs
new file mode 100644
index 000000000..ec4a76e7f
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Notifications;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Session
+{
+ /// <summary>
+ /// Creates an entry in the activity log whenever a user starts playback.
+ /// </summary>
+ public class PlaybackStartLogger : IEventConsumer<PlaybackStartEventArgs>
+ {
+ private readonly ILogger<PlaybackStartLogger> _logger;
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlaybackStartLogger"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public PlaybackStartLogger(ILogger<PlaybackStartLogger> logger, ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _logger = logger;
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PlaybackStartEventArgs eventArgs)
+ {
+ if (eventArgs.MediaInfo == null)
+ {
+ _logger.LogWarning("PlaybackStart reported with null media info.");
+ return;
+ }
+
+ if (eventArgs.Item != null && eventArgs.Item.IsThemeMedia)
+ {
+ // Don't report theme song or local trailer playback
+ return;
+ }
+
+ if (eventArgs.Users.Count == 0)
+ {
+ return;
+ }
+
+ var user = eventArgs.Users[0];
+
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("UserStartedPlayingItemWithValues"),
+ user.Username,
+ GetItemName(eventArgs.MediaInfo),
+ eventArgs.DeviceName),
+ GetPlaybackNotificationType(eventArgs.MediaInfo.MediaType),
+ user.Id))
+ .ConfigureAwait(false);
+ }
+
+ private static string GetItemName(BaseItemDto item)
+ {
+ var name = item.Name;
+
+ if (!string.IsNullOrEmpty(item.SeriesName))
+ {
+ name = item.SeriesName + " - " + name;
+ }
+
+ if (item.Artists != null && item.Artists.Count > 0)
+ {
+ name = item.Artists[0] + " - " + name;
+ }
+
+ return name;
+ }
+
+ private static string GetPlaybackNotificationType(string mediaType)
+ {
+ if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.AudioPlayback.ToString();
+ }
+
+ if (string.Equals(mediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.VideoPlayback.ToString();
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
new file mode 100644
index 000000000..51a882c14
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Notifications;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Session
+{
+ /// <summary>
+ /// Creates an activity log entry whenever a user stops playback.
+ /// </summary>
+ public class PlaybackStopLogger : IEventConsumer<PlaybackStopEventArgs>
+ {
+ private readonly ILogger<PlaybackStopLogger> _logger;
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlaybackStopLogger"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public PlaybackStopLogger(ILogger<PlaybackStopLogger> logger, ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _logger = logger;
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PlaybackStopEventArgs eventArgs)
+ {
+ var item = eventArgs.MediaInfo;
+
+ if (item == null)
+ {
+ _logger.LogWarning("PlaybackStopped reported with null media info.");
+ return;
+ }
+
+ if (eventArgs.Item != null && eventArgs.Item.IsThemeMedia)
+ {
+ // Don't report theme song or local trailer playback
+ return;
+ }
+
+ if (eventArgs.Users.Count == 0)
+ {
+ return;
+ }
+
+ var user = eventArgs.Users[0];
+
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("UserStoppedPlayingItemWithValues"),
+ user.Username,
+ GetItemName(item),
+ eventArgs.DeviceName),
+ GetPlaybackStoppedNotificationType(item.MediaType),
+ user.Id))
+ .ConfigureAwait(false);
+ }
+
+ private static string GetItemName(BaseItemDto item)
+ {
+ var name = item.Name;
+
+ if (!string.IsNullOrEmpty(item.SeriesName))
+ {
+ name = item.SeriesName + " - " + name;
+ }
+
+ if (item.Artists != null && item.Artists.Count > 0)
+ {
+ name = item.Artists[0] + " - " + name;
+ }
+
+ return name;
+ }
+
+ private static string GetPlaybackStoppedNotificationType(string mediaType)
+ {
+ if (string.Equals(mediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.AudioPlaybackStopped.ToString();
+ }
+
+ if (string.Equals(mediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
+ {
+ return NotificationType.VideoPlaybackStopped.ToString();
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs
new file mode 100644
index 000000000..cf20946ec
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionEndedLogger.cs
@@ -0,0 +1,54 @@
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Session;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Session
+{
+ /// <summary>
+ /// Creates an entry in the activity log whenever a session ends.
+ /// </summary>
+ public class SessionEndedLogger : IEventConsumer<SessionEndedEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SessionEndedLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public SessionEndedLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(SessionEndedEventArgs eventArgs)
+ {
+ if (string.IsNullOrEmpty(eventArgs.Argument.UserName))
+ {
+ return;
+ }
+
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("UserOfflineFromDevice"),
+ eventArgs.Argument.UserName,
+ eventArgs.Argument.DeviceName),
+ "SessionEnded",
+ eventArgs.Argument.UserId)
+ {
+ ShortOverview = string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("LabelIpAddressValue"),
+ eventArgs.Argument.RemoteEndPoint),
+ }).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs
new file mode 100644
index 000000000..6a0f29b09
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/SessionStartedLogger.cs
@@ -0,0 +1,54 @@
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Session;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Session
+{
+ /// <summary>
+ /// Creates an entry in the activity log when a session is started.
+ /// </summary>
+ public class SessionStartedLogger : IEventConsumer<SessionStartedEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SessionStartedLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public SessionStartedLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(SessionStartedEventArgs eventArgs)
+ {
+ if (string.IsNullOrEmpty(eventArgs.Argument.UserName))
+ {
+ return;
+ }
+
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("UserOnlineFromDevice"),
+ eventArgs.Argument.UserName,
+ eventArgs.Argument.DeviceName),
+ "SessionStarted",
+ eventArgs.Argument.UserId)
+ {
+ ShortOverview = string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("LabelIpAddressValue"),
+ eventArgs.Argument.RemoteEndPoint)
+ }).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/System/PendingRestartNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/System/PendingRestartNotifier.cs
new file mode 100644
index 000000000..2fa38dd71
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/System/PendingRestartNotifier.cs
@@ -0,0 +1,31 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Events.System;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Session;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.System
+{
+ /// <summary>
+ /// Notifies users when there is a pending restart.
+ /// </summary>
+ public class PendingRestartNotifier : IEventConsumer<PendingRestartEventArgs>
+ {
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PendingRestartNotifier"/> class.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ public PendingRestartNotifier(ISessionManager sessionManager)
+ {
+ _sessionManager = sessionManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PendingRestartEventArgs eventArgs)
+ {
+ await _sessionManager.SendRestartRequiredNotification(CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs
new file mode 100644
index 000000000..05201a346
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedLogger.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Notifications;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.System
+{
+ /// <summary>
+ /// Creates an activity log entry whenever a task is completed.
+ /// </summary>
+ public class TaskCompletedLogger : IEventConsumer<TaskCompletionEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="TaskCompletedLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public TaskCompletedLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(TaskCompletionEventArgs e)
+ {
+ var result = e.Result;
+ var task = e.Task;
+
+ if (task.ScheduledTask is IConfigurableScheduledTask activityTask
+ && !activityTask.IsLogged)
+ {
+ return;
+ }
+
+ var time = result.EndTimeUtc - result.StartTimeUtc;
+ var runningTime = string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("LabelRunningTimeValue"),
+ ToUserFriendlyString(time));
+
+ if (result.Status == TaskCompletionStatus.Failed)
+ {
+ var vals = new List<string>();
+
+ if (!string.IsNullOrEmpty(e.Result.ErrorMessage))
+ {
+ vals.Add(e.Result.ErrorMessage);
+ }
+
+ if (!string.IsNullOrEmpty(e.Result.LongErrorMessage))
+ {
+ vals.Add(e.Result.LongErrorMessage);
+ }
+
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("ScheduledTaskFailedWithName"), task.Name),
+ NotificationType.TaskFailed.ToString(),
+ Guid.Empty)
+ {
+ LogSeverity = LogLevel.Error,
+ Overview = string.Join(Environment.NewLine, vals),
+ ShortOverview = runningTime
+ }).ConfigureAwait(false);
+ }
+ }
+
+ private static string ToUserFriendlyString(TimeSpan span)
+ {
+ const int DaysInYear = 365;
+ const int DaysInMonth = 30;
+
+ // Get each non-zero value from TimeSpan component
+ var values = new List<string>();
+
+ // Number of years
+ int days = span.Days;
+ if (days >= DaysInYear)
+ {
+ int years = days / DaysInYear;
+ values.Add(CreateValueString(years, "year"));
+ days %= DaysInYear;
+ }
+
+ // Number of months
+ if (days >= DaysInMonth)
+ {
+ int months = days / DaysInMonth;
+ values.Add(CreateValueString(months, "month"));
+ days = days % DaysInMonth;
+ }
+
+ // Number of days
+ if (days >= 1)
+ {
+ values.Add(CreateValueString(days, "day"));
+ }
+
+ // Number of hours
+ if (span.Hours >= 1)
+ {
+ values.Add(CreateValueString(span.Hours, "hour"));
+ }
+
+ // Number of minutes
+ if (span.Minutes >= 1)
+ {
+ values.Add(CreateValueString(span.Minutes, "minute"));
+ }
+
+ // Number of seconds (include when 0 if no other components included)
+ if (span.Seconds >= 1 || values.Count == 0)
+ {
+ values.Add(CreateValueString(span.Seconds, "second"));
+ }
+
+ // Combine values into string
+ var builder = new StringBuilder();
+ for (int i = 0; i < values.Count; i++)
+ {
+ if (builder.Length > 0)
+ {
+ builder.Append(i == values.Count - 1 ? " and " : ", ");
+ }
+
+ builder.Append(values[i]);
+ }
+
+ // Return result
+ return builder.ToString();
+ }
+
+ /// <summary>
+ /// Constructs a string description of a time-span value.
+ /// </summary>
+ /// <param name="value">The value of this item.</param>
+ /// <param name="description">The name of this item (singular form).</param>
+ private static string CreateValueString(int value, string description)
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "{0:#,##0} {1}",
+ value,
+ value == 1 ? description : string.Format(CultureInfo.InvariantCulture, "{0}s", description));
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedNotifier.cs
new file mode 100644
index 000000000..0993c6df7
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedNotifier.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+using MediaBrowser.Model.Tasks;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.System
+{
+ /// <summary>
+ /// Notifies admin users when a task is completed.
+ /// </summary>
+ public class TaskCompletedNotifier : IEventConsumer<TaskCompletionEventArgs>
+ {
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="TaskCompletedNotifier"/> class.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ public TaskCompletedNotifier(ISessionManager sessionManager)
+ {
+ _sessionManager = sessionManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(TaskCompletionEventArgs eventArgs)
+ {
+ await _sessionManager.SendMessageToAdminSessions(SessionMessageType.ScheduledTaskEnded, eventArgs.Result, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationCancelledNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationCancelledNotifier.cs
new file mode 100644
index 000000000..1d790da6b
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationCancelledNotifier.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
+{
+ /// <summary>
+ /// Notifies admin users when a plugin installation is cancelled.
+ /// </summary>
+ public class PluginInstallationCancelledNotifier : IEventConsumer<PluginInstallationCancelledEventArgs>
+ {
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginInstallationCancelledNotifier"/> class.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ public PluginInstallationCancelledNotifier(ISessionManager sessionManager)
+ {
+ _sessionManager = sessionManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PluginInstallationCancelledEventArgs eventArgs)
+ {
+ await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageInstallationCancelled, eventArgs.Argument, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs
new file mode 100644
index 000000000..d71c298c5
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Notifications;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
+{
+ /// <summary>
+ /// Creates an entry in the activity log when a package installation fails.
+ /// </summary>
+ public class PluginInstallationFailedLogger : IEventConsumer<InstallationFailedEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginInstallationFailedLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public PluginInstallationFailedLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(InstallationFailedEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("NameInstallFailed"),
+ eventArgs.InstallationInfo.Name),
+ NotificationType.InstallationFailed.ToString(),
+ Guid.Empty)
+ {
+ ShortOverview = string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("VersionNumber"),
+ eventArgs.InstallationInfo.Version),
+ Overview = eventArgs.Exception.Message
+ }).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedNotifier.cs
new file mode 100644
index 000000000..a1faf18fc
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedNotifier.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
+{
+ /// <summary>
+ /// Notifies admin users when a plugin installation fails.
+ /// </summary>
+ public class PluginInstallationFailedNotifier : IEventConsumer<InstallationFailedEventArgs>
+ {
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginInstallationFailedNotifier"/> class.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ public PluginInstallationFailedNotifier(ISessionManager sessionManager)
+ {
+ _sessionManager = sessionManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(InstallationFailedEventArgs eventArgs)
+ {
+ await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageInstallationFailed, eventArgs.InstallationInfo, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs
new file mode 100644
index 000000000..8837172db
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Notifications;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
+{
+ /// <summary>
+ /// Creates an entry in the activity log when a plugin is installed.
+ /// </summary>
+ public class PluginInstalledLogger : IEventConsumer<PluginInstalledEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginInstalledLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public PluginInstalledLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PluginInstalledEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("PluginInstalledWithName"),
+ eventArgs.Argument.Name),
+ NotificationType.PluginInstalled.ToString(),
+ Guid.Empty)
+ {
+ ShortOverview = string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("VersionNumber"),
+ eventArgs.Argument.Version)
+ }).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledNotifier.cs
new file mode 100644
index 000000000..bd1a71404
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledNotifier.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
+{
+ /// <summary>
+ /// Notifies admin users when a plugin is installed.
+ /// </summary>
+ public class PluginInstalledNotifier : IEventConsumer<PluginInstalledEventArgs>
+ {
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginInstalledNotifier"/> class.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ public PluginInstalledNotifier(ISessionManager sessionManager)
+ {
+ _sessionManager = sessionManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PluginInstalledEventArgs eventArgs)
+ {
+ await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageInstallationCompleted, eventArgs.Argument, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallingNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallingNotifier.cs
new file mode 100644
index 000000000..b513ac64a
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallingNotifier.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
+{
+ /// <summary>
+ /// Notifies admin users when a plugin is being installed.
+ /// </summary>
+ public class PluginInstallingNotifier : IEventConsumer<PluginInstallingEventArgs>
+ {
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginInstallingNotifier"/> class.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ public PluginInstallingNotifier(ISessionManager sessionManager)
+ {
+ _sessionManager = sessionManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PluginInstallingEventArgs eventArgs)
+ {
+ await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageInstalling, eventArgs.Argument, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs
new file mode 100644
index 000000000..91a30069e
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Notifications;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
+{
+ /// <summary>
+ /// Creates an entry in the activity log when a plugin is uninstalled.
+ /// </summary>
+ public class PluginUninstalledLogger : IEventConsumer<PluginUninstalledEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginUninstalledLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public PluginUninstalledLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PluginUninstalledEventArgs e)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("PluginUninstalledWithName"),
+ e.Argument.Name),
+ NotificationType.PluginUninstalled.ToString(),
+ Guid.Empty))
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledNotifier.cs
new file mode 100644
index 000000000..1fd7b9adf
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledNotifier.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
+{
+ /// <summary>
+ /// Notifies admin users when a plugin is uninstalled.
+ /// </summary>
+ public class PluginUninstalledNotifier : IEventConsumer<PluginUninstalledEventArgs>
+ {
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginUninstalledNotifier"/> class.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ public PluginUninstalledNotifier(ISessionManager sessionManager)
+ {
+ _sessionManager = sessionManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PluginUninstalledEventArgs eventArgs)
+ {
+ await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageUninstalled, eventArgs.Argument, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs
new file mode 100644
index 000000000..9ce16f774
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUpdatedLogger.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Notifications;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
+{
+ /// <summary>
+ /// Creates an entry in the activity log when a plugin is updated.
+ /// </summary>
+ public class PluginUpdatedLogger : IEventConsumer<PluginUpdatedEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PluginUpdatedLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public PluginUpdatedLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(PluginUpdatedEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("PluginUpdatedWithName"),
+ eventArgs.Argument.Name),
+ NotificationType.PluginUpdateInstalled.ToString(),
+ Guid.Empty)
+ {
+ ShortOverview = string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("VersionNumber"),
+ eventArgs.Argument.Version),
+ Overview = eventArgs.Argument.Changelog
+ }).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs
new file mode 100644
index 000000000..dc855cc36
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserCreatedLogger.cs
@@ -0,0 +1,43 @@
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Events.Users;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Users
+{
+ /// <summary>
+ /// Creates an entry in the activity log when a user is created.
+ /// </summary>
+ public class UserCreatedLogger : IEventConsumer<UserCreatedEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UserCreatedLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public UserCreatedLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(UserCreatedEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("UserCreatedWithName"),
+ eventArgs.Argument.Username),
+ "UserCreated",
+ eventArgs.Argument.Id))
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs
new file mode 100644
index 000000000..c68a62c81
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedLogger.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Events.Users;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Users
+{
+ /// <summary>
+ /// Adds an entry to the activity log when a user is deleted.
+ /// </summary>
+ public class UserDeletedLogger : IEventConsumer<UserDeletedEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UserDeletedLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public UserDeletedLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(UserDeletedEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("UserDeletedWithName"),
+ eventArgs.Argument.Username),
+ "UserDeleted",
+ Guid.Empty))
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedNotifier.cs
new file mode 100644
index 000000000..303e88621
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedNotifier.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Events.Users;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Users
+{
+ /// <summary>
+ /// Notifies the user's sessions when a user is deleted.
+ /// </summary>
+ public class UserDeletedNotifier : IEventConsumer<UserDeletedEventArgs>
+ {
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UserDeletedNotifier"/> class.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ public UserDeletedNotifier(ISessionManager sessionManager)
+ {
+ _sessionManager = sessionManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(UserDeletedEventArgs eventArgs)
+ {
+ await _sessionManager.SendMessageToUserSessions(
+ new List<Guid> { eventArgs.Argument.Id },
+ SessionMessageType.UserDeleted,
+ eventArgs.Argument.Id.ToString("N", CultureInfo.InvariantCulture),
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs
new file mode 100644
index 000000000..a31f222ee
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserLockedOutLogger.cs
@@ -0,0 +1,47 @@
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Events.Users;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Notifications;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Users
+{
+ /// <summary>
+ /// Creates an entry in the activity log when a user is locked out.
+ /// </summary>
+ public class UserLockedOutLogger : IEventConsumer<UserLockedOutEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UserLockedOutLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public UserLockedOutLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(UserLockedOutEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("UserLockedOutWithName"),
+ eventArgs.Argument.Username),
+ NotificationType.UserLockedOut.ToString(),
+ eventArgs.Argument.Id)
+ {
+ LogSeverity = LogLevel.Error
+ }).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs
new file mode 100644
index 000000000..dc8ecbf48
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserPasswordChangedLogger.cs
@@ -0,0 +1,43 @@
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Events.Users;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Globalization;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Users
+{
+ /// <summary>
+ /// Creates an entry in the activity log when a user's password is changed.
+ /// </summary>
+ public class UserPasswordChangedLogger : IEventConsumer<UserPasswordChangedEventArgs>
+ {
+ private readonly ILocalizationManager _localizationManager;
+ private readonly IActivityManager _activityManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UserPasswordChangedLogger"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The localization manager.</param>
+ /// <param name="activityManager">The activity manager.</param>
+ public UserPasswordChangedLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
+ {
+ _localizationManager = localizationManager;
+ _activityManager = activityManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(UserPasswordChangedEventArgs eventArgs)
+ {
+ await _activityManager.CreateAsync(new ActivityLog(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localizationManager.GetLocalizedString("UserPasswordChangedWithName"),
+ eventArgs.Argument.Username),
+ "UserPasswordChanged",
+ eventArgs.Argument.Id))
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Users/UserUpdatedNotifier.cs b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserUpdatedNotifier.cs
new file mode 100644
index 000000000..a14911b94
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserUpdatedNotifier.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Events.Users;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+
+namespace Jellyfin.Server.Implementations.Events.Consumers.Users
+{
+ /// <summary>
+ /// Notifies a user when their account has been updated.
+ /// </summary>
+ public class UserUpdatedNotifier : IEventConsumer<UserUpdatedEventArgs>
+ {
+ private readonly IUserManager _userManager;
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UserUpdatedNotifier"/> class.
+ /// </summary>
+ /// <param name="userManager">The user manager.</param>
+ /// <param name="sessionManager">The session manager.</param>
+ public UserUpdatedNotifier(IUserManager userManager, ISessionManager sessionManager)
+ {
+ _userManager = userManager;
+ _sessionManager = sessionManager;
+ }
+
+ /// <inheritdoc />
+ public async Task OnEvent(UserUpdatedEventArgs e)
+ {
+ await _sessionManager.SendMessageToUserSessions(
+ new List<Guid> { e.Argument.Id },
+ SessionMessageType.UserUpdated,
+ _userManager.GetUserDto(e.Argument),
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/EventManager.cs b/Jellyfin.Server.Implementations/Events/EventManager.cs
new file mode 100644
index 000000000..707002442
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/EventManager.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Events;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.Events
+{
+ /// <summary>
+ /// Handles the firing of events.
+ /// </summary>
+ public class EventManager : IEventManager
+ {
+ private readonly ILogger<EventManager> _logger;
+ private readonly IServerApplicationHost _appHost;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="EventManager"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="appHost">The application host.</param>
+ public EventManager(ILogger<EventManager> logger, IServerApplicationHost appHost)
+ {
+ _logger = logger;
+ _appHost = appHost;
+ }
+
+ /// <inheritdoc />
+ public void Publish<T>(T eventArgs)
+ where T : EventArgs
+ {
+ Task.WaitAll(PublishInternal(eventArgs));
+ }
+
+ /// <inheritdoc />
+ public async Task PublishAsync<T>(T eventArgs)
+ where T : EventArgs
+ {
+ await PublishInternal(eventArgs).ConfigureAwait(false);
+ }
+
+ private async Task PublishInternal<T>(T eventArgs)
+ where T : EventArgs
+ {
+ using var scope = _appHost.ServiceProvider.CreateScope();
+ foreach (var service in scope.ServiceProvider.GetServices<IEventConsumer<T>>())
+ {
+ try
+ {
+ await service.OnEvent(eventArgs).ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Uncaught exception in EventConsumer {type}: ", service.GetType());
+ }
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
new file mode 100644
index 000000000..5d558189b
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs
@@ -0,0 +1,72 @@
+using Jellyfin.Data.Events;
+using Jellyfin.Data.Events.System;
+using Jellyfin.Data.Events.Users;
+using Jellyfin.Server.Implementations.Events.Consumers.Library;
+using Jellyfin.Server.Implementations.Events.Consumers.Security;
+using Jellyfin.Server.Implementations.Events.Consumers.Session;
+using Jellyfin.Server.Implementations.Events.Consumers.System;
+using Jellyfin.Server.Implementations.Events.Consumers.Updates;
+using Jellyfin.Server.Implementations.Events.Consumers.Users;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Session;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Jellyfin.Server.Implementations.Events
+{
+ /// <summary>
+ /// A class containing extensions to <see cref="IServiceCollection"/> for eventing.
+ /// </summary>
+ public static class EventingServiceCollectionExtensions
+ {
+ /// <summary>
+ /// Adds the event services to the service collection.
+ /// </summary>
+ /// <param name="collection">The service collection.</param>
+ public static void AddEventServices(this IServiceCollection collection)
+ {
+ // Library consumers
+ collection.AddScoped<IEventConsumer<SubtitleDownloadFailureEventArgs>, SubtitleDownloadFailureLogger>();
+
+ // Security consumers
+ collection.AddScoped<IEventConsumer<GenericEventArgs<AuthenticationRequest>>, AuthenticationFailedLogger>();
+ collection.AddScoped<IEventConsumer<GenericEventArgs<AuthenticationResult>>, AuthenticationSucceededLogger>();
+
+ // Session consumers
+ collection.AddScoped<IEventConsumer<PlaybackStartEventArgs>, PlaybackStartLogger>();
+ collection.AddScoped<IEventConsumer<PlaybackStopEventArgs>, PlaybackStopLogger>();
+ collection.AddScoped<IEventConsumer<SessionEndedEventArgs>, SessionEndedLogger>();
+ collection.AddScoped<IEventConsumer<SessionStartedEventArgs>, SessionStartedLogger>();
+
+ // System consumers
+ collection.AddScoped<IEventConsumer<PendingRestartEventArgs>, PendingRestartNotifier>();
+ collection.AddScoped<IEventConsumer<TaskCompletionEventArgs>, TaskCompletedLogger>();
+ collection.AddScoped<IEventConsumer<TaskCompletionEventArgs>, TaskCompletedNotifier>();
+
+ // Update consumers
+ collection.AddScoped<IEventConsumer<PluginInstallationCancelledEventArgs>, PluginInstallationCancelledNotifier>();
+ collection.AddScoped<IEventConsumer<InstallationFailedEventArgs>, PluginInstallationFailedLogger>();
+ collection.AddScoped<IEventConsumer<InstallationFailedEventArgs>, PluginInstallationFailedNotifier>();
+ collection.AddScoped<IEventConsumer<PluginInstalledEventArgs>, PluginInstalledLogger>();
+ collection.AddScoped<IEventConsumer<PluginInstalledEventArgs>, PluginInstalledNotifier>();
+ collection.AddScoped<IEventConsumer<PluginInstallingEventArgs>, PluginInstallingNotifier>();
+ collection.AddScoped<IEventConsumer<PluginUninstalledEventArgs>, PluginUninstalledLogger>();
+ collection.AddScoped<IEventConsumer<PluginUninstalledEventArgs>, PluginUninstalledNotifier>();
+ collection.AddScoped<IEventConsumer<PluginUpdatedEventArgs>, PluginUpdatedLogger>();
+
+ // User consumers
+ collection.AddScoped<IEventConsumer<UserCreatedEventArgs>, UserCreatedLogger>();
+ collection.AddScoped<IEventConsumer<UserDeletedEventArgs>, UserDeletedLogger>();
+ collection.AddScoped<IEventConsumer<UserDeletedEventArgs>, UserDeletedNotifier>();
+ collection.AddScoped<IEventConsumer<UserLockedOutEventArgs>, UserLockedOutLogger>();
+ collection.AddScoped<IEventConsumer<UserPasswordChangedEventArgs>, UserPasswordChangedLogger>();
+ collection.AddScoped<IEventConsumer<UserUpdatedEventArgs>, UserUpdatedNotifier>();
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
index 21748ca19..c52be3b8a 100644
--- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
+++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
@@ -24,11 +24,12 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.6">
+ <PackageReference Include="System.Linq.Async" Version="4.1.1" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
- <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.6">
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
diff --git a/Jellyfin.Server.Implementations/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs
index 7d864ebc6..45e71f16e 100644
--- a/Jellyfin.Server.Implementations/JellyfinDb.cs
+++ b/Jellyfin.Server.Implementations/JellyfinDb.cs
@@ -1,15 +1,14 @@
#pragma warning disable CS1591
-using System;
using System.Linq;
-using Jellyfin.Data;
using Jellyfin.Data.Entities;
+using Jellyfin.Data.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations
{
/// <inheritdoc/>
- public partial class JellyfinDb : DbContext
+ public class JellyfinDb : DbContext
{
/// <summary>
/// Initializes a new instance of the <see cref="JellyfinDb"/> class.
@@ -130,7 +129,7 @@ namespace Jellyfin.Server.Implementations
foreach (var saveEntity in ChangeTracker.Entries()
.Where(e => e.State == EntityState.Modified)
.Select(entry => entry.Entity)
- .OfType<ISavingChanges>())
+ .OfType<IHasConcurrencyToken>())
{
saveEntity.OnSavingChanges();
}
@@ -138,47 +137,20 @@ namespace Jellyfin.Server.Implementations
return base.SaveChanges();
}
- /// <inheritdoc/>
- public override void Dispose()
- {
- foreach (var entry in ChangeTracker.Entries())
- {
- entry.State = EntityState.Detached;
- }
-
- GC.SuppressFinalize(this);
- base.Dispose();
- }
-
- /// <inheritdoc />
- protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
- {
- CustomInit(optionsBuilder);
- }
-
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
- OnModelCreatingImpl(modelBuilder);
modelBuilder.HasDefaultSchema("jellyfin");
- /*modelBuilder.Entity<Artwork>().HasIndex(t => t.Kind);
-
- modelBuilder.Entity<Genre>().HasIndex(t => t.Name)
- .IsUnique();
+ modelBuilder.Entity<DisplayPreferences>()
+ .HasIndex(entity => entity.UserId)
+ .IsUnique(false);
- modelBuilder.Entity<LibraryItem>().HasIndex(t => t.UrlId)
- .IsUnique();*/
-
- OnModelCreatedImpl(modelBuilder);
+ modelBuilder.Entity<DisplayPreferences>()
+ .HasIndex(entity => new { entity.UserId, entity.Client })
+ .IsUnique();
}
-
- partial void CustomInit(DbContextOptionsBuilder optionsBuilder);
-
- partial void OnModelCreatingImpl(ModelBuilder modelBuilder);
-
- partial void OnModelCreatedImpl(ModelBuilder modelBuilder);
}
}
diff --git a/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs
new file mode 100644
index 000000000..2234f9d5f
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.Designer.cs
@@ -0,0 +1,461 @@
+#pragma warning disable CS1591
+
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDb))]
+ [Migration("20200905220533_FixDisplayPreferencesIndex")]
+ partial class FixDisplayPreferencesIndex
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("jellyfin")
+ .HasAnnotation("ProductVersion", "3.1.7");
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasColumnType("TEXT")
+ .HasMaxLength(256);
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(512);
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT")
+ .HasMaxLength(512);
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasColumnType("TEXT")
+ .HasMaxLength(512);
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(256);
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("ActivityLogs");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(32);
+
+ b.Property<string>("DashboardTheme")
+ .HasColumnType("TEXT")
+ .HasMaxLength(32);
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasColumnType("TEXT")
+ .HasMaxLength(32);
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(512);
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(32);
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(64);
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Permission_Permissions_Guid");
+
+ b.ToTable("Permissions");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(65535);
+
+ b.HasKey("Id");
+
+ b.HasIndex("Preference_Preferences_Guid");
+
+ b.ToTable("Preferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("EasyPassword")
+ .HasColumnType("TEXT")
+ .HasMaxLength(65535);
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalAgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasColumnType("TEXT")
+ .HasMaxLength(65535);
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithOne("DisplayPreferences")
+ .HasForeignKey("Jellyfin.Data.Entities.DisplayPreferences", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("Permission_Permissions_Guid");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("Preference_Preferences_Guid");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.cs b/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.cs
new file mode 100644
index 000000000..33c5bb4ca
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20200905220533_FixDisplayPreferencesIndex.cs
@@ -0,0 +1,51 @@
+#pragma warning disable CS1591
+#pragma warning disable SA1601
+
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ public partial class FixDisplayPreferencesIndex : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_DisplayPreferences_UserId",
+ schema: "jellyfin",
+ table: "DisplayPreferences");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DisplayPreferences_UserId",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ column: "UserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DisplayPreferences_UserId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ columns: new[] { "UserId", "Client" },
+ unique: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_DisplayPreferences_UserId",
+ schema: "jellyfin",
+ table: "DisplayPreferences");
+
+ migrationBuilder.DropIndex(
+ name: "IX_DisplayPreferences_UserId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DisplayPreferences_UserId",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ column: "UserId",
+ unique: true);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs
new file mode 100644
index 000000000..e5c326a32
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.Designer.cs
@@ -0,0 +1,464 @@
+#pragma warning disable CS1591
+
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDb))]
+ [Migration("20201004171403_AddMaxActiveSessions")]
+ partial class AddMaxActiveSessions
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("jellyfin")
+ .HasAnnotation("ProductVersion", "3.1.8");
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasColumnType("TEXT")
+ .HasMaxLength(256);
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(512);
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT")
+ .HasMaxLength(512);
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasColumnType("TEXT")
+ .HasMaxLength(512);
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(256);
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("ActivityLogs");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(32);
+
+ b.Property<string>("DashboardTheme")
+ .HasColumnType("TEXT")
+ .HasMaxLength(32);
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasColumnType("TEXT")
+ .HasMaxLength(32);
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(512);
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(32);
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(64);
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Permission_Permissions_Guid");
+
+ b.ToTable("Permissions");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(65535);
+
+ b.HasKey("Id");
+
+ b.HasIndex("Preference_Preferences_Guid");
+
+ b.ToTable("Preferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("EasyPassword")
+ .HasColumnType("TEXT")
+ .HasMaxLength(65535);
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalAgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasColumnType("TEXT")
+ .HasMaxLength(65535);
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasMaxLength(255);
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithOne("DisplayPreferences")
+ .HasForeignKey("Jellyfin.Data.Entities.DisplayPreferences", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("Permission_Permissions_Guid");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("Preference_Preferences_Guid");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.cs b/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.cs
new file mode 100644
index 000000000..10acb4def
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20201004171403_AddMaxActiveSessions.cs
@@ -0,0 +1,28 @@
+#pragma warning disable CS1591
+#pragma warning disable SA1601
+
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ public partial class AddMaxActiveSessions : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<int>(
+ name: "MaxActiveSessions",
+ schema: "jellyfin",
+ table: "Users",
+ nullable: false,
+ defaultValue: 0);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "MaxActiveSessions",
+ schema: "jellyfin",
+ table: "Users");
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
index a6e6a2324..16d62f482 100644
--- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
+++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("jellyfin")
- .HasAnnotation("ProductVersion", "3.1.6");
+ .HasAnnotation("ProductVersion", "3.1.8");
modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
{
@@ -136,7 +136,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("Id");
- b.HasIndex("UserId")
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "Client")
.IsUnique();
b.ToTable("DisplayPreferences");
@@ -342,6 +344,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<int?>("LoginAttemptsBeforeLockout")
.HasColumnType("INTEGER");
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
b.Property<int?>("MaxParentalAgeRating")
.HasColumnType("INTEGER");
diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs
index 140853e52..1fb89c4a6 100644
--- a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs
+++ b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs
@@ -4,12 +4,12 @@
using System.Threading.Tasks;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
+using Jellyfin.Data.Events;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Security;
using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.Events;
namespace Jellyfin.Server.Implementations.Users
{
diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
index 7c5c5a3ec..76f943385 100644
--- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
+++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
@@ -14,22 +14,21 @@ namespace Jellyfin.Server.Implementations.Users
/// </summary>
public class DisplayPreferencesManager : IDisplayPreferencesManager
{
- private readonly JellyfinDbProvider _dbProvider;
+ private readonly JellyfinDb _dbContext;
/// <summary>
/// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class.
/// </summary>
- /// <param name="dbProvider">The Jellyfin db provider.</param>
- public DisplayPreferencesManager(JellyfinDbProvider dbProvider)
+ /// <param name="dbContext">The database context.</param>
+ public DisplayPreferencesManager(JellyfinDb dbContext)
{
- _dbProvider = dbProvider;
+ _dbContext = dbContext;
}
/// <inheritdoc />
public DisplayPreferences GetDisplayPreferences(Guid userId, string client)
{
- using var dbContext = _dbProvider.CreateContext();
- var prefs = dbContext.DisplayPreferences
+ var prefs = _dbContext.DisplayPreferences
.Include(pref => pref.HomeSections)
.FirstOrDefault(pref =>
pref.UserId == userId && string.Equals(pref.Client, client));
@@ -37,7 +36,7 @@ namespace Jellyfin.Server.Implementations.Users
if (prefs == null)
{
prefs = new DisplayPreferences(userId, client);
- dbContext.DisplayPreferences.Add(prefs);
+ _dbContext.DisplayPreferences.Add(prefs);
}
return prefs;
@@ -46,14 +45,13 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc />
public ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client)
{
- using var dbContext = _dbProvider.CreateContext();
- var prefs = dbContext.ItemDisplayPreferences
+ var prefs = _dbContext.ItemDisplayPreferences
.FirstOrDefault(pref => pref.UserId == userId && pref.ItemId == itemId && string.Equals(pref.Client, client));
if (prefs == null)
{
prefs = new ItemDisplayPreferences(userId, Guid.Empty, client);
- dbContext.ItemDisplayPreferences.Add(prefs);
+ _dbContext.ItemDisplayPreferences.Add(prefs);
}
return prefs;
@@ -62,27 +60,16 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc />
public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client)
{
- using var dbContext = _dbProvider.CreateContext();
-
- return dbContext.ItemDisplayPreferences
+ return _dbContext.ItemDisplayPreferences
+ .AsQueryable()
.Where(prefs => prefs.UserId == userId && prefs.ItemId != Guid.Empty && string.Equals(prefs.Client, client))
.ToList();
}
/// <inheritdoc />
- public void SaveChanges(DisplayPreferences preferences)
- {
- using var dbContext = _dbProvider.CreateContext();
- dbContext.Update(preferences);
- dbContext.SaveChanges();
- }
-
- /// <inheritdoc />
- public void SaveChanges(ItemDisplayPreferences preferences)
+ public void SaveChanges()
{
- using var dbContext = _dbProvider.CreateContext();
- dbContext.Update(preferences);
- dbContext.SaveChanges();
+ _dbContext.SaveChanges();
}
}
}
diff --git a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
index e38cd07f0..5f32479e1 100644
--- a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Users
public string Name => "InvalidOrMissingAuthenticationProvider";
/// <inheritdoc />
- public bool IsEnabled => true;
+ public bool IsEnabled => false;
/// <inheritdoc />
public Task<ProviderAuthenticationResult> Authenticate(string username, string password)
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 11402ee05..40b89ed28 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -2,6 +2,7 @@
#pragma warning disable CA1307
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -10,18 +11,20 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
+using Jellyfin.Data.Events;
+using Jellyfin.Data.Events.Users;
using MediaBrowser.Common;
using MediaBrowser.Common.Cryptography;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Cryptography;
using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Events;
using MediaBrowser.Model.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -34,6 +37,7 @@ namespace Jellyfin.Server.Implementations.Users
public class UserManager : IUserManager
{
private readonly JellyfinDbProvider _dbProvider;
+ private readonly IEventManager _eventManager;
private readonly ICryptoProvider _cryptoProvider;
private readonly INetworkManager _networkManager;
private readonly IApplicationHost _appHost;
@@ -45,10 +49,13 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider;
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
+ private readonly IDictionary<Guid, User> _users;
+
/// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class.
/// </summary>
/// <param name="dbProvider">The database provider.</param>
+ /// <param name="eventManager">The event manager.</param>
/// <param name="cryptoProvider">The cryptography provider.</param>
/// <param name="networkManager">The network manager.</param>
/// <param name="appHost">The application host.</param>
@@ -56,6 +63,7 @@ namespace Jellyfin.Server.Implementations.Users
/// <param name="logger">The logger.</param>
public UserManager(
JellyfinDbProvider dbProvider,
+ IEventManager eventManager,
ICryptoProvider cryptoProvider,
INetworkManager networkManager,
IApplicationHost appHost,
@@ -63,6 +71,7 @@ namespace Jellyfin.Server.Implementations.Users
ILogger<UserManager> logger)
{
_dbProvider = dbProvider;
+ _eventManager = eventManager;
_cryptoProvider = cryptoProvider;
_networkManager = networkManager;
_appHost = appHost;
@@ -75,49 +84,28 @@ namespace Jellyfin.Server.Implementations.Users
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
- }
- /// <inheritdoc/>
- public event EventHandler<GenericEventArgs<User>>? OnUserPasswordChanged;
+ _users = new ConcurrentDictionary<Guid, User>();
+ using var dbContext = _dbProvider.CreateContext();
+ foreach (var user in dbContext.Users
+ .Include(user => user.Permissions)
+ .Include(user => user.Preferences)
+ .Include(user => user.AccessSchedules)
+ .Include(user => user.ProfileImage)
+ .AsEnumerable())
+ {
+ _users.Add(user.Id, user);
+ }
+ }
/// <inheritdoc/>
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
/// <inheritdoc/>
- public event EventHandler<GenericEventArgs<User>>? OnUserCreated;
-
- /// <inheritdoc/>
- public event EventHandler<GenericEventArgs<User>>? OnUserDeleted;
-
- /// <inheritdoc/>
- public event EventHandler<GenericEventArgs<User>>? OnUserLockedOut;
-
- /// <inheritdoc/>
- public IEnumerable<User> Users
- {
- get
- {
- using var dbContext = _dbProvider.CreateContext();
- return dbContext.Users
- .Include(user => user.Permissions)
- .Include(user => user.Preferences)
- .Include(user => user.AccessSchedules)
- .Include(user => user.ProfileImage)
- .ToList();
- }
- }
+ public IEnumerable<User> Users => _users.Values;
/// <inheritdoc/>
- public IEnumerable<Guid> UsersIds
- {
- get
- {
- using var dbContext = _dbProvider.CreateContext();
- return dbContext.Users
- .Select(user => user.Id)
- .ToList();
- }
- }
+ public IEnumerable<Guid> UsersIds => _users.Keys;
/// <inheritdoc/>
public User? GetUserById(Guid id)
@@ -127,13 +115,8 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Guid can't be empty", nameof(id));
}
- using var dbContext = _dbProvider.CreateContext();
- return dbContext.Users
- .Include(user => user.Permissions)
- .Include(user => user.Preferences)
- .Include(user => user.AccessSchedules)
- .Include(user => user.ProfileImage)
- .FirstOrDefault(user => user.Id == id);
+ _users.TryGetValue(id, out var user);
+ return user;
}
/// <inheritdoc/>
@@ -144,14 +127,7 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Invalid username", nameof(name));
}
- using var dbContext = _dbProvider.CreateContext();
- return dbContext.Users
- .Include(user => user.Permissions)
- .Include(user => user.Preferences)
- .Include(user => user.AccessSchedules)
- .Include(user => user.ProfileImage)
- .AsEnumerable()
- .FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
+ return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
}
/// <inheritdoc/>
@@ -206,17 +182,21 @@ namespace Jellyfin.Server.Implementations.Users
internal async Task<User> CreateUserInternalAsync(string name, JellyfinDb dbContext)
{
// TODO: Remove after user item data is migrated.
- var max = await dbContext.Users.AnyAsync().ConfigureAwait(false)
- ? await dbContext.Users.Select(u => u.InternalId).MaxAsync().ConfigureAwait(false)
+ var max = await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false)
+ ? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false)
: 0;
- return new User(
+ var user = new User(
name,
_defaultAuthenticationProvider.GetType().FullName,
_defaultPasswordResetProvider.GetType().FullName)
{
InternalId = max + 1
};
+
+ _users.Add(user.Id, user);
+
+ return user;
}
/// <inheritdoc/>
@@ -227,14 +207,14 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)");
}
- using var dbContext = _dbProvider.CreateContext();
+ await using var dbContext = _dbProvider.CreateContext();
var newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
- OnUserCreated?.Invoke(this, new GenericEventArgs<User>(newUser));
+ await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
return newUser;
}
@@ -242,28 +222,12 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public void DeleteUser(Guid userId)
{
- using var dbContext = _dbProvider.CreateContext();
- var user = dbContext.Users
- .Include(u => u.Permissions)
- .Include(u => u.Preferences)
- .Include(u => u.AccessSchedules)
- .Include(u => u.ProfileImage)
- .FirstOrDefault(u => u.Id == userId);
- if (user == null)
+ if (!_users.TryGetValue(userId, out var user))
{
throw new ResourceNotFoundException(nameof(userId));
}
- if (dbContext.Users.Find(user.Id) == null)
- {
- throw new ArgumentException(string.Format(
- CultureInfo.InvariantCulture,
- "The user cannot be deleted because there is no user with the Name {0} and Id {1}.",
- user.Username,
- user.Id));
- }
-
- if (dbContext.Users.Count() == 1)
+ if (_users.Count == 1)
{
throw new InvalidOperationException(string.Format(
CultureInfo.InvariantCulture,
@@ -282,6 +246,8 @@ namespace Jellyfin.Server.Implementations.Users
nameof(userId));
}
+ using var dbContext = _dbProvider.CreateContext();
+
// Clear all entities related to the user from the database.
if (user.ProfileImage != null)
{
@@ -293,7 +259,9 @@ namespace Jellyfin.Server.Implementations.Users
dbContext.RemoveRange(user.AccessSchedules);
dbContext.Users.Remove(user);
dbContext.SaveChanges();
- OnUserDeleted?.Invoke(this, new GenericEventArgs<User>(user));
+ _users.Remove(userId);
+
+ _eventManager.Publish(new UserDeletedEventArgs(user));
}
/// <inheritdoc/>
@@ -319,7 +287,7 @@ namespace Jellyfin.Server.Implementations.Users
await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
await UpdateUserAsync(user).ConfigureAwait(false);
- OnUserPasswordChanged?.Invoke(this, new GenericEventArgs<User>(user));
+ await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -338,7 +306,7 @@ namespace Jellyfin.Server.Implementations.Users
user.EasyPassword = newPasswordSha1;
UpdateUser(user);
- OnUserPasswordChanged?.Invoke(this, new GenericEventArgs<User>(user));
+ _eventManager.Publish(new UserPasswordChangedEventArgs(user));
}
/// <inheritdoc/>
@@ -384,6 +352,7 @@ namespace Jellyfin.Server.Implementations.Users
PasswordResetProviderId = user.PasswordResetProviderId,
InvalidLoginAttemptCount = user.InvalidLoginAttemptCount,
LoginAttemptsBeforeLockout = user.LoginAttemptsBeforeLockout ?? -1,
+ MaxActiveSessions = user.MaxActiveSessions,
IsAdministrator = user.HasPermission(PermissionKind.IsAdministrator),
IsHidden = user.HasPermission(PermissionKind.IsHidden),
IsDisabled = user.HasPermission(PermissionKind.IsDisabled),
@@ -407,13 +376,13 @@ namespace Jellyfin.Server.Implementations.Users
EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing),
AccessSchedules = user.AccessSchedules.ToArray(),
BlockedTags = user.GetPreference(PreferenceKind.BlockedTags),
- EnabledChannels = user.GetPreference(PreferenceKind.EnabledChannels),
+ EnabledChannels = user.GetPreference(PreferenceKind.EnabledChannels)?.Select(Guid.Parse).ToArray(),
EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices),
- EnabledFolders = user.GetPreference(PreferenceKind.EnabledFolders),
+ EnabledFolders = user.GetPreference(PreferenceKind.EnabledFolders)?.Select(Guid.Parse).ToArray(),
EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders),
SyncPlayAccess = user.SyncPlayAccess,
- BlockedChannels = user.GetPreference(PreferenceKind.BlockedChannels),
- BlockedMediaFolders = user.GetPreference(PreferenceKind.BlockedMediaFolders),
+ BlockedChannels = user.GetPreference(PreferenceKind.BlockedChannels)?.Select(Guid.Parse).ToArray(),
+ BlockedMediaFolders = user.GetPreference(PreferenceKind.BlockedMediaFolders)?.Select(Guid.Parse).ToArray(),
BlockUnratedItems = user.GetPreference(PreferenceKind.BlockUnratedItems).Select(Enum.Parse<UnratedItem>).ToArray()
}
};
@@ -463,11 +432,9 @@ namespace Jellyfin.Server.Implementations.Users
// the authentication provider might have created it
user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
- if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy)
+ if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user != null)
{
- UpdatePolicy(user.Id, hasNewUserPolicy.GetNewUserPolicy());
-
- await UpdateUserAsync(user).ConfigureAwait(false);
+ await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
}
}
}
@@ -592,9 +559,7 @@ namespace Jellyfin.Server.Implementations.Users
public async Task InitializeAsync()
{
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
- using var dbContext = _dbProvider.CreateContext();
-
- if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
+ if (_users.Any())
{
return;
}
@@ -607,6 +572,7 @@ namespace Jellyfin.Server.Implementations.Users
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
+ await using var dbContext = _dbProvider.CreateContext();
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
newUser.SetPermission(PermissionKind.IsAdministrator, true);
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
@@ -647,9 +613,9 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc/>
- public void UpdateConfiguration(Guid userId, UserConfiguration config)
+ public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
{
- using var dbContext = _dbProvider.CreateContext();
+ await using var dbContext = _dbProvider.CreateContext();
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
@@ -676,13 +642,13 @@ namespace Jellyfin.Server.Implementations.Users
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
dbContext.Update(user);
- dbContext.SaveChanges();
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
- public void UpdatePolicy(Guid userId, UserPolicy policy)
+ public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
{
- using var dbContext = _dbProvider.CreateContext();
+ await using var dbContext = _dbProvider.CreateContext();
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
@@ -706,6 +672,7 @@ namespace Jellyfin.Server.Implementations.Users
user.PasswordResetProviderId = policy.PasswordResetProviderId;
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
+ user.MaxActiveSessions = policy.MaxActiveSessions;
user.SyncPlayAccess = policy.SyncPlayAccess;
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
@@ -740,21 +707,22 @@ namespace Jellyfin.Server.Implementations.Users
PreferenceKind.BlockUnratedItems,
policy.BlockUnratedItems?.Select(i => i.ToString()).ToArray() ?? Array.Empty<string>());
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
- user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
+ user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray());
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
- user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
+ user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray());
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
dbContext.Update(user);
- dbContext.SaveChanges();
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
- public void ClearProfileImage(User user)
+ public async Task ClearProfileImageAsync(User user)
{
- using var dbContext = _dbProvider.CreateContext();
+ await using var dbContext = _dbProvider.CreateContext();
dbContext.Remove(user.ProfileImage);
- dbContext.SaveChanges();
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ user.ProfileImage = null;
}
private static bool IsValidUsername(string name)
@@ -804,7 +772,7 @@ namespace Jellyfin.Server.Implementations.Users
private IList<IPasswordResetProvider> GetPasswordResetProviders(User user)
{
- var passwordResetProviderId = user?.PasswordResetProviderId;
+ var passwordResetProviderId = user.PasswordResetProviderId;
var providers = _passwordResetProviders.Where(i => i.IsEnabled).ToArray();
if (!string.IsNullOrEmpty(passwordResetProviderId))
@@ -901,7 +869,7 @@ namespace Jellyfin.Server.Implementations.Users
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
- OnUserLockedOut?.Invoke(this, new GenericEventArgs<User>(user));
+ await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
_logger.LogWarning(
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
user.Username,