aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations
diff options
context:
space:
mode:
authorcrobibero <cody@robibe.ro>2020-09-11 15:53:04 -0600
committercrobibero <cody@robibe.ro>2020-09-11 15:53:04 -0600
commitf13b87afa3e81e7fa2710caec58a7d6cb20f7635 (patch)
tree0f269ac5baa27b334c5377d9dec4010aedf25183 /Jellyfin.Server.Implementations
parent2363ad544979adf32207fa927f106fadb784f1fb (diff)
parent6bf0acb854683377bebad3ca27de17706519c420 (diff)
Merge remote-tracking branch 'upstream/master' into api-upload-subtitle
Diffstat (limited to 'Jellyfin.Server.Implementations')
-rw-r--r--Jellyfin.Server.Implementations/Activity/ActivityManager.cs93
-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.cs31
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationCancelledNotifier.cs31
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedLogger.cs51
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedNotifier.cs31
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledLogger.cs50
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledNotifier.cs31
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallingNotifier.cs31
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledLogger.cs45
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledNotifier.cs31
-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.cs38
-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.cs41
-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.csproj43
-rw-r--r--Jellyfin.Server.Implementations/JellyfinDb.cs156
-rw-r--r--Jellyfin.Server.Implementations/JellyfinDbProvider.cs41
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs72
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.cs46
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs312
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.cs197
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs459
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs132
-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/DesignTimeJellyfinDbFactory.cs20
-rw-r--r--Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs457
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs120
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs137
-rw-r--r--Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs67
-rw-r--r--Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs74
-rw-r--r--Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs38
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs909
47 files changed, 5366 insertions, 0 deletions
diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
new file mode 100644
index 000000000..abdd290d4
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Events;
+using MediaBrowser.Model.Activity;
+using MediaBrowser.Model.Querying;
+
+namespace Jellyfin.Server.Implementations.Activity
+{
+ /// <summary>
+ /// Manages the storage and retrieval of <see cref="ActivityLog"/> instances.
+ /// </summary>
+ public class ActivityManager : IActivityManager
+ {
+ private readonly JellyfinDbProvider _provider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ActivityManager"/> class.
+ /// </summary>
+ /// <param name="provider">The Jellyfin database provider.</param>
+ public ActivityManager(JellyfinDbProvider provider)
+ {
+ _provider = provider;
+ }
+
+ /// <inheritdoc/>
+ public event EventHandler<GenericEventArgs<ActivityLogEntry>> EntryCreated;
+
+ /// <inheritdoc/>
+ public async Task CreateAsync(ActivityLog entry)
+ {
+ await using var dbContext = _provider.CreateContext();
+
+ dbContext.ActivityLogs.Add(entry);
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+
+ EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry)));
+ }
+
+ /// <inheritdoc/>
+ public QueryResult<ActivityLogEntry> GetPagedResult(
+ Func<IQueryable<ActivityLog>, IQueryable<ActivityLog>> func,
+ int? startIndex,
+ int? limit)
+ {
+ using var dbContext = _provider.CreateContext();
+
+ var query = func(dbContext.ActivityLogs.OrderByDescending(entry => entry.DateCreated));
+
+ if (startIndex.HasValue)
+ {
+ query = query.Skip(startIndex.Value);
+ }
+
+ if (limit.HasValue)
+ {
+ query = query.Take(limit.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()
+ };
+ }
+
+ /// <inheritdoc/>
+ public QueryResult<ActivityLogEntry> GetPagedResult(int? startIndex, int? limit)
+ {
+ return GetPagedResult(logs => logs, startIndex, limit);
+ }
+
+ private static ActivityLogEntry ConvertToOldModel(ActivityLog entry)
+ {
+ return new ActivityLogEntry
+ {
+ Id = entry.Id,
+ Name = entry.Name,
+ Overview = entry.Overview,
+ ShortOverview = entry.ShortOverview,
+ Type = entry.Type,
+ ItemId = entry.ItemId,
+ UserId = entry.UserId,
+ Date = entry.DateCreated,
+ Severity = entry.LogSeverity
+ };
+ }
+ }
+}
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..80ed56cd8
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/System/TaskCompletedNotifier.cs
@@ -0,0 +1,31 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.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("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..1c600683a
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationCancelledNotifier.cs
@@ -0,0 +1,31 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Controller.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("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..ea0c878d4
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallationFailedNotifier.cs
@@ -0,0 +1,31 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Updates;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.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("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..3dda5a04c
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstalledNotifier.cs
@@ -0,0 +1,31 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Controller.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("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..f691d11a7
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginInstallingNotifier.cs
@@ -0,0 +1,31 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Controller.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("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..709692f6b
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Updates/PluginUninstalledNotifier.cs
@@ -0,0 +1,31 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Events.Updates;
+using MediaBrowser.Controller.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("PluginUninstalled", 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..10367a939
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserDeletedNotifier.cs
@@ -0,0 +1,38 @@
+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;
+
+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 },
+ "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..6081dd044
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Users/UserUpdatedNotifier.cs
@@ -0,0 +1,41 @@
+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;
+
+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 },
+ "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
new file mode 100644
index 000000000..30ed3e6af
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
@@ -0,0 +1,43 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>netcoreapp3.1</TargetFramework>
+ <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ </PropertyGroup>
+
+ <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
+ </PropertyGroup>
+
+ <!-- Code analysers-->
+ <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
+ <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
+ <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
+ <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Compile Include="..\SharedVersion.cs" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.7">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.7">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\Jellyfin.Data\Jellyfin.Data.csproj" />
+ <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
+ <ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/Jellyfin.Server.Implementations/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs
new file mode 100644
index 000000000..45e71f16e
--- /dev/null
+++ b/Jellyfin.Server.Implementations/JellyfinDb.cs
@@ -0,0 +1,156 @@
+#pragma warning disable CS1591
+
+using System.Linq;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Server.Implementations
+{
+ /// <inheritdoc/>
+ public class JellyfinDb : DbContext
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinDb"/> class.
+ /// </summary>
+ /// <param name="options">The database context options.</param>
+ public JellyfinDb(DbContextOptions<JellyfinDb> options) : base(options)
+ {
+ }
+
+ /// <summary>
+ /// Gets or sets the default connection string.
+ /// </summary>
+ public static string ConnectionString { get; set; } = @"Data Source=jellyfin.db";
+
+ public virtual DbSet<AccessSchedule> AccessSchedules { get; set; }
+
+ public virtual DbSet<ActivityLog> ActivityLogs { get; set; }
+
+ public virtual DbSet<DisplayPreferences> DisplayPreferences { get; set; }
+
+ public virtual DbSet<ImageInfo> ImageInfos { get; set; }
+
+ public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; }
+
+ public virtual DbSet<Permission> Permissions { get; set; }
+
+ public virtual DbSet<Preference> Preferences { get; set; }
+
+ public virtual DbSet<User> Users { get; set; }
+
+ /*public virtual DbSet<Artwork> Artwork { get; set; }
+
+ public virtual DbSet<Book> Books { get; set; }
+
+ public virtual DbSet<BookMetadata> BookMetadata { get; set; }
+
+ public virtual DbSet<Chapter> Chapters { get; set; }
+
+ public virtual DbSet<Collection> Collections { get; set; }
+
+ public virtual DbSet<CollectionItem> CollectionItems { get; set; }
+
+ public virtual DbSet<Company> Companies { get; set; }
+
+ public virtual DbSet<CompanyMetadata> CompanyMetadata { get; set; }
+
+ public virtual DbSet<CustomItem> CustomItems { get; set; }
+
+ public virtual DbSet<CustomItemMetadata> CustomItemMetadata { get; set; }
+
+ public virtual DbSet<Episode> Episodes { get; set; }
+
+ public virtual DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
+
+ public virtual DbSet<Genre> Genres { get; set; }
+
+ public virtual DbSet<Group> Groups { get; set; }
+
+ public virtual DbSet<Library> Libraries { get; set; }
+
+ public virtual DbSet<LibraryItem> LibraryItems { get; set; }
+
+ public virtual DbSet<LibraryRoot> LibraryRoot { get; set; }
+
+ public virtual DbSet<MediaFile> MediaFiles { get; set; }
+
+ public virtual DbSet<MediaFileStream> MediaFileStream { get; set; }
+
+ public virtual DbSet<Metadata> Metadata { get; set; }
+
+ public virtual DbSet<MetadataProvider> MetadataProviders { get; set; }
+
+ public virtual DbSet<MetadataProviderId> MetadataProviderIds { get; set; }
+
+ public virtual DbSet<Movie> Movies { get; set; }
+
+ public virtual DbSet<MovieMetadata> MovieMetadata { get; set; }
+
+ public virtual DbSet<MusicAlbum> MusicAlbums { get; set; }
+
+ public virtual DbSet<MusicAlbumMetadata> MusicAlbumMetadata { get; set; }
+
+ public virtual DbSet<Person> People { get; set; }
+
+ public virtual DbSet<PersonRole> PersonRoles { get; set; }
+
+ public virtual DbSet<Photo> Photo { get; set; }
+
+ public virtual DbSet<PhotoMetadata> PhotoMetadata { get; set; }
+
+ public virtual DbSet<ProviderMapping> ProviderMappings { get; set; }
+
+ public virtual DbSet<Rating> Ratings { get; set; }
+
+ /// <summary>
+ /// Repository for global::Jellyfin.Data.Entities.RatingSource - This is the entity to
+ /// store review ratings, not age ratings.
+ /// </summary>
+ public virtual DbSet<RatingSource> RatingSources { get; set; }
+
+ public virtual DbSet<Release> Releases { get; set; }
+
+ public virtual DbSet<Season> Seasons { get; set; }
+
+ public virtual DbSet<SeasonMetadata> SeasonMetadata { get; set; }
+
+ public virtual DbSet<Series> Series { get; set; }
+
+ public virtual DbSet<SeriesMetadata> SeriesMetadata { get; set; }
+
+ public virtual DbSet<Track> Tracks { get; set; }
+
+ public virtual DbSet<TrackMetadata> TrackMetadata { get; set; }*/
+
+ /// <inheritdoc/>
+ public override int SaveChanges()
+ {
+ foreach (var saveEntity in ChangeTracker.Entries()
+ .Where(e => e.State == EntityState.Modified)
+ .Select(entry => entry.Entity)
+ .OfType<IHasConcurrencyToken>())
+ {
+ saveEntity.OnSavingChanges();
+ }
+
+ return base.SaveChanges();
+ }
+
+ /// <inheritdoc />
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.HasDefaultSchema("jellyfin");
+
+ modelBuilder.Entity<DisplayPreferences>()
+ .HasIndex(entity => entity.UserId)
+ .IsUnique(false);
+
+ modelBuilder.Entity<DisplayPreferences>()
+ .HasIndex(entity => new { entity.UserId, entity.Client })
+ .IsUnique();
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/JellyfinDbProvider.cs b/Jellyfin.Server.Implementations/JellyfinDbProvider.cs
new file mode 100644
index 000000000..486be6053
--- /dev/null
+++ b/Jellyfin.Server.Implementations/JellyfinDbProvider.cs
@@ -0,0 +1,41 @@
+using System;
+using System.IO;
+using MediaBrowser.Common.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Jellyfin.Server.Implementations
+{
+ /// <summary>
+ /// Factory class for generating new <see cref="JellyfinDb"/> instances.
+ /// </summary>
+ public class JellyfinDbProvider
+ {
+ private readonly IServiceProvider _serviceProvider;
+ private readonly IApplicationPaths _appPaths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinDbProvider"/> class.
+ /// </summary>
+ /// <param name="serviceProvider">The application's service provider.</param>
+ /// <param name="appPaths">The application paths.</param>
+ public JellyfinDbProvider(IServiceProvider serviceProvider, IApplicationPaths appPaths)
+ {
+ _serviceProvider = serviceProvider;
+ _appPaths = appPaths;
+
+ using var jellyfinDb = CreateContext();
+ jellyfinDb.Database.Migrate();
+ }
+
+ /// <summary>
+ /// Creates a new <see cref="JellyfinDb"/> context.
+ /// </summary>
+ /// <returns>The newly created context.</returns>
+ public JellyfinDb CreateContext()
+ {
+ var contextOptions = new DbContextOptionsBuilder<JellyfinDb>().UseSqlite($"Filename={Path.Combine(_appPaths.DataPath, "jellyfin.db")}");
+ return ActivatorUtilities.CreateInstance<JellyfinDb>(_serviceProvider, contextOptions.Options);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs
new file mode 100644
index 000000000..98a83b745
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.Designer.cs
@@ -0,0 +1,72 @@
+#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("20200514181226_AddActivityLog")]
+ partial class AddActivityLog
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("jellyfin")
+ .HasAnnotation("ProductVersion", "3.1.3");
+
+ 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");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.cs b/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.cs
new file mode 100644
index 000000000..5e0b454d8
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20200514181226_AddActivityLog.cs
@@ -0,0 +1,46 @@
+#pragma warning disable CS1591
+#pragma warning disable SA1601
+
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ public partial class AddActivityLog : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "jellyfin");
+
+ migrationBuilder.CreateTable(
+ name: "ActivityLogs",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Name = table.Column<string>(maxLength: 512, nullable: false),
+ Overview = table.Column<string>(maxLength: 512, nullable: true),
+ ShortOverview = table.Column<string>(maxLength: 512, nullable: true),
+ Type = table.Column<string>(maxLength: 256, nullable: false),
+ UserId = table.Column<Guid>(nullable: false),
+ ItemId = table.Column<string>(maxLength: 256, nullable: true),
+ DateCreated = table.Column<DateTime>(nullable: false),
+ LogSeverity = table.Column<int>(nullable: false),
+ RowVersion = table.Column<uint>(nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ActivityLogs", x => x.Id);
+ });
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "ActivityLogs",
+ schema: "jellyfin");
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs
new file mode 100644
index 000000000..6342ce9cf
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.Designer.cs
@@ -0,0 +1,312 @@
+#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("20200613202153_AddUsers")]
+ partial class AddUsers
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("jellyfin")
+ .HasAnnotation("ProductVersion", "3.1.4");
+
+ 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.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.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.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId");
+ });
+
+ 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/20200613202153_AddUsers.cs b/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.cs
new file mode 100644
index 000000000..7e5a76850
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20200613202153_AddUsers.cs
@@ -0,0 +1,197 @@
+#pragma warning disable CS1591
+#pragma warning disable SA1601
+
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ public partial class AddUsers : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Users",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<Guid>(nullable: false),
+ Username = table.Column<string>(maxLength: 255, nullable: false),
+ Password = table.Column<string>(maxLength: 65535, nullable: true),
+ EasyPassword = table.Column<string>(maxLength: 65535, nullable: true),
+ MustUpdatePassword = table.Column<bool>(nullable: false),
+ AudioLanguagePreference = table.Column<string>(maxLength: 255, nullable: true),
+ AuthenticationProviderId = table.Column<string>(maxLength: 255, nullable: false),
+ PasswordResetProviderId = table.Column<string>(maxLength: 255, nullable: false),
+ InvalidLoginAttemptCount = table.Column<int>(nullable: false),
+ LastActivityDate = table.Column<DateTime>(nullable: true),
+ LastLoginDate = table.Column<DateTime>(nullable: true),
+ LoginAttemptsBeforeLockout = table.Column<int>(nullable: true),
+ SubtitleMode = table.Column<int>(nullable: false),
+ PlayDefaultAudioTrack = table.Column<bool>(nullable: false),
+ SubtitleLanguagePreference = table.Column<string>(maxLength: 255, nullable: true),
+ DisplayMissingEpisodes = table.Column<bool>(nullable: false),
+ DisplayCollectionsView = table.Column<bool>(nullable: false),
+ EnableLocalPassword = table.Column<bool>(nullable: false),
+ HidePlayedInLatest = table.Column<bool>(nullable: false),
+ RememberAudioSelections = table.Column<bool>(nullable: false),
+ RememberSubtitleSelections = table.Column<bool>(nullable: false),
+ EnableNextEpisodeAutoPlay = table.Column<bool>(nullable: false),
+ EnableAutoLogin = table.Column<bool>(nullable: false),
+ EnableUserPreferenceAccess = table.Column<bool>(nullable: false),
+ MaxParentalAgeRating = table.Column<int>(nullable: true),
+ RemoteClientBitrateLimit = table.Column<int>(nullable: true),
+ InternalId = table.Column<long>(nullable: false),
+ SyncPlayAccess = table.Column<int>(nullable: false),
+ RowVersion = table.Column<uint>(nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "AccessSchedules",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ UserId = table.Column<Guid>(nullable: false),
+ DayOfWeek = table.Column<int>(nullable: false),
+ StartHour = table.Column<double>(nullable: false),
+ EndHour = table.Column<double>(nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AccessSchedules", x => x.Id);
+ table.ForeignKey(
+ name: "FK_AccessSchedules_Users_UserId",
+ column: x => x.UserId,
+ principalSchema: "jellyfin",
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "ImageInfos",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ UserId = table.Column<Guid>(nullable: true),
+ Path = table.Column<string>(maxLength: 512, nullable: false),
+ LastModified = table.Column<DateTime>(nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ImageInfos", x => x.Id);
+ table.ForeignKey(
+ name: "FK_ImageInfos_Users_UserId",
+ column: x => x.UserId,
+ principalSchema: "jellyfin",
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Permissions",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Kind = table.Column<int>(nullable: false),
+ Value = table.Column<bool>(nullable: false),
+ RowVersion = table.Column<uint>(nullable: false),
+ Permission_Permissions_Guid = table.Column<Guid>(nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Permissions", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Permissions_Users_Permission_Permissions_Guid",
+ column: x => x.Permission_Permissions_Guid,
+ principalSchema: "jellyfin",
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Preferences",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Kind = table.Column<int>(nullable: false),
+ Value = table.Column<string>(maxLength: 65535, nullable: false),
+ RowVersion = table.Column<uint>(nullable: false),
+ Preference_Preferences_Guid = table.Column<Guid>(nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Preferences", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Preferences_Users_Preference_Preferences_Guid",
+ column: x => x.Preference_Preferences_Guid,
+ principalSchema: "jellyfin",
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AccessSchedules_UserId",
+ schema: "jellyfin",
+ table: "AccessSchedules",
+ column: "UserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ImageInfos_UserId",
+ schema: "jellyfin",
+ table: "ImageInfos",
+ column: "UserId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Permissions_Permission_Permissions_Guid",
+ schema: "jellyfin",
+ table: "Permissions",
+ column: "Permission_Permissions_Guid");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Preferences_Preference_Preferences_Guid",
+ schema: "jellyfin",
+ table: "Preferences",
+ column: "Preference_Preferences_Guid");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "AccessSchedules",
+ schema: "jellyfin");
+
+ migrationBuilder.DropTable(
+ name: "ImageInfos",
+ schema: "jellyfin");
+
+ migrationBuilder.DropTable(
+ name: "Permissions",
+ schema: "jellyfin");
+
+ migrationBuilder.DropTable(
+ name: "Preferences",
+ schema: "jellyfin");
+
+ migrationBuilder.DropTable(
+ name: "Users",
+ schema: "jellyfin");
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs
new file mode 100644
index 000000000..d44707d06
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.Designer.cs
@@ -0,0 +1,459 @@
+#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("20200728005145_AddDisplayPreferences")]
+ partial class AddDisplayPreferences
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("jellyfin")
+ .HasAnnotation("ProductVersion", "3.1.6");
+
+ 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")
+ .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/20200728005145_AddDisplayPreferences.cs b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs
new file mode 100644
index 000000000..3009f0b61
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20200728005145_AddDisplayPreferences.cs
@@ -0,0 +1,132 @@
+#pragma warning disable CS1591
+#pragma warning disable SA1601
+
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ public partial class AddDisplayPreferences : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DisplayPreferences",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ UserId = table.Column<Guid>(nullable: false),
+ Client = table.Column<string>(maxLength: 32, nullable: false),
+ ShowSidebar = table.Column<bool>(nullable: false),
+ ShowBackdrop = table.Column<bool>(nullable: false),
+ ScrollDirection = table.Column<int>(nullable: false),
+ IndexBy = table.Column<int>(nullable: true),
+ SkipForwardLength = table.Column<int>(nullable: false),
+ SkipBackwardLength = table.Column<int>(nullable: false),
+ ChromecastVersion = table.Column<int>(nullable: false),
+ EnableNextVideoInfoOverlay = table.Column<bool>(nullable: false),
+ DashboardTheme = table.Column<string>(maxLength: 32, nullable: true),
+ TvHome = table.Column<string>(maxLength: 32, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DisplayPreferences", x => x.Id);
+ table.ForeignKey(
+ name: "FK_DisplayPreferences_Users_UserId",
+ column: x => x.UserId,
+ principalSchema: "jellyfin",
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "ItemDisplayPreferences",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ UserId = table.Column<Guid>(nullable: false),
+ ItemId = table.Column<Guid>(nullable: false),
+ Client = table.Column<string>(maxLength: 32, nullable: false),
+ ViewType = table.Column<int>(nullable: false),
+ RememberIndexing = table.Column<bool>(nullable: false),
+ IndexBy = table.Column<int>(nullable: true),
+ RememberSorting = table.Column<bool>(nullable: false),
+ SortBy = table.Column<string>(maxLength: 64, nullable: false),
+ SortOrder = table.Column<int>(nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_ItemDisplayPreferences", x => x.Id);
+ table.ForeignKey(
+ name: "FK_ItemDisplayPreferences_Users_UserId",
+ column: x => x.UserId,
+ principalSchema: "jellyfin",
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "HomeSection",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ DisplayPreferencesId = table.Column<int>(nullable: false),
+ Order = table.Column<int>(nullable: false),
+ Type = table.Column<int>(nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_HomeSection", x => x.Id);
+ table.ForeignKey(
+ name: "FK_HomeSection_DisplayPreferences_DisplayPreferencesId",
+ column: x => x.DisplayPreferencesId,
+ principalSchema: "jellyfin",
+ principalTable: "DisplayPreferences",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DisplayPreferences_UserId",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ column: "UserId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_HomeSection_DisplayPreferencesId",
+ schema: "jellyfin",
+ table: "HomeSection",
+ column: "DisplayPreferencesId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_ItemDisplayPreferences_UserId",
+ schema: "jellyfin",
+ table: "ItemDisplayPreferences",
+ column: "UserId");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "HomeSection",
+ schema: "jellyfin");
+
+ migrationBuilder.DropTable(
+ name: "ItemDisplayPreferences",
+ schema: "jellyfin");
+
+ migrationBuilder.DropTable(
+ name: "DisplayPreferences",
+ schema: "jellyfin");
+ }
+ }
+}
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/DesignTimeJellyfinDbFactory.cs b/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs
new file mode 100644
index 000000000..72a4a8c3b
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/DesignTimeJellyfinDbFactory.cs
@@ -0,0 +1,20 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ /// <summary>
+ /// The design time factory for <see cref="JellyfinDb"/>.
+ /// This is only used for the creation of migrations and not during runtime.
+ /// </summary>
+ internal class DesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDb>
+ {
+ public JellyfinDb CreateDbContext(string[] args)
+ {
+ var optionsBuilder = new DbContextOptionsBuilder<JellyfinDb>();
+ optionsBuilder.UseSqlite("Data Source=jellyfin.db");
+
+ return new JellyfinDb(optionsBuilder.Options);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
new file mode 100644
index 000000000..ccfcf96b1
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
@@ -0,0 +1,457 @@
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDb))]
+ partial class JellyfinDbModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(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/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
new file mode 100644
index 000000000..f79e433a6
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
@@ -0,0 +1,120 @@
+#nullable enable
+
+using System;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Common.Cryptography;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Model.Cryptography;
+
+namespace Jellyfin.Server.Implementations.Users
+{
+ /// <summary>
+ /// The default authentication provider.
+ /// </summary>
+ public class DefaultAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
+ {
+ private readonly ICryptoProvider _cryptographyProvider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DefaultAuthenticationProvider"/> class.
+ /// </summary>
+ /// <param name="cryptographyProvider">The cryptography provider.</param>
+ public DefaultAuthenticationProvider(ICryptoProvider cryptographyProvider)
+ {
+ _cryptographyProvider = cryptographyProvider;
+ }
+
+ /// <inheritdoc />
+ public string Name => "Default";
+
+ /// <inheritdoc />
+ public bool IsEnabled => true;
+
+ /// <inheritdoc />
+ // This is dumb and an artifact of the backwards way auth providers were designed.
+ // This version of authenticate was never meant to be called, but needs to be here for interface compat
+ // Only the providers that don't provide local user support use this
+ public Task<ProviderAuthenticationResult> Authenticate(string username, string password)
+ {
+ throw new NotImplementedException();
+ }
+
+ /// <inheritdoc />
+ // This is the version that we need to use for local users. Because reasons.
+ public Task<ProviderAuthenticationResult> Authenticate(string username, string password, User resolvedUser)
+ {
+ if (resolvedUser == null)
+ {
+ throw new AuthenticationException("Specified user does not exist.");
+ }
+
+ bool success = false;
+
+ // As long as jellyfin supports passwordless users, we need this little block here to accommodate
+ if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password))
+ {
+ return Task.FromResult(new ProviderAuthenticationResult
+ {
+ Username = username
+ });
+ }
+
+ // Handle the case when the stored password is null, but the user tried to login with a password
+ if (resolvedUser.Password != null)
+ {
+ byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
+
+ PasswordHash readyHash = PasswordHash.Parse(resolvedUser.Password);
+ if (_cryptographyProvider.GetSupportedHashMethods().Contains(readyHash.Id)
+ || _cryptographyProvider.DefaultHashMethod == readyHash.Id)
+ {
+ byte[] calculatedHash = _cryptographyProvider.ComputeHash(
+ readyHash.Id,
+ passwordBytes,
+ readyHash.Salt.ToArray());
+
+ if (readyHash.Hash.SequenceEqual(calculatedHash))
+ {
+ success = true;
+ }
+ }
+ else
+ {
+ throw new AuthenticationException($"Requested crypto method not available in provider: {readyHash.Id}");
+ }
+ }
+
+ if (!success)
+ {
+ throw new AuthenticationException("Invalid username or password");
+ }
+
+ return Task.FromResult(new ProviderAuthenticationResult
+ {
+ Username = username
+ });
+ }
+
+ /// <inheritdoc />
+ public bool HasPassword(User user)
+ => !string.IsNullOrEmpty(user?.Password);
+
+ /// <inheritdoc />
+ public Task ChangePassword(User user, string newPassword)
+ {
+ if (string.IsNullOrEmpty(newPassword))
+ {
+ user.Password = null;
+ return Task.CompletedTask;
+ }
+
+ PasswordHash newPasswordHash = _cryptographyProvider.CreatePasswordHash(newPassword);
+ user.Password = newPasswordHash.ToString();
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
new file mode 100644
index 000000000..6cb13cd23
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
@@ -0,0 +1,137 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Users;
+
+namespace Jellyfin.Server.Implementations.Users
+{
+ /// <summary>
+ /// The default password reset provider.
+ /// </summary>
+ public class DefaultPasswordResetProvider : IPasswordResetProvider
+ {
+ private const string BaseResetFileName = "passwordreset";
+
+ private readonly IApplicationHost _appHost;
+
+ private readonly string _passwordResetFileBase;
+ private readonly string _passwordResetFileBaseDir;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DefaultPasswordResetProvider"/> class.
+ /// </summary>
+ /// <param name="configurationManager">The configuration manager.</param>
+ /// <param name="appHost">The application host.</param>
+ public DefaultPasswordResetProvider(IServerConfigurationManager configurationManager, IApplicationHost appHost)
+ {
+ _passwordResetFileBaseDir = configurationManager.ApplicationPaths.ProgramDataPath;
+ _passwordResetFileBase = Path.Combine(_passwordResetFileBaseDir, BaseResetFileName);
+ _appHost = appHost;
+ // TODO: Remove the circular dependency on UserManager
+ }
+
+ /// <inheritdoc />
+ public string Name => "Default Password Reset Provider";
+
+ /// <inheritdoc />
+ public bool IsEnabled => true;
+
+ /// <inheritdoc />
+ public async Task<PinRedeemResult> RedeemPasswordResetPin(string pin)
+ {
+ var userManager = _appHost.Resolve<IUserManager>();
+ var usersReset = new List<string>();
+ foreach (var resetFile in Directory.EnumerateFiles(_passwordResetFileBaseDir, $"{BaseResetFileName}*"))
+ {
+ SerializablePasswordReset spr;
+ await using (var str = File.OpenRead(resetFile))
+ {
+ spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false);
+ }
+
+ if (spr.ExpirationDate < DateTime.UtcNow)
+ {
+ File.Delete(resetFile);
+ }
+ else if (string.Equals(
+ spr.Pin.Replace("-", string.Empty, StringComparison.Ordinal),
+ pin.Replace("-", string.Empty, StringComparison.Ordinal),
+ StringComparison.InvariantCultureIgnoreCase))
+ {
+ var resetUser = userManager.GetUserByName(spr.UserName)
+ ?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found");
+
+ await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false);
+ usersReset.Add(resetUser.Username);
+ File.Delete(resetFile);
+ }
+ }
+
+ if (usersReset.Count < 1)
+ {
+ throw new ResourceNotFoundException($"No Users found with a password reset request matching pin {pin}");
+ }
+
+ return new PinRedeemResult
+ {
+ Success = true,
+ UsersReset = usersReset.ToArray()
+ };
+ }
+
+ /// <inheritdoc />
+ public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork)
+ {
+ string pin;
+ using (var cryptoRandom = RandomNumberGenerator.Create())
+ {
+ byte[] bytes = new byte[4];
+ cryptoRandom.GetBytes(bytes);
+ pin = BitConverter.ToString(bytes);
+ }
+
+ DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
+ string filePath = _passwordResetFileBase + user.Id + ".json";
+ SerializablePasswordReset spr = new SerializablePasswordReset
+ {
+ ExpirationDate = expireTime,
+ Pin = pin,
+ PinFile = filePath,
+ UserName = user.Username
+ };
+
+ await using (FileStream fileStream = File.OpenWrite(filePath))
+ {
+ await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
+ await fileStream.FlushAsync().ConfigureAwait(false);
+ }
+
+ user.EasyPassword = pin;
+
+ return new ForgotPasswordResult
+ {
+ Action = ForgotPasswordAction.PinCode,
+ PinExpirationDate = expireTime,
+ };
+ }
+
+#nullable disable
+ private class SerializablePasswordReset : PasswordPinCreationResult
+ {
+ public string Pin { get; set; }
+
+ public string UserName { get; set; }
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs
new file mode 100644
index 000000000..1fb89c4a6
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Users/DeviceAccessEntryPoint.cs
@@ -0,0 +1,67 @@
+#nullable enable
+#pragma warning disable CS1591
+
+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;
+
+namespace Jellyfin.Server.Implementations.Users
+{
+ public sealed class DeviceAccessEntryPoint : IServerEntryPoint
+ {
+ private readonly IUserManager _userManager;
+ private readonly IAuthenticationRepository _authRepo;
+ private readonly IDeviceManager _deviceManager;
+ private readonly ISessionManager _sessionManager;
+
+ public DeviceAccessEntryPoint(IUserManager userManager, IAuthenticationRepository authRepo, IDeviceManager deviceManager, ISessionManager sessionManager)
+ {
+ _userManager = userManager;
+ _authRepo = authRepo;
+ _deviceManager = deviceManager;
+ _sessionManager = sessionManager;
+ }
+
+ public Task RunAsync()
+ {
+ _userManager.OnUserUpdated += OnUserUpdated;
+
+ return Task.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ }
+
+ private void OnUserUpdated(object? sender, GenericEventArgs<User> e)
+ {
+ var user = e.Argument;
+ if (!user.HasPermission(PermissionKind.EnableAllDevices))
+ {
+ UpdateDeviceAccess(user);
+ }
+ }
+
+ private void UpdateDeviceAccess(User user)
+ {
+ var existing = _authRepo.Get(new AuthenticationInfoQuery
+ {
+ UserId = user.Id
+ }).Items;
+
+ foreach (var authInfo in existing)
+ {
+ if (!string.IsNullOrEmpty(authInfo.DeviceId) && !_deviceManager.CanAccessDevice(user, authInfo.DeviceId))
+ {
+ _sessionManager.Logout(authInfo);
+ }
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
new file mode 100644
index 000000000..46f1c618f
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
@@ -0,0 +1,74 @@
+#pragma warning disable CA1307
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Server.Implementations.Users
+{
+ /// <summary>
+ /// Manages the storage and retrieval of display preferences through Entity Framework.
+ /// </summary>
+ public class DisplayPreferencesManager : IDisplayPreferencesManager
+ {
+ private readonly JellyfinDb _dbContext;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class.
+ /// </summary>
+ /// <param name="dbContext">The database context.</param>
+ public DisplayPreferencesManager(JellyfinDb dbContext)
+ {
+ _dbContext = dbContext;
+ }
+
+ /// <inheritdoc />
+ public DisplayPreferences GetDisplayPreferences(Guid userId, string client)
+ {
+ var prefs = _dbContext.DisplayPreferences
+ .Include(pref => pref.HomeSections)
+ .FirstOrDefault(pref =>
+ pref.UserId == userId && string.Equals(pref.Client, client));
+
+ if (prefs == null)
+ {
+ prefs = new DisplayPreferences(userId, client);
+ _dbContext.DisplayPreferences.Add(prefs);
+ }
+
+ return prefs;
+ }
+
+ /// <inheritdoc />
+ public ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client)
+ {
+ 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);
+ }
+
+ return prefs;
+ }
+
+ /// <inheritdoc />
+ public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client)
+ {
+ return _dbContext.ItemDisplayPreferences
+ .Where(prefs => prefs.UserId == userId && prefs.ItemId != Guid.Empty && string.Equals(prefs.Client, client))
+ .ToList();
+ }
+
+ /// <inheritdoc />
+ public void SaveChanges()
+ {
+ _dbContext.SaveChanges();
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
new file mode 100644
index 000000000..e38cd07f0
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
@@ -0,0 +1,38 @@
+#nullable enable
+
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Authentication;
+
+namespace Jellyfin.Server.Implementations.Users
+{
+ /// <summary>
+ /// An invalid authentication provider.
+ /// </summary>
+ public class InvalidAuthProvider : IAuthenticationProvider
+ {
+ /// <inheritdoc />
+ public string Name => "InvalidOrMissingAuthenticationProvider";
+
+ /// <inheritdoc />
+ public bool IsEnabled => true;
+
+ /// <inheritdoc />
+ public Task<ProviderAuthenticationResult> Authenticate(string username, string password)
+ {
+ throw new AuthenticationException("User Account cannot login with this provider. The Normal provider for this user cannot be found");
+ }
+
+ /// <inheritdoc />
+ public bool HasPassword(User user)
+ {
+ return true;
+ }
+
+ /// <inheritdoc />
+ public Task ChangePassword(User user, string newPassword)
+ {
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
new file mode 100644
index 000000000..8f04baa08
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -0,0 +1,909 @@
+#nullable enable
+#pragma warning disable CA1307
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+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.Users;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.Users
+{
+ /// <summary>
+ /// Manages the creation and retrieval of <see cref="User"/> instances.
+ /// </summary>
+ public class UserManager : IUserManager
+ {
+ private readonly JellyfinDbProvider _dbProvider;
+ private readonly IEventManager _eventManager;
+ private readonly ICryptoProvider _cryptoProvider;
+ private readonly INetworkManager _networkManager;
+ private readonly IApplicationHost _appHost;
+ private readonly IImageProcessor _imageProcessor;
+ private readonly ILogger<UserManager> _logger;
+ private readonly IReadOnlyCollection<IPasswordResetProvider> _passwordResetProviders;
+ private readonly IReadOnlyCollection<IAuthenticationProvider> _authenticationProviders;
+ private readonly InvalidAuthProvider _invalidAuthProvider;
+ private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider;
+ private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
+
+ /// <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>
+ /// <param name="imageProcessor">The image processor.</param>
+ /// <param name="logger">The logger.</param>
+ public UserManager(
+ JellyfinDbProvider dbProvider,
+ IEventManager eventManager,
+ ICryptoProvider cryptoProvider,
+ INetworkManager networkManager,
+ IApplicationHost appHost,
+ IImageProcessor imageProcessor,
+ ILogger<UserManager> logger)
+ {
+ _dbProvider = dbProvider;
+ _eventManager = eventManager;
+ _cryptoProvider = cryptoProvider;
+ _networkManager = networkManager;
+ _appHost = appHost;
+ _imageProcessor = imageProcessor;
+ _logger = logger;
+
+ _passwordResetProviders = appHost.GetExports<IPasswordResetProvider>();
+ _authenticationProviders = appHost.GetExports<IAuthenticationProvider>();
+
+ _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
+ _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
+ _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
+ }
+
+ /// <inheritdoc/>
+ public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
+
+ /// <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();
+ }
+ }
+
+ /// <inheritdoc/>
+ public IEnumerable<Guid> UsersIds
+ {
+ get
+ {
+ using var dbContext = _dbProvider.CreateContext();
+ return dbContext.Users
+ .Select(user => user.Id)
+ .ToList();
+ }
+ }
+
+ /// <inheritdoc/>
+ public User? GetUserById(Guid id)
+ {
+ if (id == Guid.Empty)
+ {
+ 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);
+ }
+
+ /// <inheritdoc/>
+ public User? GetUserByName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ 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));
+ }
+
+ /// <inheritdoc/>
+ public async Task RenameUser(User user, string newName)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+
+ if (string.IsNullOrWhiteSpace(newName))
+ {
+ throw new ArgumentException("Invalid username", nameof(newName));
+ }
+
+ if (user.Username.Equals(newName, StringComparison.Ordinal))
+ {
+ throw new ArgumentException("The new and old names must be different.");
+ }
+
+ if (Users.Any(u => u.Id != user.Id && u.Username.Equals(newName, StringComparison.Ordinal)))
+ {
+ throw new ArgumentException(string.Format(
+ CultureInfo.InvariantCulture,
+ "A user with the name '{0}' already exists.",
+ newName));
+ }
+
+ user.Username = newName;
+ await UpdateUserAsync(user).ConfigureAwait(false);
+
+ OnUserUpdated?.Invoke(this, new GenericEventArgs<User>(user));
+ }
+
+ /// <inheritdoc/>
+ public void UpdateUser(User user)
+ {
+ using var dbContext = _dbProvider.CreateContext();
+ dbContext.Users.Update(user);
+ dbContext.SaveChanges();
+ }
+
+ /// <inheritdoc/>
+ public async Task UpdateUserAsync(User user)
+ {
+ await using var dbContext = _dbProvider.CreateContext();
+ dbContext.Users.Update(user);
+
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ }
+
+ 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)
+ : 0;
+
+ return new User(
+ name,
+ _defaultAuthenticationProvider.GetType().FullName,
+ _defaultPasswordResetProvider.GetType().FullName)
+ {
+ InternalId = max + 1
+ };
+ }
+
+ /// <inheritdoc/>
+ public async Task<User> CreateUserAsync(string name)
+ {
+ if (!IsValidUsername(name))
+ {
+ throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)");
+ }
+
+ using var dbContext = _dbProvider.CreateContext();
+
+ var newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
+
+ dbContext.Users.Add(newUser);
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+
+ await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
+
+ return newUser;
+ }
+
+ /// <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)
+ {
+ 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)
+ {
+ throw new InvalidOperationException(string.Format(
+ CultureInfo.InvariantCulture,
+ "The user '{0}' cannot be deleted because there must be at least one user in the system.",
+ user.Username));
+ }
+
+ if (user.HasPermission(PermissionKind.IsAdministrator)
+ && Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
+ {
+ throw new ArgumentException(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ "The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
+ user.Username),
+ nameof(userId));
+ }
+
+ // Clear all entities related to the user from the database.
+ if (user.ProfileImage != null)
+ {
+ dbContext.Remove(user.ProfileImage);
+ }
+
+ dbContext.RemoveRange(user.Permissions);
+ dbContext.RemoveRange(user.Preferences);
+ dbContext.RemoveRange(user.AccessSchedules);
+ dbContext.Users.Remove(user);
+ dbContext.SaveChanges();
+
+ _eventManager.Publish(new UserDeletedEventArgs(user));
+ }
+
+ /// <inheritdoc/>
+ public Task ResetPassword(User user)
+ {
+ return ChangePassword(user, string.Empty);
+ }
+
+ /// <inheritdoc/>
+ public void ResetEasyPassword(User user)
+ {
+ ChangeEasyPassword(user, string.Empty, null);
+ }
+
+ /// <inheritdoc/>
+ public async Task ChangePassword(User user, string newPassword)
+ {
+ if (user == null)
+ {
+ throw new ArgumentNullException(nameof(user));
+ }
+
+ await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
+ await UpdateUserAsync(user).ConfigureAwait(false);
+
+ await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false);
+ }
+
+ /// <inheritdoc/>
+ public void ChangeEasyPassword(User user, string newPassword, string? newPasswordSha1)
+ {
+ if (newPassword != null)
+ {
+ newPasswordSha1 = _cryptoProvider.CreatePasswordHash(newPassword).ToString();
+ }
+
+ if (string.IsNullOrWhiteSpace(newPasswordSha1))
+ {
+ throw new ArgumentNullException(nameof(newPasswordSha1));
+ }
+
+ user.EasyPassword = newPasswordSha1;
+ UpdateUser(user);
+
+ _eventManager.Publish(new UserPasswordChangedEventArgs(user));
+ }
+
+ /// <inheritdoc/>
+ public UserDto GetUserDto(User user, string? remoteEndPoint = null)
+ {
+ var hasPassword = GetAuthenticationProvider(user).HasPassword(user);
+ return new UserDto
+ {
+ Name = user.Username,
+ Id = user.Id,
+ ServerId = _appHost.SystemId,
+ HasPassword = hasPassword,
+ HasConfiguredPassword = hasPassword,
+ HasConfiguredEasyPassword = !string.IsNullOrEmpty(user.EasyPassword),
+ EnableAutoLogin = user.EnableAutoLogin,
+ LastLoginDate = user.LastLoginDate,
+ LastActivityDate = user.LastActivityDate,
+ PrimaryImageTag = user.ProfileImage != null ? _imageProcessor.GetImageCacheTag(user) : null,
+ Configuration = new UserConfiguration
+ {
+ SubtitleMode = user.SubtitleMode,
+ HidePlayedInLatest = user.HidePlayedInLatest,
+ EnableLocalPassword = user.EnableLocalPassword,
+ PlayDefaultAudioTrack = user.PlayDefaultAudioTrack,
+ DisplayCollectionsView = user.DisplayCollectionsView,
+ DisplayMissingEpisodes = user.DisplayMissingEpisodes,
+ AudioLanguagePreference = user.AudioLanguagePreference,
+ RememberAudioSelections = user.RememberAudioSelections,
+ EnableNextEpisodeAutoPlay = user.EnableNextEpisodeAutoPlay,
+ RememberSubtitleSelections = user.RememberSubtitleSelections,
+ SubtitleLanguagePreference = user.SubtitleLanguagePreference ?? string.Empty,
+ OrderedViews = user.GetPreference(PreferenceKind.OrderedViews),
+ GroupedFolders = user.GetPreference(PreferenceKind.GroupedFolders),
+ MyMediaExcludes = user.GetPreference(PreferenceKind.MyMediaExcludes),
+ LatestItemsExcludes = user.GetPreference(PreferenceKind.LatestItemExcludes)
+ },
+ Policy = new UserPolicy
+ {
+ MaxParentalRating = user.MaxParentalAgeRating,
+ EnableUserPreferenceAccess = user.EnableUserPreferenceAccess,
+ RemoteClientBitrateLimit = user.RemoteClientBitrateLimit ?? 0,
+ AuthenticationProviderId = user.AuthenticationProviderId,
+ PasswordResetProviderId = user.PasswordResetProviderId,
+ InvalidLoginAttemptCount = user.InvalidLoginAttemptCount,
+ LoginAttemptsBeforeLockout = user.LoginAttemptsBeforeLockout ?? -1,
+ IsAdministrator = user.HasPermission(PermissionKind.IsAdministrator),
+ IsHidden = user.HasPermission(PermissionKind.IsHidden),
+ IsDisabled = user.HasPermission(PermissionKind.IsDisabled),
+ EnableSharedDeviceControl = user.HasPermission(PermissionKind.EnableSharedDeviceControl),
+ EnableRemoteAccess = user.HasPermission(PermissionKind.EnableRemoteAccess),
+ EnableLiveTvManagement = user.HasPermission(PermissionKind.EnableLiveTvManagement),
+ EnableLiveTvAccess = user.HasPermission(PermissionKind.EnableLiveTvAccess),
+ EnableMediaPlayback = user.HasPermission(PermissionKind.EnableMediaPlayback),
+ EnableAudioPlaybackTranscoding = user.HasPermission(PermissionKind.EnableAudioPlaybackTranscoding),
+ EnableVideoPlaybackTranscoding = user.HasPermission(PermissionKind.EnableVideoPlaybackTranscoding),
+ EnableContentDeletion = user.HasPermission(PermissionKind.EnableContentDeletion),
+ EnableContentDownloading = user.HasPermission(PermissionKind.EnableContentDownloading),
+ EnableSyncTranscoding = user.HasPermission(PermissionKind.EnableSyncTranscoding),
+ EnableMediaConversion = user.HasPermission(PermissionKind.EnableMediaConversion),
+ EnableAllChannels = user.HasPermission(PermissionKind.EnableAllChannels),
+ EnableAllDevices = user.HasPermission(PermissionKind.EnableAllDevices),
+ EnableAllFolders = user.HasPermission(PermissionKind.EnableAllFolders),
+ EnableRemoteControlOfOtherUsers = user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers),
+ EnablePlaybackRemuxing = user.HasPermission(PermissionKind.EnablePlaybackRemuxing),
+ ForceRemoteSourceTranscoding = user.HasPermission(PermissionKind.ForceRemoteSourceTranscoding),
+ EnablePublicSharing = user.HasPermission(PermissionKind.EnablePublicSharing),
+ AccessSchedules = user.AccessSchedules.ToArray(),
+ BlockedTags = user.GetPreference(PreferenceKind.BlockedTags),
+ EnabledChannels = user.GetPreference(PreferenceKind.EnabledChannels)?.Select(Guid.Parse).ToArray(),
+ EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices),
+ EnabledFolders = user.GetPreference(PreferenceKind.EnabledFolders)?.Select(Guid.Parse).ToArray(),
+ EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders),
+ SyncPlayAccess = user.SyncPlayAccess,
+ 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()
+ }
+ };
+ }
+
+ /// <inheritdoc/>
+ public async Task<User?> AuthenticateUser(
+ string username,
+ string password,
+ string passwordSha1,
+ string remoteEndPoint,
+ bool isUserSession)
+ {
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ _logger.LogInformation("Authentication request without username has been denied (IP: {IP}).", remoteEndPoint);
+ throw new ArgumentNullException(nameof(username));
+ }
+
+ var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
+ bool success;
+ IAuthenticationProvider? authenticationProvider;
+
+ if (user != null)
+ {
+ var authResult = await AuthenticateLocalUser(username, password, user, remoteEndPoint)
+ .ConfigureAwait(false);
+ authenticationProvider = authResult.authenticationProvider;
+ success = authResult.success;
+ }
+ else
+ {
+ var authResult = await AuthenticateLocalUser(username, password, null, remoteEndPoint)
+ .ConfigureAwait(false);
+ authenticationProvider = authResult.authenticationProvider;
+ string updatedUsername = authResult.username;
+ success = authResult.success;
+
+ if (success
+ && authenticationProvider != null
+ && !(authenticationProvider is DefaultAuthenticationProvider))
+ {
+ // Trust the username returned by the authentication provider
+ username = updatedUsername;
+
+ // Search the database for the user again
+ // the authentication provider might have created it
+ user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
+
+ if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy)
+ {
+ UpdatePolicy(user.Id, hasNewUserPolicy.GetNewUserPolicy());
+
+ await UpdateUserAsync(user).ConfigureAwait(false);
+ }
+ }
+ }
+
+ if (success && user != null && authenticationProvider != null)
+ {
+ var providerId = authenticationProvider.GetType().FullName;
+
+ if (!string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
+ {
+ user.AuthenticationProviderId = providerId;
+ await UpdateUserAsync(user).ConfigureAwait(false);
+ }
+ }
+
+ if (user == null)
+ {
+ _logger.LogInformation(
+ "Authentication request for {UserName} has been denied (IP: {IP}).",
+ username,
+ remoteEndPoint);
+ throw new AuthenticationException("Invalid username or password entered.");
+ }
+
+ if (user.HasPermission(PermissionKind.IsDisabled))
+ {
+ _logger.LogInformation(
+ "Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).",
+ username,
+ remoteEndPoint);
+ throw new SecurityException(
+ $"The {user.Username} account is currently disabled. Please consult with your administrator.");
+ }
+
+ if (!user.HasPermission(PermissionKind.EnableRemoteAccess) &&
+ !_networkManager.IsInLocalNetwork(remoteEndPoint))
+ {
+ _logger.LogInformation(
+ "Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).",
+ username,
+ remoteEndPoint);
+ throw new SecurityException("Forbidden.");
+ }
+
+ if (!user.IsParentalScheduleAllowed())
+ {
+ _logger.LogInformation(
+ "Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).",
+ username,
+ remoteEndPoint);
+ throw new SecurityException("User is not allowed access at this time.");
+ }
+
+ // Update LastActivityDate and LastLoginDate, then save
+ if (success)
+ {
+ if (isUserSession)
+ {
+ user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
+ }
+
+ user.InvalidLoginAttemptCount = 0;
+ await UpdateUserAsync(user).ConfigureAwait(false);
+ _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
+ }
+ else
+ {
+ await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
+ _logger.LogInformation(
+ "Authentication request for {UserName} has been denied (IP: {IP}).",
+ user.Username,
+ remoteEndPoint);
+ }
+
+ return success ? user : null;
+ }
+
+ /// <inheritdoc/>
+ public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
+ {
+ var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername);
+
+ if (user != null && isInNetwork)
+ {
+ var passwordResetProvider = GetPasswordResetProvider(user);
+ var result = await passwordResetProvider
+ .StartForgotPasswordProcess(user, isInNetwork)
+ .ConfigureAwait(false);
+
+ await UpdateUserAsync(user).ConfigureAwait(false);
+ return result;
+ }
+
+ return new ForgotPasswordResult
+ {
+ Action = ForgotPasswordAction.InNetworkRequired,
+ PinFile = string.Empty
+ };
+ }
+
+ /// <inheritdoc/>
+ public async Task<PinRedeemResult> RedeemPasswordResetPin(string pin)
+ {
+ foreach (var provider in _passwordResetProviders)
+ {
+ var result = await provider.RedeemPasswordResetPin(pin).ConfigureAwait(false);
+
+ if (result.Success)
+ {
+ return result;
+ }
+ }
+
+ return new PinRedeemResult
+ {
+ Success = false,
+ UsersReset = Array.Empty<string>()
+ };
+ }
+
+ /// <inheritdoc />
+ 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))
+ {
+ return;
+ }
+
+ var defaultName = Environment.UserName;
+ if (string.IsNullOrWhiteSpace(defaultName) || !IsValidUsername(defaultName))
+ {
+ defaultName = "MyJellyfinUser";
+ }
+
+ _logger.LogWarning("No users, creating one with username {UserName}", defaultName);
+
+ var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
+ newUser.SetPermission(PermissionKind.IsAdministrator, true);
+ newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
+ newUser.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true);
+
+ dbContext.Users.Add(newUser);
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ }
+
+ /// <inheritdoc/>
+ public NameIdPair[] GetAuthenticationProviders()
+ {
+ return _authenticationProviders
+ .Where(provider => provider.IsEnabled)
+ .OrderBy(i => i is DefaultAuthenticationProvider ? 0 : 1)
+ .ThenBy(i => i.Name)
+ .Select(i => new NameIdPair
+ {
+ Name = i.Name,
+ Id = i.GetType().FullName
+ })
+ .ToArray();
+ }
+
+ /// <inheritdoc/>
+ public NameIdPair[] GetPasswordResetProviders()
+ {
+ return _passwordResetProviders
+ .Where(provider => provider.IsEnabled)
+ .OrderBy(i => i is DefaultPasswordResetProvider ? 0 : 1)
+ .ThenBy(i => i.Name)
+ .Select(i => new NameIdPair
+ {
+ Name = i.Name,
+ Id = i.GetType().FullName
+ })
+ .ToArray();
+ }
+
+ /// <inheritdoc/>
+ public void UpdateConfiguration(Guid userId, UserConfiguration config)
+ {
+ 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)
+ ?? throw new ArgumentException("No user exists with given Id!");
+
+ user.SubtitleMode = config.SubtitleMode;
+ user.HidePlayedInLatest = config.HidePlayedInLatest;
+ user.EnableLocalPassword = config.EnableLocalPassword;
+ user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack;
+ user.DisplayCollectionsView = config.DisplayCollectionsView;
+ user.DisplayMissingEpisodes = config.DisplayMissingEpisodes;
+ user.AudioLanguagePreference = config.AudioLanguagePreference;
+ user.RememberAudioSelections = config.RememberAudioSelections;
+ user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay;
+ user.RememberSubtitleSelections = config.RememberSubtitleSelections;
+ user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
+
+ user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
+ user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
+ user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
+ user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
+
+ dbContext.Update(user);
+ dbContext.SaveChanges();
+ }
+
+ /// <inheritdoc/>
+ public void UpdatePolicy(Guid userId, UserPolicy policy)
+ {
+ 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)
+ ?? throw new ArgumentException("No user exists with given Id!");
+
+ // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
+ int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
+ {
+ -1 => null,
+ 0 => 3,
+ _ => policy.LoginAttemptsBeforeLockout
+ };
+
+ user.MaxParentalAgeRating = policy.MaxParentalRating;
+ user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
+ user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
+ user.AuthenticationProviderId = policy.AuthenticationProviderId;
+ user.PasswordResetProviderId = policy.PasswordResetProviderId;
+ user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
+ user.LoginAttemptsBeforeLockout = maxLoginAttempts;
+ user.SyncPlayAccess = policy.SyncPlayAccess;
+ user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
+ user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
+ user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
+ user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
+ user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
+ user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
+ user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
+ user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
+ user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
+ user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
+ user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
+ user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
+ user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
+ user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
+ user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
+ user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
+ user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
+ user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
+ user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
+ user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
+ user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
+
+ user.AccessSchedules.Clear();
+ foreach (var policyAccessSchedule in policy.AccessSchedules)
+ {
+ user.AccessSchedules.Add(policyAccessSchedule);
+ }
+
+ // TODO: fix this at some point
+ user.SetPreference(
+ PreferenceKind.BlockUnratedItems,
+ policy.BlockUnratedItems?.Select(i => i.ToString()).ToArray() ?? Array.Empty<string>());
+ user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
+ 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?.Select(i => i.ToString("N", CultureInfo.InvariantCulture)).ToArray());
+ user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
+
+ dbContext.Update(user);
+ dbContext.SaveChanges();
+ }
+
+ /// <inheritdoc/>
+ public void ClearProfileImage(User user)
+ {
+ using var dbContext = _dbProvider.CreateContext();
+ dbContext.Remove(user.ProfileImage);
+ dbContext.SaveChanges();
+ }
+
+ private static bool IsValidUsername(string name)
+ {
+ // This is some regex that matches only on unicode "word" characters, as well as -, _ and @
+ // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
+ // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( )
+ return Regex.IsMatch(name, @"^[\w\ \-'._@]*$");
+ }
+
+ private IAuthenticationProvider GetAuthenticationProvider(User user)
+ {
+ return GetAuthenticationProviders(user)[0];
+ }
+
+ private IPasswordResetProvider GetPasswordResetProvider(User user)
+ {
+ return GetPasswordResetProviders(user)[0];
+ }
+
+ private IList<IAuthenticationProvider> GetAuthenticationProviders(User? user)
+ {
+ var authenticationProviderId = user?.AuthenticationProviderId;
+
+ var providers = _authenticationProviders.Where(i => i.IsEnabled).ToList();
+
+ if (!string.IsNullOrEmpty(authenticationProviderId))
+ {
+ providers = providers.Where(i => string.Equals(authenticationProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase)).ToList();
+ }
+
+ if (providers.Count == 0)
+ {
+ // Assign the user to the InvalidAuthProvider since no configured auth provider was valid/found
+ _logger.LogWarning(
+ "User {Username} was found with invalid/missing Authentication Provider {AuthenticationProviderId}. Assigning user to InvalidAuthProvider until this is corrected",
+ user?.Username,
+ user?.AuthenticationProviderId);
+ providers = new List<IAuthenticationProvider>
+ {
+ _invalidAuthProvider
+ };
+ }
+
+ return providers;
+ }
+
+ private IList<IPasswordResetProvider> GetPasswordResetProviders(User user)
+ {
+ var passwordResetProviderId = user?.PasswordResetProviderId;
+ var providers = _passwordResetProviders.Where(i => i.IsEnabled).ToArray();
+
+ if (!string.IsNullOrEmpty(passwordResetProviderId))
+ {
+ providers = providers.Where(i =>
+ string.Equals(passwordResetProviderId, i.GetType().FullName, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ }
+
+ if (providers.Length == 0)
+ {
+ providers = new IPasswordResetProvider[]
+ {
+ _defaultPasswordResetProvider
+ };
+ }
+
+ return providers;
+ }
+
+ private async Task<(IAuthenticationProvider? authenticationProvider, string username, bool success)> AuthenticateLocalUser(
+ string username,
+ string password,
+ User? user,
+ string remoteEndPoint)
+ {
+ bool success = false;
+ IAuthenticationProvider? authenticationProvider = null;
+
+ foreach (var provider in GetAuthenticationProviders(user))
+ {
+ var providerAuthResult =
+ await AuthenticateWithProvider(provider, username, password, user).ConfigureAwait(false);
+ var updatedUsername = providerAuthResult.username;
+ success = providerAuthResult.success;
+
+ if (success)
+ {
+ authenticationProvider = provider;
+ username = updatedUsername;
+ break;
+ }
+ }
+
+ if (!success
+ && _networkManager.IsInLocalNetwork(remoteEndPoint)
+ && user?.EnableLocalPassword == true
+ && !string.IsNullOrEmpty(user.EasyPassword))
+ {
+ // Check easy password
+ var passwordHash = PasswordHash.Parse(user.EasyPassword);
+ var hash = _cryptoProvider.ComputeHash(
+ passwordHash.Id,
+ Encoding.UTF8.GetBytes(password),
+ passwordHash.Salt.ToArray());
+ success = passwordHash.Hash.SequenceEqual(hash);
+ }
+
+ return (authenticationProvider, username, success);
+ }
+
+ private async Task<(string username, bool success)> AuthenticateWithProvider(
+ IAuthenticationProvider provider,
+ string username,
+ string password,
+ User? resolvedUser)
+ {
+ try
+ {
+ var authenticationResult = provider is IRequiresResolvedUser requiresResolvedUser
+ ? await requiresResolvedUser.Authenticate(username, password, resolvedUser).ConfigureAwait(false)
+ : await provider.Authenticate(username, password).ConfigureAwait(false);
+
+ if (authenticationResult.Username != username)
+ {
+ _logger.LogDebug("Authentication provider provided updated username {1}", authenticationResult.Username);
+ username = authenticationResult.Username;
+ }
+
+ return (username, true);
+ }
+ catch (AuthenticationException ex)
+ {
+ _logger.LogError(ex, "Error authenticating with provider {Provider}", provider.Name);
+
+ return (username, false);
+ }
+ }
+
+ private async Task IncrementInvalidLoginAttemptCount(User user)
+ {
+ user.InvalidLoginAttemptCount++;
+ int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
+ if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
+ {
+ user.SetPermission(PermissionKind.IsDisabled, true);
+ await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
+ _logger.LogWarning(
+ "Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
+ user.Username,
+ user.InvalidLoginAttemptCount);
+ }
+
+ await UpdateUserAsync(user).ConfigureAwait(false);
+ }
+ }
+}