repo creation with initial code after cloning public repo

This commit is contained in:
2026-02-19 07:36:25 -05:00
commit 460884fea3
2860 changed files with 650825 additions and 0 deletions
@@ -0,0 +1,213 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Model.Activity;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Activity;
/// <summary>
/// Manages the storage and retrieval of <see cref="ActivityLog"/> instances.
/// </summary>
public class ActivityManager : IActivityManager
{
private readonly IDbContextFactory<JellyfinDbContext> _provider;
/// <summary>
/// Initializes a new instance of the <see cref="ActivityManager"/> class.
/// </summary>
/// <param name="provider">The Jellyfin database provider.</param>
public ActivityManager(IDbContextFactory<JellyfinDbContext> provider)
{
_provider = provider;
}
/// <inheritdoc/>
public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated;
/// <inheritdoc/>
public async Task CreateAsync(ActivityLog entry)
{
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.ActivityLogs.Add(entry);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry)));
}
/// <inheritdoc/>
public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query)
{
// TODO allow sorting and filtering by item id. Currently not possible because ActivityLog stores the item id as a string.
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
// TODO switch to LeftJoin in .NET 10.
var entries = from a in dbContext.ActivityLogs
join u in dbContext.Users on a.UserId equals u.Id into ugj
from u in ugj.DefaultIfEmpty()
select new ExpandedActivityLog { ActivityLog = a, Username = u.Username };
if (query.HasUserId is not null)
{
entries = entries.Where(e => e.ActivityLog.UserId.Equals(default) != query.HasUserId.Value);
}
if (query.MinDate is not null)
{
entries = entries.Where(e => e.ActivityLog.DateCreated >= query.MinDate.Value);
}
if (query.MaxDate is not null)
{
entries = entries.Where(e => e.ActivityLog.DateCreated <= query.MaxDate.Value);
}
if (!string.IsNullOrEmpty(query.Name))
{
entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Name, $"%{query.Name}%"));
}
if (!string.IsNullOrEmpty(query.Overview))
{
entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Overview, $"%{query.Overview}%"));
}
if (!string.IsNullOrEmpty(query.ShortOverview))
{
entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.ShortOverview, $"%{query.ShortOverview}%"));
}
if (!string.IsNullOrEmpty(query.Type))
{
entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Type, $"%{query.Type}%"));
}
if (!query.ItemId.IsNullOrEmpty())
{
var itemId = query.ItemId.Value.ToString("N");
entries = entries.Where(e => e.ActivityLog.ItemId == itemId);
}
if (!string.IsNullOrEmpty(query.Username))
{
entries = entries.Where(e => EF.Functions.Like(e.Username, $"%{query.Username}%"));
}
if (query.Severity is not null)
{
entries = entries.Where(e => e.ActivityLog.LogSeverity == query.Severity);
}
return new QueryResult<ActivityLogEntry>(
query.Skip,
await entries.CountAsync().ConfigureAwait(false),
await ApplyOrdering(entries, query.OrderBy)
.Skip(query.Skip ?? 0)
.Take(query.Limit ?? 100)
.Select(entity => new ActivityLogEntry(entity.ActivityLog.Name, entity.ActivityLog.Type, entity.ActivityLog.UserId)
{
Id = entity.ActivityLog.Id,
Overview = entity.ActivityLog.Overview,
ShortOverview = entity.ActivityLog.ShortOverview,
ItemId = entity.ActivityLog.ItemId,
Date = entity.ActivityLog.DateCreated,
Severity = entity.ActivityLog.LogSeverity
})
.ToListAsync()
.ConfigureAwait(false));
}
}
/// <inheritdoc />
public async Task CleanAsync(DateTime startDate)
{
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await dbContext.ActivityLogs
.Where(entry => entry.DateCreated <= startDate)
.ExecuteDeleteAsync()
.ConfigureAwait(false);
}
}
private static ActivityLogEntry ConvertToOldModel(ActivityLog entry)
{
return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId)
{
Id = entry.Id,
Overview = entry.Overview,
ShortOverview = entry.ShortOverview,
ItemId = entry.ItemId,
Date = entry.DateCreated,
Severity = entry.LogSeverity
};
}
private IOrderedQueryable<ExpandedActivityLog> ApplyOrdering(IQueryable<ExpandedActivityLog> query, IReadOnlyCollection<(ActivityLogSortBy, SortOrder)>? sorting)
{
if (sorting is null || sorting.Count == 0)
{
return query.OrderByDescending(e => e.ActivityLog.DateCreated);
}
IOrderedQueryable<ExpandedActivityLog> ordered = null!;
foreach (var (sortBy, sortOrder) in sorting)
{
var orderBy = MapOrderBy(sortBy);
if (ordered == null)
{
ordered = sortOrder == SortOrder.Ascending
? query.OrderBy(orderBy)
: query.OrderByDescending(orderBy);
}
else
{
ordered = sortOrder == SortOrder.Ascending
? ordered.ThenBy(orderBy)
: ordered.ThenByDescending(orderBy);
}
}
return ordered;
}
private Expression<Func<ExpandedActivityLog, object?>> MapOrderBy(ActivityLogSortBy sortBy)
{
return sortBy switch
{
ActivityLogSortBy.Name => e => e.ActivityLog.Name,
ActivityLogSortBy.Overiew => e => e.ActivityLog.Overview,
ActivityLogSortBy.ShortOverview => e => e.ActivityLog.ShortOverview,
ActivityLogSortBy.Type => e => e.ActivityLog.Type,
ActivityLogSortBy.DateCreated => e => e.ActivityLog.DateCreated,
ActivityLogSortBy.Username => e => e.Username,
ActivityLogSortBy.LogSeverity => e => e.ActivityLog.LogSeverity,
_ => throw new ArgumentOutOfRangeException(nameof(sortBy), sortBy, "Unhandled ActivityLogSortBy")
};
}
private class ExpandedActivityLog
{
public ActivityLog ActivityLog { get; set; } = null!;
public string? Username { get; set; }
}
}
@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
namespace Jellyfin.Server.Implementations.DatabaseConfiguration;
/// <summary>
/// Factory for constructing a database configuration.
/// </summary>
public class DatabaseConfigurationFactory : IConfigurationFactory
{
/// <inheritdoc/>
public IEnumerable<ConfigurationStore> GetConfigurations()
{
yield return new DatabaseConfigurationStore();
}
}
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using Jellyfin.Database.Implementations.DbConfiguration;
using MediaBrowser.Common.Configuration;
namespace Jellyfin.Server.Implementations.DatabaseConfiguration;
/// <summary>
/// A configuration that stores database related settings.
/// </summary>
public class DatabaseConfigurationStore : ConfigurationStore
{
/// <summary>
/// The name of the configuration in the storage.
/// </summary>
public const string StoreKey = "database";
/// <summary>
/// Initializes a new instance of the <see cref="DatabaseConfigurationStore"/> class.
/// </summary>
public DatabaseConfigurationStore()
{
ConfigurationType = typeof(DatabaseConfigurationOptions);
Key = StoreKey;
}
}
@@ -0,0 +1,311 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Data.Dtos;
using Jellyfin.Data.Events;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Security;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Devices;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Querying;
using MediaBrowser.Model.Session;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Devices
{
/// <summary>
/// Manages the creation, updating, and retrieval of devices.
/// </summary>
public class DeviceManager : IDeviceManager
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IUserManager _userManager;
private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new();
private readonly ConcurrentDictionary<int, Device> _devices;
private readonly ConcurrentDictionary<string, DeviceOptions> _deviceOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DeviceManager"/> class.
/// </summary>
/// <param name="dbProvider">The database provider.</param>
/// <param name="userManager">The user manager.</param>
public DeviceManager(IDbContextFactory<JellyfinDbContext> dbProvider, IUserManager userManager)
{
_dbProvider = dbProvider;
_userManager = userManager;
_devices = new ConcurrentDictionary<int, Device>();
_deviceOptions = new ConcurrentDictionary<string, DeviceOptions>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var device in dbContext.Devices
.OrderBy(d => d.Id)
.AsEnumerable())
{
_devices.TryAdd(device.Id, device);
}
foreach (var deviceOption in dbContext.DeviceOptions
.OrderBy(d => d.Id)
.AsEnumerable())
{
_deviceOptions.TryAdd(deviceOption.DeviceId, deviceOption);
}
}
/// <inheritdoc />
public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>>? DeviceOptionsUpdated;
/// <inheritdoc />
public void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
{
_capabilitiesMap[deviceId] = capabilities;
}
/// <inheritdoc />
public async Task UpdateDeviceOptions(string deviceId, string? deviceName)
{
DeviceOptions? deviceOptions;
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
deviceOptions = await dbContext.DeviceOptions.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false);
if (deviceOptions is null)
{
deviceOptions = new DeviceOptions(deviceId);
dbContext.DeviceOptions.Add(deviceOptions);
}
deviceOptions.CustomName = deviceName;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
_deviceOptions[deviceId] = deviceOptions;
DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, DeviceOptions>(deviceId, deviceOptions)));
}
/// <inheritdoc />
public async Task<Device> CreateDevice(Device device)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Devices.Add(device);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_devices.TryAdd(device.Id, device);
}
return device;
}
/// <inheritdoc />
public DeviceOptionsDto? GetDeviceOptions(string deviceId)
{
if (_deviceOptions.TryGetValue(deviceId, out var deviceOptions))
{
return ToDeviceOptionsDto(deviceOptions);
}
return null;
}
/// <inheritdoc />
public ClientCapabilities GetCapabilities(string? deviceId)
{
if (deviceId is null)
{
return new();
}
return _capabilitiesMap.TryGetValue(deviceId, out ClientCapabilities? result)
? result
: new();
}
/// <inheritdoc />
public DeviceInfoDto? GetDevice(string id)
{
var device = _devices.Values.Where(d => d.DeviceId == id).OrderByDescending(d => d.DateLastActivity).FirstOrDefault();
_deviceOptions.TryGetValue(id, out var deviceOption);
var deviceInfo = device is null ? null : ToDeviceInfo(device, deviceOption);
return deviceInfo is null ? null : ToDeviceInfoDto(deviceInfo);
}
/// <inheritdoc />
public QueryResult<Device> GetDevices(DeviceQuery query)
{
IEnumerable<Device> devices = _devices.Values
.Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
.Where(device => query.DeviceId is null || device.DeviceId == query.DeviceId)
.Where(device => query.AccessToken is null || device.AccessToken == query.AccessToken)
.OrderBy(d => d.Id)
.ToList();
var count = devices.Count();
if (query.Skip.HasValue)
{
devices = devices.Skip(query.Skip.Value);
}
if (query.Limit.HasValue && query.Limit.Value > 0)
{
devices = devices.Take(query.Limit.Value);
}
return new QueryResult<Device>(query.Skip, count, devices.ToList());
}
/// <inheritdoc />
public QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query)
{
var devices = GetDevices(query);
return new QueryResult<DeviceInfo>(
devices.StartIndex,
devices.TotalRecordCount,
devices.Items.Select(device => ToDeviceInfo(device)).ToList());
}
/// <inheritdoc />
public QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId)
{
IEnumerable<Device> devices = _devices.Values
.OrderByDescending(d => d.DateLastActivity)
.ThenBy(d => d.DeviceId);
if (!userId.IsNullOrEmpty())
{
var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
throw new ResourceNotFoundException();
}
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
}
var array = devices.Select(device =>
{
_deviceOptions.TryGetValue(device.DeviceId, out var option);
return ToDeviceInfo(device, option);
})
.Select(ToDeviceInfoDto)
.ToArray();
return new QueryResult<DeviceInfoDto>(array);
}
/// <inheritdoc />
public async Task DeleteDevice(Device device)
{
_devices.TryRemove(device.Id, out _);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Devices.Remove(device);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task UpdateDevice(Device device)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Devices.Update(device);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
_devices[device.Id] = device;
}
/// <inheritdoc />
public bool CanAccessDevice(User user, string deviceId)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentException.ThrowIfNullOrEmpty(deviceId);
if (user.HasPermission(PermissionKind.EnableAllDevices) || user.HasPermission(PermissionKind.IsAdministrator))
{
return true;
}
return user.GetPreference(PreferenceKind.EnabledDevices).Contains(deviceId, StringComparison.OrdinalIgnoreCase)
|| !GetCapabilities(deviceId).SupportsPersistentIdentifier;
}
private DeviceInfo ToDeviceInfo(Device authInfo, DeviceOptions? options = null)
{
var caps = GetCapabilities(authInfo.DeviceId);
var user = _userManager.GetUserById(authInfo.UserId) ?? throw new ResourceNotFoundException("User with UserId " + authInfo.UserId + " not found");
return new()
{
AppName = authInfo.AppName,
AppVersion = authInfo.AppVersion,
Id = authInfo.DeviceId,
LastUserId = authInfo.UserId,
LastUserName = user.Username,
Name = authInfo.DeviceName,
DateLastActivity = authInfo.DateLastActivity,
IconUrl = caps.IconUrl,
CustomName = options?.CustomName,
};
}
private DeviceOptionsDto ToDeviceOptionsDto(DeviceOptions options)
{
return new()
{
Id = options.Id,
DeviceId = options.DeviceId,
CustomName = options.CustomName,
};
}
private DeviceInfoDto ToDeviceInfoDto(DeviceInfo info)
{
return new()
{
Name = info.Name,
CustomName = info.CustomName,
AccessToken = info.AccessToken,
Id = info.Id,
LastUserName = info.LastUserName,
AppName = info.AppName,
AppVersion = info.AppVersion,
LastUserId = info.LastUserId,
DateLastActivity = info.DateLastActivity,
Capabilities = ToClientCapabilitiesDto(info.Capabilities),
IconUrl = info.IconUrl
};
}
/// <inheritdoc />
public ClientCapabilitiesDto ToClientCapabilitiesDto(ClientCapabilities capabilities)
{
return new()
{
PlayableMediaTypes = capabilities.PlayableMediaTypes,
SupportedCommands = capabilities.SupportedCommands,
SupportsMediaControl = capabilities.SupportsMediaControl,
SupportsPersistentIdentifier = capabilities.SupportsPersistentIdentifier,
DeviceProfile = capabilities.DeviceProfile,
AppStoreUrl = capabilities.AppStoreUrl,
IconUrl = capabilities.IconUrl
};
}
}
}
@@ -0,0 +1,101 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Lyrics;
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 lyric download fails.
/// </summary>
public class LyricDownloadFailureLogger : IEventConsumer<LyricDownloadFailureEventArgs>
{
private readonly ILocalizationManager _localizationManager;
private readonly IActivityManager _activityManager;
/// <summary>
/// Initializes a new instance of the <see cref="LyricDownloadFailureLogger"/> class.
/// </summary>
/// <param name="localizationManager">The localization manager.</param>
/// <param name="activityManager">The activity manager.</param>
public LyricDownloadFailureLogger(ILocalizationManager localizationManager, IActivityManager activityManager)
{
_localizationManager = localizationManager;
_activityManager = activityManager;
}
/// <inheritdoc />
public async Task OnEvent(LyricDownloadFailureEventArgs eventArgs)
{
await _activityManager.CreateAsync(new ActivityLog(
string.Format(
CultureInfo.InvariantCulture,
_localizationManager.GetLocalizedString("LyricDownloadFailureFromForItem"),
eventArgs.Provider,
GetItemName(eventArgs.Item)),
"LyricDownloadFailure",
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;
}
}
@@ -0,0 +1,102 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.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;
}
}
}
@@ -0,0 +1,51 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Authentication;
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<AuthenticationRequestEventArgs>
{
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(AuthenticationRequestEventArgs eventArgs)
{
await _activityManager.CreateAsync(new ActivityLog(
string.Format(
CultureInfo.InvariantCulture,
_localizationManager.GetLocalizedString("FailedLoginAttemptWithUserName"),
eventArgs.Username),
"AuthenticationFailed",
Guid.Empty)
{
LogSeverity = LogLevel.Error,
ShortOverview = string.Format(
CultureInfo.InvariantCulture,
_localizationManager.GetLocalizedString("LabelIpAddressValue"),
eventArgs.RemoteEndPoint),
}).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,48 @@
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Authentication;
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<AuthenticationResultEventArgs>
{
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(AuthenticationResultEventArgs eventArgs)
{
await _activityManager.CreateAsync(new ActivityLog(
string.Format(
CultureInfo.InvariantCulture,
_localizationManager.GetLocalizedString("AuthenticationSucceededWithUserName"),
eventArgs.User.Name),
"AuthenticationSucceeded",
eventArgs.User.Id)
{
ShortOverview = string.Format(
CultureInfo.InvariantCulture,
_localizationManager.GetLocalizedString("LabelIpAddressValue"),
eventArgs.SessionInfo?.RemoteEndPoint),
}).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,108 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.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 is null)
{
_logger.LogWarning("PlaybackStart reported with null media info.");
return;
}
if (eventArgs.Item is not 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)
{
ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture),
})
.ConfigureAwait(false);
}
private static string GetItemName(BaseItemDto item)
{
var name = item.Name;
if (!string.IsNullOrEmpty(item.SeriesName))
{
name = item.SeriesName + " - " + name;
}
if (item.Artists is not null && item.Artists.Count > 0)
{
name = item.Artists[0] + " - " + name;
}
return name;
}
private static string GetPlaybackNotificationType(MediaType mediaType)
{
if (mediaType == MediaType.Audio)
{
return NotificationType.AudioPlayback.ToString();
}
if (mediaType == MediaType.Video)
{
return NotificationType.VideoPlayback.ToString();
}
return "Playback";
}
}
}
@@ -0,0 +1,116 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.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 is null)
{
_logger.LogWarning("PlaybackStopped reported with null media info.");
return;
}
if (eventArgs.Item is not 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];
var notificationType = GetPlaybackStoppedNotificationType(item.MediaType);
if (notificationType is null)
{
return;
}
await _activityManager.CreateAsync(new ActivityLog(
string.Format(
CultureInfo.InvariantCulture,
_localizationManager.GetLocalizedString("UserStoppedPlayingItemWithValues"),
user.Username,
GetItemName(item),
eventArgs.DeviceName),
notificationType,
user.Id)
{
ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture),
})
.ConfigureAwait(false);
}
private static string GetItemName(BaseItemDto item)
{
var name = item.Name;
if (!string.IsNullOrEmpty(item.SeriesName))
{
name = item.SeriesName + " - " + name;
}
if (item.Artists is not null && item.Artists.Count > 0)
{
name = item.Artists[0] + " - " + name;
}
return name;
}
private static string? GetPlaybackStoppedNotificationType(MediaType mediaType)
{
if (mediaType == MediaType.Audio)
{
return NotificationType.AudioPlaybackStopped.ToString();
}
if (mediaType == MediaType.Video)
{
return NotificationType.VideoPlaybackStopped.ToString();
}
return null;
}
}
}
@@ -0,0 +1,54 @@
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.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);
}
}
}
@@ -0,0 +1,54 @@
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.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);
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.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 eventArgs)
{
var result = eventArgs.Result;
var task = eventArgs.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(eventArgs.Result.ErrorMessage))
{
vals.Add(eventArgs.Result.ErrorMessage);
}
if (!string.IsNullOrEmpty(eventArgs.Result.LongErrorMessage))
{
vals.Add(eventArgs.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));
}
}
}
@@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
using MediaBrowser.Model.Tasks;
namespace Jellyfin.Server.Implementations.Events.Consumers.System
{
/// <summary>
/// Notifies admin users when a task is completed.
/// </summary>
public class TaskCompletedNotifier : IEventConsumer<TaskCompletionEventArgs>
{
private readonly ISessionManager _sessionManager;
/// <summary>
/// Initializes a new instance of the <see cref="TaskCompletedNotifier"/> class.
/// </summary>
/// <param name="sessionManager">The session manager.</param>
public TaskCompletedNotifier(ISessionManager sessionManager)
{
_sessionManager = sessionManager;
}
/// <inheritdoc />
public async Task OnEvent(TaskCompletionEventArgs eventArgs)
{
await _sessionManager.SendMessageToAdminSessions(SessionMessageType.ScheduledTaskEnded, eventArgs.Result, CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Updates;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
{
/// <summary>
/// Notifies admin users when a plugin installation is cancelled.
/// </summary>
public class PluginInstallationCancelledNotifier : IEventConsumer<PluginInstallationCancelledEventArgs>
{
private readonly ISessionManager _sessionManager;
/// <summary>
/// Initializes a new instance of the <see cref="PluginInstallationCancelledNotifier"/> class.
/// </summary>
/// <param name="sessionManager">The session manager.</param>
public PluginInstallationCancelledNotifier(ISessionManager sessionManager)
{
_sessionManager = sessionManager;
}
/// <inheritdoc />
public async Task OnEvent(PluginInstallationCancelledEventArgs eventArgs)
{
await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageInstallationCancelled, eventArgs.Argument, CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,51 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.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);
}
}
}
@@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Updates;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
{
/// <summary>
/// Notifies admin users when a plugin installation fails.
/// </summary>
public class PluginInstallationFailedNotifier : IEventConsumer<InstallationFailedEventArgs>
{
private readonly ISessionManager _sessionManager;
/// <summary>
/// Initializes a new instance of the <see cref="PluginInstallationFailedNotifier"/> class.
/// </summary>
/// <param name="sessionManager">The session manager.</param>
public PluginInstallationFailedNotifier(ISessionManager sessionManager)
{
_sessionManager = sessionManager;
}
/// <inheritdoc />
public async Task OnEvent(InstallationFailedEventArgs eventArgs)
{
await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageInstallationFailed, eventArgs.InstallationInfo, CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,50 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.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);
}
}
}
@@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Updates;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
{
/// <summary>
/// Notifies admin users when a plugin is installed.
/// </summary>
public class PluginInstalledNotifier : IEventConsumer<PluginInstalledEventArgs>
{
private readonly ISessionManager _sessionManager;
/// <summary>
/// Initializes a new instance of the <see cref="PluginInstalledNotifier"/> class.
/// </summary>
/// <param name="sessionManager">The session manager.</param>
public PluginInstalledNotifier(ISessionManager sessionManager)
{
_sessionManager = sessionManager;
}
/// <inheritdoc />
public async Task OnEvent(PluginInstalledEventArgs eventArgs)
{
await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageInstallationCompleted, eventArgs.Argument, CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Updates;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
{
/// <summary>
/// Notifies admin users when a plugin is being installed.
/// </summary>
public class PluginInstallingNotifier : IEventConsumer<PluginInstallingEventArgs>
{
private readonly ISessionManager _sessionManager;
/// <summary>
/// Initializes a new instance of the <see cref="PluginInstallingNotifier"/> class.
/// </summary>
/// <param name="sessionManager">The session manager.</param>
public PluginInstallingNotifier(ISessionManager sessionManager)
{
_sessionManager = sessionManager;
}
/// <inheritdoc />
public async Task OnEvent(PluginInstallingEventArgs eventArgs)
{
await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageInstalling, eventArgs.Argument, CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,45 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.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 eventArgs)
{
await _activityManager.CreateAsync(new ActivityLog(
string.Format(
CultureInfo.InvariantCulture,
_localizationManager.GetLocalizedString("PluginUninstalledWithName"),
eventArgs.Argument.Name),
NotificationType.PluginUninstalled.ToString(),
Guid.Empty))
.ConfigureAwait(false);
}
}
}
@@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Updates;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
namespace Jellyfin.Server.Implementations.Events.Consumers.Updates
{
/// <summary>
/// Notifies admin users when a plugin is uninstalled.
/// </summary>
public class PluginUninstalledNotifier : IEventConsumer<PluginUninstalledEventArgs>
{
private readonly ISessionManager _sessionManager;
/// <summary>
/// Initializes a new instance of the <see cref="PluginUninstalledNotifier"/> class.
/// </summary>
/// <param name="sessionManager">The session manager.</param>
public PluginUninstalledNotifier(ISessionManager sessionManager)
{
_sessionManager = sessionManager;
}
/// <inheritdoc />
public async Task OnEvent(PluginUninstalledEventArgs eventArgs)
{
await _sessionManager.SendMessageToAdminSessions(SessionMessageType.PackageUninstalled, eventArgs.Argument, CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,51 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.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);
}
}
}
@@ -0,0 +1,43 @@
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Data.Events.Users;
using Jellyfin.Database.Implementations.Entities;
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);
}
}
}
@@ -0,0 +1,44 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Data.Events.Users;
using Jellyfin.Database.Implementations.Entities;
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);
}
}
}
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events.Users;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
namespace Jellyfin.Server.Implementations.Events.Consumers.Users
{
/// <summary>
/// Notifies the user's sessions when a user is deleted.
/// </summary>
public class UserDeletedNotifier : IEventConsumer<UserDeletedEventArgs>
{
private readonly ISessionManager _sessionManager;
/// <summary>
/// Initializes a new instance of the <see cref="UserDeletedNotifier"/> class.
/// </summary>
/// <param name="sessionManager">The session manager.</param>
public UserDeletedNotifier(ISessionManager sessionManager)
{
_sessionManager = sessionManager;
}
/// <inheritdoc />
public async Task OnEvent(UserDeletedEventArgs eventArgs)
{
await _sessionManager.SendMessageToUserSessions(
new List<Guid> { eventArgs.Argument.Id },
SessionMessageType.UserDeleted,
eventArgs.Argument.Id.ToString("N", CultureInfo.InvariantCulture),
CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,47 @@
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Data.Events.Users;
using Jellyfin.Database.Implementations.Entities;
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);
}
}
}
@@ -0,0 +1,43 @@
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Data.Events.Users;
using Jellyfin.Database.Implementations.Entities;
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);
}
}
}
@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events.Users;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
namespace Jellyfin.Server.Implementations.Events.Consumers.Users
{
/// <summary>
/// Notifies a user when their account has been updated.
/// </summary>
public class UserUpdatedNotifier : IEventConsumer<UserUpdatedEventArgs>
{
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
/// <summary>
/// Initializes a new instance of the <see cref="UserUpdatedNotifier"/> class.
/// </summary>
/// <param name="userManager">The user manager.</param>
/// <param name="sessionManager">The session manager.</param>
public UserUpdatedNotifier(IUserManager userManager, ISessionManager sessionManager)
{
_userManager = userManager;
_sessionManager = sessionManager;
}
/// <inheritdoc />
public async Task OnEvent(UserUpdatedEventArgs eventArgs)
{
await _sessionManager.SendMessageToUserSessions(
new List<Guid> { eventArgs.Argument.Id },
SessionMessageType.UserUpdated,
_userManager.GetUserDto(eventArgs.Argument),
CancellationToken.None).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,65 @@
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
{
PublishInternal(eventArgs).GetAwaiter().GetResult();
}
/// <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();
if (scope is null)
{
return;
}
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());
}
}
}
}
}
@@ -0,0 +1,72 @@
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.Events;
using MediaBrowser.Controller.Events.Authentication;
using MediaBrowser.Controller.Events.Session;
using MediaBrowser.Controller.Events.Updates;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
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<LyricDownloadFailureEventArgs>, LyricDownloadFailureLogger>();
collection.AddScoped<IEventConsumer<SubtitleDownloadFailureEventArgs>, SubtitleDownloadFailureLogger>();
// Security consumers
collection.AddScoped<IEventConsumer<AuthenticationRequestEventArgs>, AuthenticationFailedLogger>();
collection.AddScoped<IEventConsumer<AuthenticationResultEventArgs>, 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>();
}
}
}
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
namespace Jellyfin.Server.Implementations.Extensions;
/// <summary>
/// Provides <see cref="Expression"/> extension methods.
/// </summary>
public static class ExpressionExtensions
{
/// <summary>
/// Combines two predicates into a single predicate using a logical OR operation.
/// </summary>
/// <typeparam name="T">The predicate parameter type.</typeparam>
/// <param name="firstPredicate">The first predicate expression to combine.</param>
/// <param name="secondPredicate">The second predicate expression to combine.</param>
/// <returns>A new expression representing the OR combination of the input predicates.</returns>
public static Expression<Func<T, bool>> Or<T>(this Expression<Func<T, bool>> firstPredicate, Expression<Func<T, bool>> secondPredicate)
{
ArgumentNullException.ThrowIfNull(firstPredicate);
ArgumentNullException.ThrowIfNull(secondPredicate);
var invokedExpression = Expression.Invoke(secondPredicate, firstPredicate.Parameters);
return Expression.Lambda<Func<T, bool>>(Expression.OrElse(firstPredicate.Body, invokedExpression), firstPredicate.Parameters);
}
/// <summary>
/// Combines multiple predicates into a single predicate using a logical OR operation.
/// </summary>
/// <typeparam name="T">The predicate parameter type.</typeparam>
/// <param name="predicates">A collection of predicate expressions to combine.</param>
/// <returns>A new expression representing the OR combination of all input predicates.</returns>
public static Expression<Func<T, bool>> Or<T>(this IEnumerable<Expression<Func<T, bool>>> predicates)
{
ArgumentNullException.ThrowIfNull(predicates);
return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.Or(nextPredicate));
}
/// <summary>
/// Combines two predicates into a single predicate using a logical AND operation.
/// </summary>
/// <typeparam name="T">The predicate parameter type.</typeparam>
/// <param name="firstPredicate">The first predicate expression to combine.</param>
/// <param name="secondPredicate">The second predicate expression to combine.</param>
/// <returns>A new expression representing the AND combination of the input predicates.</returns>
public static Expression<Func<T, bool>> And<T>(this Expression<Func<T, bool>> firstPredicate, Expression<Func<T, bool>> secondPredicate)
{
ArgumentNullException.ThrowIfNull(firstPredicate);
ArgumentNullException.ThrowIfNull(secondPredicate);
var invokedExpression = Expression.Invoke(secondPredicate, firstPredicate.Parameters);
return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(firstPredicate.Body, invokedExpression), firstPredicate.Parameters);
}
/// <summary>
/// Combines multiple predicates into a single predicate using a logical AND operation.
/// </summary>
/// <typeparam name="T">The predicate parameter type.</typeparam>
/// <param name="predicates">A collection of predicate expressions to combine.</param>
/// <returns>A new expression representing the AND combination of all input predicates.</returns>
public static Expression<Func<T, bool>> And<T>(this IEnumerable<Expression<Func<T, bool>>> predicates)
{
ArgumentNullException.ThrowIfNull(predicates);
return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.And(nextPredicate));
}
}
@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
namespace Jellyfin.Server.Implementations.Extensions;
/// <summary>
/// Extensions for the <see cref="IServiceCollection"/> interface.
/// </summary>
public static class ServiceCollectionExtensions
{
private static IEnumerable<Type> DatabaseProviderTypes()
{
yield return typeof(SqliteDatabaseProvider);
}
private static IDictionary<string, JellyfinDbProviderFactory> GetSupportedDbProviders()
{
var items = new Dictionary<string, JellyfinDbProviderFactory>(StringComparer.InvariantCultureIgnoreCase);
foreach (var providerType in DatabaseProviderTypes())
{
var keyAttribute = providerType.GetCustomAttribute<JellyfinDatabaseProviderKeyAttribute>();
if (keyAttribute is null || string.IsNullOrWhiteSpace(keyAttribute.DatabaseProviderKey))
{
continue;
}
var provider = providerType;
items[keyAttribute.DatabaseProviderKey] = (services) => (IJellyfinDatabaseProvider)ActivatorUtilities.CreateInstance(services, providerType);
}
return items;
}
private static JellyfinDbProviderFactory? LoadDatabasePlugin(CustomDatabaseOptions customProviderOptions, IApplicationPaths applicationPaths)
{
var plugin = Directory.EnumerateDirectories(applicationPaths.PluginsPath)
.Where(e => Path.GetFileName(e)!.StartsWith(customProviderOptions.PluginName, StringComparison.OrdinalIgnoreCase))
.Order()
.FirstOrDefault()
?? throw new InvalidOperationException($"The requested custom database plugin with the name '{customProviderOptions.PluginName}' could not been found in '{applicationPaths.PluginsPath}'");
var dbProviderAssembly = Path.Combine(plugin, Path.ChangeExtension(customProviderOptions.PluginAssembly, "dll"));
if (!File.Exists(dbProviderAssembly))
{
throw new InvalidOperationException($"Could not find the requested assembly at '{dbProviderAssembly}'");
}
// we have to load the assembly without proxy to ensure maximum performance for this.
var assembly = Assembly.LoadFrom(dbProviderAssembly);
var dbProviderType = assembly.GetExportedTypes().FirstOrDefault(f => f.IsAssignableTo(typeof(IJellyfinDatabaseProvider)))
?? throw new InvalidOperationException($"Could not find any type implementing the '{nameof(IJellyfinDatabaseProvider)}' interface.");
return (services) => (IJellyfinDatabaseProvider)ActivatorUtilities.CreateInstance(services, dbProviderType);
}
/// <summary>
/// Adds the <see cref="IDbContextFactory{TContext}"/> interface to the service collection with second level caching enabled.
/// </summary>
/// <param name="serviceCollection">An instance of the <see cref="IServiceCollection"/> interface.</param>
/// <param name="configurationManager">The server configuration manager.</param>
/// <param name="configuration">The startup Configuration.</param>
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddJellyfinDbContext(
this IServiceCollection serviceCollection,
IServerConfigurationManager configurationManager,
IConfiguration configuration)
{
var efCoreConfiguration = configurationManager.GetConfiguration<DatabaseConfigurationOptions>("database");
JellyfinDbProviderFactory? providerFactory = null;
if (efCoreConfiguration?.DatabaseType is null)
{
var cmdMigrationArgument = configuration.GetValue<string>("migration-provider");
if (!string.IsNullOrWhiteSpace(cmdMigrationArgument))
{
efCoreConfiguration = new DatabaseConfigurationOptions()
{
DatabaseType = cmdMigrationArgument,
};
}
else
{
// when nothing is setup via new Database configuration, fallback to SQLite with default settings.
efCoreConfiguration = new DatabaseConfigurationOptions()
{
DatabaseType = "Jellyfin-SQLite",
LockingBehavior = DatabaseLockingBehaviorTypes.NoLock
};
configurationManager.SaveConfiguration("database", efCoreConfiguration);
}
}
if (efCoreConfiguration.DatabaseType.Equals("PLUGIN_PROVIDER", StringComparison.OrdinalIgnoreCase))
{
if (efCoreConfiguration.CustomProviderOptions is null)
{
throw new InvalidOperationException("The custom database provider must declare the custom provider options to work");
}
providerFactory = LoadDatabasePlugin(efCoreConfiguration.CustomProviderOptions, configurationManager.ApplicationPaths);
}
else
{
var providers = GetSupportedDbProviders();
if (!providers.TryGetValue(efCoreConfiguration.DatabaseType.ToUpperInvariant(), out providerFactory!))
{
throw new InvalidOperationException($"Jellyfin cannot find the database provider of type '{efCoreConfiguration.DatabaseType}'. Supported types are {string.Join(", ", providers.Keys)}");
}
}
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
switch (efCoreConfiguration.LockingBehavior)
{
case DatabaseLockingBehaviorTypes.NoLock:
serviceCollection.AddSingleton<IEntityFrameworkCoreLockingBehavior, NoLockBehavior>();
break;
case DatabaseLockingBehaviorTypes.Pessimistic:
serviceCollection.AddSingleton<IEntityFrameworkCoreLockingBehavior, PessimisticLockBehavior>();
break;
case DatabaseLockingBehaviorTypes.Optimistic:
serviceCollection.AddSingleton<IEntityFrameworkCoreLockingBehavior, OptimisticLockBehavior>();
break;
}
serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) =>
{
var provider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
provider.Initialise(opt, efCoreConfiguration);
var lockingBehavior = serviceProvider.GetRequiredService<IEntityFrameworkCoreLockingBehavior>();
lockingBehavior.Initialise(opt);
});
return serviceCollection;
}
}
@@ -0,0 +1,19 @@
using System;
namespace Jellyfin.Server.Implementations.FullSystemBackup;
/// <summary>
/// Manifest type for backups internal structure.
/// </summary>
internal class BackupManifest
{
public required Version ServerVersion { get; set; }
public required Version BackupEngineVersion { get; set; }
public required DateTimeOffset DateCreated { get; set; }
public required string[] DatabaseTables { get; set; }
public required BackupOptions Options { get; set; }
}
@@ -0,0 +1,15 @@
namespace Jellyfin.Server.Implementations.FullSystemBackup;
/// <summary>
/// Defines the optional contents of the backup archive.
/// </summary>
internal class BackupOptions
{
public bool Metadata { get; set; }
public bool Trickplay { get; set; }
public bool Subtitles { get; set; }
public bool Database { get; set; }
}
@@ -0,0 +1,560 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.Implementations.StorageHelpers;
using Jellyfin.Server.Implementations.SystemBackupService;
using MediaBrowser.Controller;
using MediaBrowser.Controller.SystemBackupService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Implementations.FullSystemBackup;
/// <summary>
/// Contains methods for creating and restoring backups.
/// </summary>
public class BackupService : IBackupService
{
private const string ManifestEntryName = "manifest.json";
private readonly ILogger<BackupService> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IServerApplicationHost _applicationHost;
private readonly IServerApplicationPaths _applicationPaths;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
{
AllowTrailingCommas = true,
ReferenceHandler = ReferenceHandler.IgnoreCycles,
};
private readonly Version _backupEngineVersion = new Version(0, 2, 0);
/// <summary>
/// Initializes a new instance of the <see cref="BackupService"/> class.
/// </summary>
/// <param name="logger">A logger.</param>
/// <param name="dbProvider">A Database Factory.</param>
/// <param name="applicationHost">The Application host.</param>
/// <param name="applicationPaths">The application paths.</param>
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
/// <param name="applicationLifetime">The SystemManager.</param>
public BackupService(
ILogger<BackupService> logger,
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost applicationHost,
IServerApplicationPaths applicationPaths,
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
IHostApplicationLifetime applicationLifetime)
{
_logger = logger;
_dbProvider = dbProvider;
_applicationHost = applicationHost;
_applicationPaths = applicationPaths;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
_hostApplicationLifetime = applicationLifetime;
}
/// <inheritdoc/>
public void ScheduleRestoreAndRestartServer(string archivePath)
{
_applicationHost.RestoreBackupPath = archivePath;
_applicationHost.ShouldRestart = true;
_applicationHost.NotifyPendingRestart();
_ = Task.Run(async () =>
{
await Task.Delay(500).ConfigureAwait(false);
_hostApplicationLifetime.StopApplication();
});
}
/// <inheritdoc/>
public async Task RestoreBackupAsync(string archivePath)
{
_logger.LogWarning("Begin restoring system to {BackupArchive}", archivePath); // Info isn't cutting it
if (!File.Exists(archivePath))
{
throw new FileNotFoundException($"Requested backup file '{archivePath}' does not exist.");
}
StorageHelper.TestCommonPathsForStorageCapacity(_applicationPaths, _logger);
var fileStream = File.OpenRead(archivePath);
await using (fileStream.ConfigureAwait(false))
{
using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, false);
var zipArchiveEntry = zipArchive.GetEntry(ManifestEntryName);
if (zipArchiveEntry is null)
{
throw new NotSupportedException($"The loaded archive '{archivePath}' does not appear to be a Jellyfin backup as its missing the '{ManifestEntryName}'.");
}
BackupManifest? manifest;
var manifestStream = await zipArchiveEntry.OpenAsync().ConfigureAwait(false);
await using (manifestStream.ConfigureAwait(false))
{
manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
}
if (manifest!.ServerVersion > _applicationHost.ApplicationVersion) // newer versions of Jellyfin should be able to load older versions as we have migrations.
{
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
}
if (!TestBackupVersionCompatibility(manifest.BackupEngineVersion))
{
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
}
void CopyDirectory(string source, string target)
{
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
foreach (var item in zipArchive.Entries)
{
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|| Path.EndsInDirectorySeparator(item.FullName))
{
continue;
}
_logger.LogInformation("Restore and override {File}", targetPath);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
item.ExtractToFile(targetPath, overwrite: true);
}
}
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
CopyDirectory("Data", _applicationPaths.DataPath);
CopyDirectory("Root", _applicationPaths.RootFolderPath);
if (manifest.Options.Database)
{
_logger.LogInformation("Begin restoring Database");
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
// restore migration history manually
var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json")));
if (historyEntry is null)
{
_logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
throw new InvalidOperationException("Cannot restore backup that has no History data.");
}
HistoryRow[] historyEntries;
var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false);
await using (historyArchive.ConfigureAwait(false))
{
historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
throw new InvalidOperationException("Cannot restore backup that has no History data.");
}
var historyRepository = dbContext.GetService<IHistoryRepository>();
await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false))
{
var insertScript = historyRepository.GetDeleteScript(item.MigrationId);
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
}
foreach (var item in historyEntries)
{
var insertScript = historyRepository.GetInsertScript(item);
await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
}
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
.Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
.ToArray();
var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!);
_logger.LogInformation("Begin purging database");
await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
_logger.LogInformation("Database Purged");
foreach (var entityType in entityTypes)
{
_logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
if (zipEntry is null)
{
_logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name);
continue;
}
var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false);
await using (zipEntryStream.ConfigureAwait(false))
{
_logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
var records = 0;
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false))
{
var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
if (entity is null)
{
throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
}
try
{
records++;
dbContext.Add(entity);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
}
}
_logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityType.Type.Name);
}
}
_logger.LogInformation("Try restore Database");
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogInformation("Restored database");
}
}
_logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
}
}
private bool TestBackupVersionCompatibility(Version backupEngineVersion)
{
if (backupEngineVersion == _backupEngineVersion)
{
return true;
}
return false;
}
/// <inheritdoc/>
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
{
var manifest = new BackupManifest()
{
DateCreated = DateTime.UtcNow,
ServerVersion = _applicationHost.ApplicationVersion,
DatabaseTables = null!,
BackupEngineVersion = _backupEngineVersion,
Options = Map(backupOptions)
};
_logger.LogInformation("Running database optimization before backup");
await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
var backupFolder = Path.Combine(_applicationPaths.BackupPath);
if (!Directory.Exists(backupFolder))
{
Directory.CreateDirectory(backupFolder);
}
var backupStorageSpace = StorageHelper.GetFreeSpaceOf(_applicationPaths.BackupPath);
const long FiveGigabyte = 5_368_709_115;
if (backupStorageSpace.FreeSpace < FiveGigabyte)
{
throw new InvalidOperationException($"The backup directory '{backupStorageSpace.Path}' does not have at least '{StorageHelper.HumanizeStorageSize(FiveGigabyte)}' free space. Cannot create backup.");
}
var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
try
{
_logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
var fileStream = File.OpenWrite(backupPath);
await using (fileStream.ConfigureAwait(false))
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
{
_logger.LogInformation("Starting backup process");
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
{
var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
var enumerable = method.Invoke(dbSet, null)!;
return (IAsyncEnumerable<object>)enumerable;
}
// include the migration history as well
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
[
.. typeof(JellyfinDbContext)
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
];
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
_logger.LogInformation("Begin Database backup");
foreach (var entityType in entityTypes)
{
_logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
var entities = 0;
var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false);
await using (zipEntryStream.ConfigureAwait(false))
{
var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
await using (jsonSerializer.ConfigureAwait(false))
{
jsonSerializer.WriteStartArray();
var set = entityType.ValueFactory().ConfigureAwait(false);
await foreach (var item in set.ConfigureAwait(false))
{
entities++;
try
{
using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
document.WriteTo(jsonSerializer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not load entity {Entity}", item);
throw;
}
}
jsonSerializer.WriteEndArray();
}
}
_logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
}
}
}
_logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
.Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
{
await zipArchive.CreateEntryFromFileAsync(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))).ConfigureAwait(false);
}
void CopyDirectory(string source, string target, string filter = "*")
{
if (!Directory.Exists(source))
{
return;
}
_logger.LogInformation("Backup of folder {Table}", source);
foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
{
// TODO: @bond make async
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
}
}
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
if (backupOptions.Subtitles)
{
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
}
if (backupOptions.Trickplay)
{
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
}
if (backupOptions.Metadata)
{
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
}
var manifestStream = await zipArchive.CreateEntry(ManifestEntryName).OpenAsync().ConfigureAwait(false);
await using (manifestStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
}
}
_logger.LogInformation("Backup created");
return Map(manifest, backupPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
try
{
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
}
catch (Exception innerEx)
{
_logger.LogWarning(innerEx, "Unable to remove failed backup");
}
throw;
}
}
/// <inheritdoc/>
public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
{
if (!File.Exists(archivePath))
{
return null;
}
BackupManifest? manifest;
try
{
manifest = await GetManifest(archivePath).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
return null;
}
if (manifest is null)
{
return null;
}
return Map(manifest, archivePath);
}
/// <inheritdoc/>
public async Task<BackupManifestDto[]> EnumerateBackups()
{
if (!Directory.Exists(_applicationPaths.BackupPath))
{
return [];
}
var archives = Directory.EnumerateFiles(_applicationPaths.BackupPath, "*.zip");
var manifests = new List<BackupManifestDto>();
foreach (var item in archives)
{
try
{
var manifest = await GetManifest(item).ConfigureAwait(false);
if (manifest is null)
{
continue;
}
manifests.Add(Map(manifest, item));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
}
}
return manifests.ToArray();
}
private static async ValueTask<BackupManifest?> GetManifest(string archivePath)
{
var archiveStream = File.OpenRead(archivePath);
await using (archiveStream.ConfigureAwait(false))
{
using var zipStream = new ZipArchive(archiveStream, ZipArchiveMode.Read);
var manifestEntry = zipStream.GetEntry(ManifestEntryName);
if (manifestEntry is null)
{
return null;
}
var manifestStream = await manifestEntry.OpenAsync().ConfigureAwait(false);
await using (manifestStream.ConfigureAwait(false))
{
return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
}
}
}
private static BackupManifestDto Map(BackupManifest manifest, string path)
{
return new BackupManifestDto()
{
BackupEngineVersion = manifest.BackupEngineVersion,
DateCreated = manifest.DateCreated,
ServerVersion = manifest.ServerVersion,
Path = path,
Options = Map(manifest.Options)
};
}
private static BackupOptionsDto Map(BackupOptions options)
{
return new BackupOptionsDto()
{
Metadata = options.Metadata,
Subtitles = options.Subtitles,
Trickplay = options.Trickplay,
Database = options.Database
};
}
private static BackupOptions Map(BackupOptionsDto options)
{
return new BackupOptions()
{
Metadata = options.Metadata,
Subtitles = options.Subtitles,
Trickplay = options.Trickplay,
Database = options.Database
};
}
/// <summary>
/// Windows is able to handle '/' as a path seperator in zip files
/// but linux isn't able to handle '\' as a path seperator in zip files,
/// So normalize to '/'.
/// </summary>
/// <param name="path">The path to normalize.</param>
/// <returns>The normalized path. </returns>
private static string NormalizePathSeparator(string path)
=> path.Replace('\\', '/');
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// The Chapter manager.
/// </summary>
public class ChapterRepository : IChapterRepository
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IImageProcessor _imageProcessor;
/// <summary>
/// Initializes a new instance of the <see cref="ChapterRepository"/> class.
/// </summary>
/// <param name="dbProvider">The EFCore provider.</param>
/// <param name="imageProcessor">The Image Processor.</param>
public ChapterRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IImageProcessor imageProcessor)
{
_dbProvider = dbProvider;
_imageProcessor = imageProcessor;
}
/// <inheritdoc />
public ChapterInfo? GetChapter(Guid baseItemId, int index)
{
using var context = _dbProvider.CreateDbContext();
var chapter = context.Chapters.AsNoTracking()
.Select(e => new
{
chapter = e,
baseItemPath = e.Item.Path
})
.FirstOrDefault(e => e.chapter.ItemId.Equals(baseItemId) && e.chapter.ChapterIndex == index);
if (chapter is not null)
{
return Map(chapter.chapter, chapter.baseItemPath!);
}
return null;
}
/// <inheritdoc />
public IReadOnlyList<ChapterInfo> GetChapters(Guid baseItemId)
{
using var context = _dbProvider.CreateDbContext();
return context.Chapters.AsNoTracking().Where(e => e.ItemId.Equals(baseItemId))
.Select(e => new
{
chapter = e,
baseItemPath = e.Item.Path
})
.AsEnumerable()
.Select(e => Map(e.chapter, e.baseItemPath!))
.ToArray();
}
/// <inheritdoc />
public void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters)
{
using var context = _dbProvider.CreateDbContext();
using (var transaction = context.Database.BeginTransaction())
{
context.Chapters.Where(e => e.ItemId.Equals(itemId)).ExecuteDelete();
for (var i = 0; i < chapters.Count; i++)
{
var chapter = chapters[i];
context.Chapters.Add(Map(chapter, i, itemId));
}
context.SaveChanges();
transaction.Commit();
}
}
/// <inheritdoc />
public async Task DeleteChaptersAsync(Guid itemId, CancellationToken cancellationToken)
{
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await dbContext.Chapters.Where(c => c.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
private Chapter Map(ChapterInfo chapterInfo, int index, Guid itemId)
{
return new Chapter()
{
ChapterIndex = index,
StartPositionTicks = chapterInfo.StartPositionTicks,
ImageDateModified = chapterInfo.ImageDateModified,
ImagePath = chapterInfo.ImagePath,
ItemId = itemId,
Name = chapterInfo.Name,
Item = null!
};
}
private ChapterInfo Map(Chapter chapterInfo, string baseItemPath)
{
var chapterEntity = new ChapterInfo()
{
StartPositionTicks = chapterInfo.StartPositionTicks,
ImageDateModified = chapterInfo.ImageDateModified.GetValueOrDefault(),
ImagePath = chapterInfo.ImagePath,
Name = chapterInfo.Name,
};
if (!string.IsNullOrEmpty(chapterInfo.ImagePath))
{
chapterEntity.ImageTag = _imageProcessor.GetImageCacheTag(baseItemPath, chapterEntity.ImageDateModified);
}
return chapterEntity;
}
}
@@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Repository for obtaining Keyframe data.
/// </summary>
public class KeyframeRepository : IKeyframeRepository
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
/// <summary>
/// Initializes a new instance of the <see cref="KeyframeRepository"/> class.
/// </summary>
/// <param name="dbProvider">The EFCore db factory.</param>
public KeyframeRepository(IDbContextFactory<JellyfinDbContext> dbProvider)
{
_dbProvider = dbProvider;
}
private static MediaEncoding.Keyframes.KeyframeData Map(KeyframeData entity)
{
return new MediaEncoding.Keyframes.KeyframeData(
entity.TotalDuration,
(entity.KeyframeTicks ?? []).ToList());
}
private KeyframeData Map(MediaEncoding.Keyframes.KeyframeData dto, Guid itemId)
{
return new()
{
ItemId = itemId,
TotalDuration = dto.TotalDuration,
KeyframeTicks = dto.KeyframeTicks.ToList()
};
}
/// <inheritdoc />
public IReadOnlyList<MediaEncoding.Keyframes.KeyframeData> GetKeyframeData(Guid itemId)
{
using var context = _dbProvider.CreateDbContext();
return context.KeyframeData.AsNoTracking().Where(e => e.ItemId.Equals(itemId)).Select(e => Map(e)).ToList();
}
/// <inheritdoc />
public async Task SaveKeyframeDataAsync(Guid itemId, MediaEncoding.Keyframes.KeyframeData data, CancellationToken cancellationToken)
{
using var context = _dbProvider.CreateDbContext();
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task DeleteKeyframeDataAsync(Guid itemId, CancellationToken cancellationToken)
{
using var context = _dbProvider.CreateDbContext();
await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Manager for handling Media Attachments.
/// </summary>
/// <param name="dbProvider">Efcore Factory.</param>
public class MediaAttachmentRepository(IDbContextFactory<JellyfinDbContext> dbProvider) : IMediaAttachmentRepository
{
/// <inheritdoc />
public void SaveMediaAttachments(
Guid id,
IReadOnlyList<MediaAttachment> attachments,
CancellationToken cancellationToken)
{
using var context = dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
// Users may replace a media with a version that includes attachments to one without them.
// So when saving attachments is triggered by a library scan, we always unconditionally
// clear the old ones, and then add the new ones if given.
context.AttachmentStreamInfos.Where(e => e.ItemId.Equals(id)).ExecuteDelete();
if (attachments.Any())
{
context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id)));
}
context.SaveChanges();
transaction.Commit();
}
/// <inheritdoc />
public IReadOnlyList<MediaAttachment> GetMediaAttachments(MediaAttachmentQuery filter)
{
using var context = dbProvider.CreateDbContext();
var query = context.AttachmentStreamInfos.AsNoTracking().Where(e => e.ItemId.Equals(filter.ItemId));
if (filter.Index.HasValue)
{
query = query.Where(e => e.Index == filter.Index);
}
return query.AsEnumerable().Select(Map).ToArray();
}
private MediaAttachment Map(AttachmentStreamInfo attachment)
{
return new MediaAttachment()
{
Codec = attachment.Codec,
CodecTag = attachment.CodecTag,
Comment = attachment.Comment,
FileName = attachment.Filename,
Index = attachment.Index,
MimeType = attachment.MimeType,
};
}
private AttachmentStreamInfo Map(MediaAttachment attachment, Guid id)
{
return new AttachmentStreamInfo()
{
Codec = attachment.Codec,
CodecTag = attachment.CodecTag,
Comment = attachment.Comment,
Filename = attachment.FileName,
Index = attachment.Index,
MimeType = attachment.MimeType,
ItemId = id,
Item = null!
};
}
}
@@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Repository for obtaining MediaStreams.
/// </summary>
public class MediaStreamRepository : IMediaStreamRepository
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IServerApplicationHost _serverApplicationHost;
private readonly ILocalizationManager _localization;
/// <summary>
/// Initializes a new instance of the <see cref="MediaStreamRepository"/> class.
/// </summary>
/// <param name="dbProvider">The EFCore db factory.</param>
/// <param name="serverApplicationHost">The Application host.</param>
/// <param name="localization">The Localisation Provider.</param>
public MediaStreamRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationHost serverApplicationHost, ILocalizationManager localization)
{
_dbProvider = dbProvider;
_serverApplicationHost = serverApplicationHost;
_localization = localization;
}
/// <inheritdoc />
public void SaveMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, CancellationToken cancellationToken)
{
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
context.MediaStreamInfos.Where(e => e.ItemId.Equals(id)).ExecuteDelete();
context.MediaStreamInfos.AddRange(streams.Select(f => Map(f, id)));
context.SaveChanges();
transaction.Commit();
}
/// <inheritdoc />
public IReadOnlyList<MediaStream> GetMediaStreams(MediaStreamQuery filter)
{
using var context = _dbProvider.CreateDbContext();
return TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter).AsEnumerable().Select(Map).ToArray();
}
private string? GetPathToSave(string? path)
{
if (path is null)
{
return null;
}
return _serverApplicationHost.ReverseVirtualPath(path);
}
private string? RestorePath(string? path)
{
if (path is null)
{
return null;
}
return _serverApplicationHost.ExpandVirtualPath(path);
}
private IQueryable<MediaStreamInfo> TranslateQuery(IQueryable<MediaStreamInfo> query, MediaStreamQuery filter)
{
query = query.Where(e => e.ItemId.Equals(filter.ItemId));
if (filter.Index.HasValue)
{
query = query.Where(e => e.StreamIndex == filter.Index);
}
if (filter.Type.HasValue)
{
var typeValue = (MediaStreamTypeEntity)filter.Type.Value;
query = query.Where(e => e.StreamType == typeValue);
}
return query.OrderBy(e => e.StreamIndex);
}
private MediaStream Map(MediaStreamInfo entity)
{
var dto = new MediaStream();
dto.Index = entity.StreamIndex;
dto.Type = (MediaStreamType)entity.StreamType;
dto.IsAVC = entity.IsAvc;
dto.Codec = entity.Codec;
var language = entity.Language;
// Check if the language has multiple three letter ISO codes
// if yes choose the first as that is the ISO 639-2/T code we're needing
if (language != null && _localization.TryGetISO6392TFromB(language, out string? isoT))
{
language = isoT;
}
dto.Language = language;
dto.ChannelLayout = entity.ChannelLayout;
dto.Profile = entity.Profile;
dto.AspectRatio = entity.AspectRatio;
dto.Path = RestorePath(entity.Path);
dto.IsInterlaced = entity.IsInterlaced.GetValueOrDefault();
dto.BitRate = entity.BitRate;
dto.Channels = entity.Channels;
dto.SampleRate = entity.SampleRate;
dto.IsDefault = entity.IsDefault;
dto.IsForced = entity.IsForced;
dto.IsExternal = entity.IsExternal;
dto.Height = entity.Height;
dto.Width = entity.Width;
dto.AverageFrameRate = entity.AverageFrameRate;
dto.RealFrameRate = entity.RealFrameRate;
dto.Level = entity.Level;
dto.PixelFormat = entity.PixelFormat;
dto.BitDepth = entity.BitDepth;
dto.IsAnamorphic = entity.IsAnamorphic;
dto.RefFrames = entity.RefFrames;
dto.CodecTag = entity.CodecTag;
dto.Comment = entity.Comment;
dto.NalLengthSize = entity.NalLengthSize;
dto.Title = entity.Title;
dto.TimeBase = entity.TimeBase;
dto.CodecTimeBase = entity.CodecTimeBase;
dto.ColorPrimaries = entity.ColorPrimaries;
dto.ColorSpace = entity.ColorSpace;
dto.ColorTransfer = entity.ColorTransfer;
dto.DvVersionMajor = entity.DvVersionMajor;
dto.DvVersionMinor = entity.DvVersionMinor;
dto.DvProfile = entity.DvProfile;
dto.DvLevel = entity.DvLevel;
dto.RpuPresentFlag = entity.RpuPresentFlag;
dto.ElPresentFlag = entity.ElPresentFlag;
dto.BlPresentFlag = entity.BlPresentFlag;
dto.DvBlSignalCompatibilityId = entity.DvBlSignalCompatibilityId;
dto.IsHearingImpaired = entity.IsHearingImpaired.GetValueOrDefault();
dto.Rotation = entity.Rotation;
dto.Hdr10PlusPresentFlag = entity.Hdr10PlusPresentFlag;
if (dto.Type is MediaStreamType.Audio or MediaStreamType.Subtitle)
{
dto.LocalizedDefault = _localization.GetLocalizedString("Default");
dto.LocalizedExternal = _localization.GetLocalizedString("External");
if (!string.IsNullOrEmpty(dto.Language))
{
var culture = _localization.FindLanguageInfo(dto.Language);
dto.LocalizedLanguage = culture?.DisplayName;
}
if (dto.Type is MediaStreamType.Subtitle)
{
dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined");
dto.LocalizedForced = _localization.GetLocalizedString("Forced");
dto.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired");
}
}
return dto;
}
private MediaStreamInfo Map(MediaStream dto, Guid itemId)
{
var entity = new MediaStreamInfo
{
Item = null!,
ItemId = itemId,
StreamIndex = dto.Index,
StreamType = (MediaStreamTypeEntity)dto.Type,
IsAvc = dto.IsAVC,
Codec = dto.Codec,
Language = dto.Language,
ChannelLayout = dto.ChannelLayout,
Profile = dto.Profile,
AspectRatio = dto.AspectRatio,
Path = GetPathToSave(dto.Path) ?? dto.Path,
IsInterlaced = dto.IsInterlaced,
BitRate = dto.BitRate,
Channels = dto.Channels,
SampleRate = dto.SampleRate,
IsDefault = dto.IsDefault,
IsForced = dto.IsForced,
IsExternal = dto.IsExternal,
Height = dto.Height,
Width = dto.Width,
AverageFrameRate = dto.AverageFrameRate,
RealFrameRate = dto.RealFrameRate,
Level = dto.Level.HasValue ? (float)dto.Level : null,
PixelFormat = dto.PixelFormat,
BitDepth = dto.BitDepth,
IsAnamorphic = dto.IsAnamorphic,
RefFrames = dto.RefFrames,
CodecTag = dto.CodecTag,
Comment = dto.Comment,
NalLengthSize = dto.NalLengthSize,
Title = dto.Title,
TimeBase = dto.TimeBase,
CodecTimeBase = dto.CodecTimeBase,
ColorPrimaries = dto.ColorPrimaries,
ColorSpace = dto.ColorSpace,
ColorTransfer = dto.ColorTransfer,
DvVersionMajor = dto.DvVersionMajor,
DvVersionMinor = dto.DvVersionMinor,
DvProfile = dto.DvProfile,
DvLevel = dto.DvLevel,
RpuPresentFlag = dto.RpuPresentFlag,
ElPresentFlag = dto.ElPresentFlag,
BlPresentFlag = dto.BlPresentFlag,
DvBlSignalCompatibilityId = dto.DvBlSignalCompatibilityId,
IsHearingImpaired = dto.IsHearingImpaired,
Rotation = dto.Rotation,
Hdr10PlusPresentFlag = dto.Hdr10PlusPresentFlag,
};
return entity;
}
}
@@ -0,0 +1,98 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Static class for methods which maps types of ordering to their respecting ordering functions.
/// </summary>
public static class OrderMapper
{
/// <summary>
/// Creates Func to be executed later with a given BaseItemEntity input for sorting items on query.
/// </summary>
/// <param name="sortBy">Item property to sort by.</param>
/// <param name="query">Context Query.</param>
/// <param name="jellyfinDbContext">Context.</param>
/// <returns>Func to be executed later for sorting query.</returns>
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
{
return (sortBy, query.User) switch
{
(ItemSortBy.AirTime, _) => e => e.SortName, // TODO
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
(ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
(ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
(ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
(ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
(ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded,
(ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
(ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
(ItemSortBy.Album, _) => e => e.Album,
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
(ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
(ItemSortBy.StartDate, _) => e => e.StartDate,
(ItemSortBy.Name, _) => e => e.CleanName,
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
(ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate,
(ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber,
(ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
(ItemSortBy.SeriesDatePlayed, not null) => e =>
jellyfinDbContext.BaseItems
.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
// ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
// .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played)
// .Max(f => f.LastPlayedDate),
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
_ => e => e.SortName
};
}
/// <summary>
/// Creates an expression to order search results by match quality.
/// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
/// </summary>
/// <param name="searchTerm">The search term to match against.</param>
/// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm)
{
var cleanSearchTerm = GetCleanValue(searchTerm);
var searchPrefix = cleanSearchTerm + " ";
return e =>
e.CleanName == cleanSearchTerm ? 0 :
e.CleanName!.StartsWith(searchPrefix) ? 1 :
e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
}
private static string GetCleanValue(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
return value.RemoveDiacritics().ToLowerInvariant();
}
}
@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Libraries;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Item;
#pragma warning disable RS0030 // Do not use banned APIs
#pragma warning disable CA1304 // Specify CultureInfo
#pragma warning disable CA1311 // Specify a culture or use an invariant version
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
/// <summary>
/// Manager for handling people.
/// </summary>
/// <param name="dbProvider">Efcore Factory.</param>
/// <param name="itemTypeLookup">Items lookup service.</param>
/// <remarks>
/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
/// </remarks>
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
/// <inheritdoc/>
public IReadOnlyList<PersonInfo> GetPeople(InternalPeopleQuery filter)
{
using var context = _dbProvider.CreateDbContext();
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
// Include PeopleBaseItemMap
if (!filter.ItemId.IsEmpty())
{
dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
.OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder)
.ThenBy(e => e.PersonType)
.ThenBy(e => e.Name);
}
else
{
dbQuery = dbQuery.OrderBy(e => e.Name);
}
if (filter.Limit > 0)
{
dbQuery = dbQuery.Take(filter.Limit);
}
return dbQuery.AsEnumerable().Select(Map).ToArray();
}
/// <inheritdoc/>
public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter)
{
using var context = _dbProvider.CreateDbContext();
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
// dbQuery = dbQuery.OrderBy(e => e.ListOrder);
if (filter.Limit > 0)
{
dbQuery = dbQuery.Take(filter.Limit);
}
return dbQuery.ToArray();
}
/// <inheritdoc />
public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people)
{
foreach (var person in people)
{
person.Name = person.Name.Trim();
person.Role = person.Role?.Trim() ?? string.Empty;
}
// multiple metadata providers can provide the _same_ person
people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray();
var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray();
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
var existingPersons = context.Peoples.Select(e => new
{
item = e,
SelectionKey = e.Name + "-" + e.PersonType
})
.Where(p => personKeys.Contains(p.SelectionKey))
.Select(f => f.item)
.ToArray();
var toAdd = people
.Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
.Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
.Select(Map);
context.Peoples.AddRange(toAdd);
context.SaveChanges();
var personsEntities = toAdd.Concat(existingPersons).ToArray();
var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
var listOrder = 0;
foreach (var person in people)
{
if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist)
{
continue;
}
var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString());
var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
if (existingMap is null)
{
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
{
Item = null!,
ItemId = itemId,
People = null!,
PeopleId = entityPerson.Id,
ListOrder = listOrder,
SortOrder = person.SortOrder,
Role = person.Role
});
}
else
{
// Update the order for existing mappings
existingMap.ListOrder = listOrder;
existingMap.SortOrder = person.SortOrder;
// person mapping already exists so remove from list
existingMaps.Remove(existingMap);
}
listOrder++;
}
context.PeopleBaseItemMap.RemoveRange(existingMaps);
context.SaveChanges();
transaction.Commit();
}
private PersonInfo Map(People people)
{
var mapping = people.BaseItems?.FirstOrDefault();
var personInfo = new PersonInfo()
{
Id = people.Id,
Name = people.Name,
Role = mapping?.Role,
SortOrder = mapping?.SortOrder
};
if (Enum.TryParse<PersonKind>(people.PersonType, out var kind))
{
personInfo.Type = kind;
}
return personInfo;
}
private People Map(PersonInfo people)
{
var personInfo = new People()
{
Name = people.Name,
PersonType = people.Type.ToString(),
Id = people.Id,
};
return personInfo;
}
private IQueryable<People> TranslateQuery(IQueryable<People> query, JellyfinDbContext context, InternalPeopleQuery filter)
{
if (filter.User is not null && filter.IsFavorite.HasValue)
{
var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var oldQuery = query;
query = context.UserData
.Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))
.Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person)
.Distinct()
.AsNoTracking();
}
if (!filter.ItemId.IsEmpty())
{
query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
}
if (!filter.AppearsInItemId.IsEmpty())
{
query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId)));
}
var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList();
if (queryPersonTypes.Count > 0)
{
query = query.Where(e => queryPersonTypes.Contains(e.PersonType));
}
var queryExcludePersonTypes = filter.ExcludePersonTypes.Where(IsValidPersonType).ToList();
if (queryExcludePersonTypes.Count > 0)
{
query = query.Where(e => !queryPersonTypes.Contains(e.PersonType));
}
if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty())
{
query = query.Where(e => e.BaseItems!.First(w => w.ItemId == filter.ItemId).ListOrder <= filter.MaxListOrder.Value);
}
if (!string.IsNullOrWhiteSpace(filter.NameContains))
{
var nameContainsUpper = filter.NameContains.ToUpper();
query = query.Where(e => e.Name.ToUpper().Contains(nameContainsUpper));
}
return query;
}
private bool IsAlphaNumeric(string str)
{
if (string.IsNullOrWhiteSpace(str))
{
return false;
}
for (int i = 0; i < str.Length; i++)
{
if (!char.IsLetter(str[i]) && !char.IsNumber(str[i]))
{
return false;
}
}
return true;
}
private bool IsValidPersonType(string value)
{
return IsAlphaNumeric(value);
}
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<!-- Code Analyzers -->
<ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
<PackageReference Include="IDisposableAnalyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="SerilogAnalyzer" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\SharedVersion.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Jellyfin.Data\Jellyfin.Data.csproj" />
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,289 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.MediaSegments;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Implementations.MediaSegments;
/// <summary>
/// Manages media segments retrieval and storage.
/// </summary>
public class MediaSegmentManager : IMediaSegmentManager
{
private readonly ILogger<MediaSegmentManager> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IMediaSegmentProvider[] _segmentProviders;
/// <summary>
/// Initializes a new instance of the <see cref="MediaSegmentManager"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="dbProvider">EFCore Database factory.</param>
/// <param name="segmentProviders">List of all media segment providers.</param>
public MediaSegmentManager(
ILogger<MediaSegmentManager> logger,
IDbContextFactory<JellyfinDbContext> dbProvider,
IEnumerable<IMediaSegmentProvider> segmentProviders)
{
_logger = logger;
_dbProvider = dbProvider;
_segmentProviders = segmentProviders
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
.ToArray();
}
/// <inheritdoc/>
public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken)
{
var providers = _segmentProviders
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
.OrderBy(i =>
{
var index = libraryOptions.MediaSegmentProviderOrder.IndexOf(i.Name);
return index == -1 ? int.MaxValue : index;
})
.ToList();
if (providers.Count == 0)
{
_logger.LogDebug("Skipping media segment extraction as no providers are enabled for {MediaPath}", baseItem.Path);
return;
}
var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (db.ConfigureAwait(false))
{
_logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count);
if (forceOverwrite)
{
// delete all existing media segments if forceOverwrite is set.
await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
foreach (var provider in providers)
{
if (!await provider.Supports(baseItem).ConfigureAwait(false))
{
_logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path);
continue;
}
IQueryable<MediaSegment> existingSegments;
if (forceOverwrite)
{
existingSegments = Array.Empty<MediaSegment>().AsQueryable();
}
else
{
existingSegments = db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id) && e.SegmentProviderId == GetProviderId(provider.Name));
}
var requestItem = new MediaSegmentGenerationRequest()
{
ItemId = baseItem.Id,
ExistingSegments = existingSegments.Select(e => Map(e)).ToArray()
};
try
{
var segments = await provider.GetMediaSegments(requestItem, cancellationToken)
.ConfigureAwait(false);
if (!forceOverwrite)
{
var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items.
if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f =>
{
return
e.StartTicks == f.StartTicks &&
e.EndTicks == f.EndTicks &&
e.Type == f.Type;
})))
{
_logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path);
continue;
}
// delete existing media segments that were re-generated.
await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
if (segments.Count == 0 && !requestItem.ExistingSegments.Any())
{
_logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path);
continue;
}
else if (segments.Count == 0 && requestItem.ExistingSegments.Any())
{
_logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path);
continue;
}
_logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path);
var providerId = GetProviderId(provider.Name);
foreach (var segment in segments)
{
segment.ItemId = baseItem.Id;
await CreateSegmentAsync(segment, providerId).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path);
}
}
}
}
/// <inheritdoc />
public async Task<MediaSegmentDto> CreateSegmentAsync(MediaSegmentDto mediaSegment, string segmentProviderId)
{
ArgumentOutOfRangeException.ThrowIfLessThan(mediaSegment.EndTicks, mediaSegment.StartTicks);
var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (db.ConfigureAwait(false))
{
db.MediaSegments.Add(Map(mediaSegment, segmentProviderId));
await db.SaveChangesAsync().ConfigureAwait(false);
}
return mediaSegment;
}
/// <inheritdoc />
public async Task DeleteSegmentAsync(Guid segmentId)
{
var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (db.ConfigureAwait(false))
{
await db.MediaSegments.Where(e => e.Id.Equals(segmentId)).ExecuteDeleteAsync().ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task DeleteSegmentsAsync(Guid itemId, CancellationToken cancellationToken)
{
var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (db.ConfigureAwait(false))
{
await db.MediaSegments.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem? item, IEnumerable<MediaSegmentType>? typeFilter, LibraryOptions libraryOptions, bool filterByProvider = true)
{
if (item is null)
{
_logger.LogError("Tried to request segments for an invalid item");
return [];
}
var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (db.ConfigureAwait(false))
{
var query = db.MediaSegments
.Where(e => e.ItemId.Equals(item.Id));
if (typeFilter is not null)
{
query = query.Where(e => typeFilter.Contains(e.Type));
}
if (filterByProvider)
{
var providerIds = _segmentProviders
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
.Select(f => GetProviderId(f.Name))
.ToArray();
if (providerIds.Length == 0)
{
return [];
}
query = query.Where(e => providerIds.Contains(e.SegmentProviderId));
}
return query
.OrderBy(e => e.StartTicks)
.AsNoTracking()
.AsEnumerable()
.Select(Map)
.ToArray();
}
}
private static MediaSegmentDto Map(MediaSegment segment)
{
return new MediaSegmentDto()
{
Id = segment.Id,
EndTicks = segment.EndTicks,
ItemId = segment.ItemId,
StartTicks = segment.StartTicks,
Type = segment.Type
};
}
private static MediaSegment Map(MediaSegmentDto segment, string segmentProviderId)
{
return new MediaSegment()
{
Id = segment.Id,
EndTicks = segment.EndTicks,
ItemId = segment.ItemId,
StartTicks = segment.StartTicks,
Type = segment.Type,
SegmentProviderId = segmentProviderId
};
}
/// <inheritdoc />
public bool HasSegments(Guid itemId)
{
using var db = _dbProvider.CreateDbContext();
return db.MediaSegments.Any(e => e.ItemId.Equals(itemId));
}
/// <inheritdoc/>
public bool IsTypeSupported(BaseItem baseItem)
{
return baseItem.MediaType is Data.Enums.MediaType.Video or Data.Enums.MediaType.Audio;
}
/// <inheritdoc/>
public IEnumerable<(string Name, string Id)> GetSupportedProviders(BaseItem item)
{
if (item is not (Video or Audio))
{
return [];
}
return _segmentProviders
.Select(p => (p.Name, GetProviderId(p.Name)));
}
private string GetProviderId(string name)
=> name.ToLowerInvariant()
.GetMD5()
.ToString("N", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,23 @@
using System.Reflection;
using System.Resources;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("Jellyfin.Server.Implementations")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("Jellyfin Project")]
[assembly: AssemblyProduct("Jellyfin Server")]
[assembly: AssemblyCopyright("Copyright © 2019 Jellyfin Contributors. Code released under the GNU General Public License")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: NeutralResourcesLanguage("en")]
[assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
@@ -0,0 +1,69 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities.Security;
using MediaBrowser.Controller.Security;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Implementations.Security
{
/// <inheritdoc />
public class AuthenticationManager : IAuthenticationManager
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationManager"/> class.
/// </summary>
/// <param name="dbProvider">The database provider.</param>
public AuthenticationManager(IDbContextFactory<JellyfinDbContext> dbProvider)
{
_dbProvider = dbProvider;
}
/// <inheritdoc />
public async Task CreateApiKey(string name)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.ApiKeys.Add(new ApiKey(name));
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<AuthenticationInfo>> GetApiKeys()
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
return await dbContext.ApiKeys
.Select(key => new AuthenticationInfo
{
AppName = key.Name,
AccessToken = key.AccessToken,
DateCreated = key.DateCreated,
DeviceId = string.Empty,
DeviceName = string.Empty,
AppVersion = string.Empty
}).ToListAsync().ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task DeleteApiKey(string accessToken)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await dbContext.ApiKeys
.Where(apiKey => apiKey.AccessToken == accessToken)
.ExecuteDeleteAsync()
.ConfigureAwait(false);
}
}
}
}
@@ -0,0 +1,319 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations;
using Jellyfin.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Net.Http.Headers;
namespace Jellyfin.Server.Implementations.Security
{
public class AuthorizationContext : IAuthorizationContext
{
private readonly IDbContextFactory<JellyfinDbContext> _jellyfinDbProvider;
private readonly IUserManager _userManager;
private readonly IDeviceManager _deviceManager;
private readonly IServerApplicationHost _serverApplicationHost;
private readonly IServerConfigurationManager _configurationManager;
public AuthorizationContext(
IDbContextFactory<JellyfinDbContext> jellyfinDb,
IUserManager userManager,
IDeviceManager deviceManager,
IServerApplicationHost serverApplicationHost,
IServerConfigurationManager configurationManager)
{
_jellyfinDbProvider = jellyfinDb;
_userManager = userManager;
_deviceManager = deviceManager;
_serverApplicationHost = serverApplicationHost;
_configurationManager = configurationManager;
}
public Task<AuthorizationInfo> GetAuthorizationInfo(HttpContext requestContext)
{
if (requestContext.Request.HttpContext.Items.TryGetValue("AuthorizationInfo", out var cached) && cached is not null)
{
return Task.FromResult((AuthorizationInfo)cached); // Cache should never contain null
}
return GetAuthorization(requestContext);
}
public async Task<AuthorizationInfo> GetAuthorizationInfo(HttpRequest requestContext)
{
var auth = GetAuthorizationDictionary(requestContext);
var authInfo = await GetAuthorizationInfoFromDictionary(auth, requestContext.Headers, requestContext.Query).ConfigureAwait(false);
return authInfo;
}
/// <summary>
/// Gets the authorization.
/// </summary>
/// <param name="httpContext">The HTTP context.</param>
/// <returns>Dictionary{System.StringSystem.String}.</returns>
private async Task<AuthorizationInfo> GetAuthorization(HttpContext httpContext)
{
var authInfo = await GetAuthorizationInfo(httpContext.Request).ConfigureAwait(false);
httpContext.Request.HttpContext.Items["AuthorizationInfo"] = authInfo;
return authInfo;
}
private async Task<AuthorizationInfo> GetAuthorizationInfoFromDictionary(
Dictionary<string, string>? auth,
IHeaderDictionary headers,
IQueryCollection queryString)
{
string? deviceId = null;
string? deviceName = null;
string? client = null;
string? version = null;
string? token = null;
if (auth is not null)
{
auth.TryGetValue("DeviceId", out deviceId);
auth.TryGetValue("Device", out deviceName);
auth.TryGetValue("Client", out client);
auth.TryGetValue("Version", out version);
auth.TryGetValue("Token", out token);
}
if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(token))
{
token = headers["X-Emby-Token"];
}
if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(token))
{
token = headers["X-MediaBrowser-Token"];
}
if (string.IsNullOrEmpty(token))
{
token = queryString["ApiKey"];
}
if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(token))
{
token = queryString["api_key"];
}
var authInfo = new AuthorizationInfo
{
Client = client,
Device = deviceName,
DeviceId = deviceId,
Version = version,
Token = token,
IsAuthenticated = false
};
if (!authInfo.HasToken)
{
// Request doesn't contain a token.
return authInfo;
}
var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var device = _deviceManager.GetDevices(
new DeviceQuery { AccessToken = token }).Items.FirstOrDefault();
if (device is not null)
{
authInfo.IsAuthenticated = true;
var updateToken = false;
// TODO: Remove these checks for IsNullOrWhiteSpace
if (string.IsNullOrWhiteSpace(authInfo.Client))
{
authInfo.Client = device.AppName;
}
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
{
authInfo.DeviceId = device.DeviceId;
}
// Temporary. TODO - allow clients to specify that the token has been shared with a casting device
var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(authInfo.Device))
{
authInfo.Device = device.DeviceName;
}
else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.DeviceName = authInfo.Device;
}
}
if (string.IsNullOrWhiteSpace(authInfo.Version))
{
authInfo.Version = device.AppVersion;
}
else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.AppVersion = authInfo.Version;
}
}
if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3)
{
device.DateLastActivity = DateTime.UtcNow;
updateToken = true;
}
authInfo.User = _userManager.GetUserById(device.UserId);
if (updateToken)
{
await _deviceManager.UpdateDevice(device).ConfigureAwait(false);
}
}
else
{
var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false);
if (key is not null)
{
authInfo.IsAuthenticated = true;
authInfo.Client = key.Name;
authInfo.Token = key.AccessToken;
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
{
authInfo.DeviceId = _serverApplicationHost.SystemId;
}
if (string.IsNullOrWhiteSpace(authInfo.Device))
{
authInfo.Device = _serverApplicationHost.Name;
}
if (string.IsNullOrWhiteSpace(authInfo.Version))
{
authInfo.Version = _serverApplicationHost.ApplicationVersionString;
}
authInfo.IsApiKey = true;
}
}
return authInfo;
}
}
/// <summary>
/// Gets the auth.
/// </summary>
/// <param name="httpReq">The HTTP request.</param>
/// <returns>Dictionary{System.StringSystem.String}.</returns>
private Dictionary<string, string>? GetAuthorizationDictionary(HttpRequest httpReq)
{
var auth = httpReq.Headers[HeaderNames.Authorization];
if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(auth))
{
auth = httpReq.Headers["X-Emby-Authorization"];
}
return auth.Count > 0 ? GetAuthorization(auth[0]) : null;
}
/// <summary>
/// Gets the authorization.
/// </summary>
/// <param name="authorizationHeader">The authorization header.</param>
/// <returns>Dictionary{System.StringSystem.String}.</returns>
private Dictionary<string, string>? GetAuthorization(ReadOnlySpan<char> authorizationHeader)
{
var firstSpace = authorizationHeader.IndexOf(' ');
// There should be at least two parts
if (firstSpace == -1)
{
return null;
}
var name = authorizationHeader[..firstSpace];
var validName = name.Equals("MediaBrowser", StringComparison.OrdinalIgnoreCase);
validName = validName || (_configurationManager.Configuration.EnableLegacyAuthorization && name.Equals("Emby", StringComparison.OrdinalIgnoreCase));
if (!validName)
{
return null;
}
// Remove up until the first space
authorizationHeader = authorizationHeader[(firstSpace + 1)..];
return GetParts(authorizationHeader);
}
/// <summary>
/// Get the authorization header components.
/// </summary>
/// <param name="authorizationHeader">The authorization header.</param>
/// <returns>Dictionary{System.StringSystem.String}.</returns>
public static Dictionary<string, string> GetParts(ReadOnlySpan<char> authorizationHeader)
{
var result = new Dictionary<string, string>();
var escaped = false;
int start = 0;
string key = string.Empty;
int i;
for (i = 0; i < authorizationHeader.Length; i++)
{
var token = authorizationHeader[i];
if (token == '"' || token == ',')
{
// Applying a XOR logic to evaluate whether it is opening or closing a value
escaped = (!escaped) == (token == '"');
if (token == ',' && !escaped)
{
// Meeting a comma after a closing escape char means the value is complete
if (start < i)
{
result[key] = WebUtility.UrlDecode(authorizationHeader[start..i].Trim('"').ToString());
key = string.Empty;
}
start = i + 1;
}
}
else if (!escaped && token == '=')
{
key = authorizationHeader[start.. i].Trim().ToString();
start = i + 1;
}
}
// Add last value
if (start < i)
{
result[key] = WebUtility.UrlDecode(authorizationHeader[start..i].Trim('"').ToString());
}
return result;
}
}
}
@@ -0,0 +1,108 @@
using System;
using System.Globalization;
using System.IO;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Model.System;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Implementations.StorageHelpers;
/// <summary>
/// Contains methods to help with checking for storage and returning storage data for jellyfin folders.
/// </summary>
public static class StorageHelper
{
private const long TwoGigabyte = 2_147_483_647L;
private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
/// <summary>
/// Tests the available storage capacity on the jellyfin paths with estimated minimum values.
/// </summary>
/// <param name="applicationPaths">The application paths.</param>
/// <param name="logger">Logger.</param>
public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger)
{
TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte);
}
/// <summary>
/// Gets the free space of a specific directory.
/// </summary>
/// <param name="path">Path to a folder.</param>
/// <returns>The number of bytes available space.</returns>
public static FolderStorageInfo GetFreeSpaceOf(string path)
{
try
{
var driveInfo = new DriveInfo(path);
return new FolderStorageInfo()
{
Path = path,
FreeSpace = driveInfo.AvailableFreeSpace,
UsedSpace = driveInfo.TotalSize - driveInfo.AvailableFreeSpace,
StorageType = driveInfo.DriveType.ToString(),
DeviceId = driveInfo.Name,
};
}
catch
{
return new FolderStorageInfo()
{
Path = path,
FreeSpace = -1,
UsedSpace = -1,
StorageType = null,
DeviceId = null
};
}
}
/// <summary>
/// Gets the underlying drive data from a given path and checks if the available storage capacity matches the threshold.
/// </summary>
/// <param name="path">The path to a folder to evaluate.</param>
/// <param name="logger">The logger.</param>
/// <param name="threshold">The threshold to check for or -1 to just log the data.</param>
/// <exception cref="InvalidOperationException">Thrown when the threshold is not available on the underlying storage.</exception>
private static void TestDataDirectorySize(string path, ILogger logger, long threshold = -1)
{
logger.LogDebug("Check path {TestPath} for storage capacity", path);
Directory.CreateDirectory(path);
var drive = new DriveInfo(path);
if (threshold != -1 && drive.AvailableFreeSpace < threshold)
{
throw new InvalidOperationException($"The path `{path}` has insufficient free space. Available: {HumanizeStorageSize(drive.AvailableFreeSpace)}, Required: {HumanizeStorageSize(threshold)}.");
}
logger.LogInformation(
"Storage path `{TestPath}` ({StorageType}) successfully checked with {FreeSpace} free which is over the minimum of {MinFree}.",
path,
drive.DriveType,
HumanizeStorageSize(drive.AvailableFreeSpace),
HumanizeStorageSize(threshold));
}
/// <summary>
/// Formats a size in bytes into a common human readable form.
/// </summary>
/// <remarks>
/// Taken and slightly modified from https://stackoverflow.com/a/4975942/1786007 .
/// </remarks>
/// <param name="byteCount">The size in bytes.</param>
/// <returns>A human readable approximate representation of the argument.</returns>
public static string HumanizeStorageSize(long byteCount)
{
if (byteCount == 0)
{
return $"0{_byteHumanizedSuffixes[0]}";
}
var bytes = Math.Abs(byteCount);
var place = Convert.ToInt32(Math.Floor(Math.Log(bytes, 1024)));
var num = Math.Round(bytes / Math.Pow(1024, place), 1);
return (Math.Sign(byteCount) * num).ToString(CultureInfo.InvariantCulture) + _byteHumanizedSuffixes[place];
}
}
@@ -0,0 +1,695 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using J2N.Collections.Generic.Extensions;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Implementations.Trickplay;
/// <summary>
/// ITrickplayManager implementation.
/// </summary>
public class TrickplayManager : ITrickplayManager
{
private readonly ILogger<TrickplayManager> _logger;
private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem;
private readonly EncodingHelper _encodingHelper;
private readonly IServerConfigurationManager _config;
private readonly IImageEncoder _imageEncoder;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IApplicationPaths _appPaths;
private readonly IPathManager _pathManager;
private static readonly AsyncNonKeyedLocker _resourcePool = new(1);
private static readonly string[] _trickplayImgExtensions = [".jpg"];
/// <summary>
/// Initializes a new instance of the <see cref="TrickplayManager"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="mediaEncoder">The media encoder.</param>
/// <param name="fileSystem">The file system.</param>
/// <param name="encodingHelper">The encoding helper.</param>
/// <param name="config">The server configuration manager.</param>
/// <param name="imageEncoder">The image encoder.</param>
/// <param name="dbProvider">The database provider.</param>
/// <param name="appPaths">The application paths.</param>
/// <param name="pathManager">The path manager.</param>
public TrickplayManager(
ILogger<TrickplayManager> logger,
IMediaEncoder mediaEncoder,
IFileSystem fileSystem,
EncodingHelper encodingHelper,
IServerConfigurationManager config,
IImageEncoder imageEncoder,
IDbContextFactory<JellyfinDbContext> dbProvider,
IApplicationPaths appPaths,
IPathManager pathManager)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_fileSystem = fileSystem;
_encodingHelper = encodingHelper;
_config = config;
_imageEncoder = imageEncoder;
_dbProvider = dbProvider;
_appPaths = appPaths;
_pathManager = pathManager;
}
/// <inheritdoc />
public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{
var options = _config.Configuration.TrickplayOptions;
if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction || !CanGenerateTrickplay(video, options.Interval))
{
return;
}
var existingTrickplayResolutions = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
foreach (var resolution in existingTrickplayResolutions)
{
cancellationToken.ThrowIfCancellationRequested();
var existingResolution = resolution.Key;
var tileWidth = resolution.Value.TileWidth;
var tileHeight = resolution.Value.TileHeight;
var shouldBeSavedWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia;
var localOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false));
var mediaOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true));
if (shouldBeSavedWithMedia && localOutputDir.Exists)
{
var localDirFiles = localOutputDir.EnumerateFiles();
var mediaDirExists = mediaOutputDir.Exists;
if (localDirFiles.Any() && ((mediaDirExists && mediaOutputDir.EnumerateFiles().Any()) || !mediaDirExists))
{
// Move images from local dir to media dir
MoveContent(localOutputDir.FullName, mediaOutputDir.FullName);
_logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutputDir);
}
}
else if (!shouldBeSavedWithMedia && mediaOutputDir.Exists)
{
var mediaDirFiles = mediaOutputDir.EnumerateFiles();
var localDirExists = localOutputDir.Exists;
if (mediaDirFiles.Any() && ((localDirExists && localOutputDir.EnumerateFiles().Any()) || !localDirExists))
{
// Move images from media dir to local dir
MoveContent(mediaOutputDir.FullName, localOutputDir.FullName);
_logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutputDir);
}
}
}
}
private void MoveContent(string sourceFolder, string destinationFolder)
{
_fileSystem.MoveDirectory(sourceFolder, destinationFolder);
var parent = Directory.GetParent(sourceFolder);
if (parent is not null)
{
var parentContent = parent.EnumerateDirectories();
if (!parentContent.Any())
{
parent.Delete();
}
}
}
/// <inheritdoc />
public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{
var options = _config.Configuration.TrickplayOptions;
if (!CanGenerateTrickplay(video, options.Interval) || libraryOptions is null)
{
return;
}
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
if (!libraryOptions.EnableTrickplayImageExtraction || replace)
{
// Prune existing data
if (Directory.Exists(trickplayDirectory))
{
try
{
Directory.Delete(trickplayDirectory, true);
}
catch (Exception ex)
{
_logger.LogWarning("Unable to clear trickplay directory: {Directory}: {Exception}", trickplayDirectory, ex);
}
}
await dbContext.TrickplayInfos
.Where(i => i.ItemId.Equals(video.Id))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
if (!replace)
{
return;
}
}
_logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
if (options.Interval < 1000)
{
_logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value of 1000", options.Interval);
options.Interval = 1000;
}
foreach (var width in options.WidthResolutions)
{
cancellationToken.ThrowIfCancellationRequested();
await RefreshTrickplayDataInternal(
video,
replace,
width,
options,
saveWithMedia,
cancellationToken).ConfigureAwait(false);
}
// Cleanup old trickplay files
if (Directory.Exists(trickplayDirectory))
{
var existingFolders = Directory.GetDirectories(trickplayDirectory).ToList();
var trickplayInfos = await dbContext.TrickplayInfos
.AsNoTracking()
.Where(i => i.ItemId.Equals(video.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)).ToList();
var foldersToRemove = existingFolders.Except(expectedFolders);
foreach (var folder in foldersToRemove)
{
try
{
_logger.LogWarning("Pruning trickplay files for {Item}", video.Path);
Directory.Delete(folder, true);
}
catch (Exception ex)
{
_logger.LogWarning("Unable to remove trickplay directory: {Directory}: {Exception}", folder, ex);
}
}
}
}
}
private async Task RefreshTrickplayDataInternal(
Video video,
bool replace,
int width,
TrickplayOptions options,
bool saveWithMedia,
CancellationToken cancellationToken)
{
var imgTempDir = string.Empty;
using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
{
try
{
// Extract images
// Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay.
var mediaSource = video.GetMediaSources(false).FirstOrDefault(source => Guid.Parse(source.Id).Equals(video.Id));
if (mediaSource is null)
{
_logger.LogDebug("Found no matching media source for item {ItemId}", video.Id);
return;
}
var mediaPath = mediaSource.Path;
if (!File.Exists(mediaPath))
{
_logger.LogWarning("Media not found at {Path} for item {ItemID}", mediaPath, video.Id);
return;
}
// We support video backdrops, but we should not generate trickplay images for them
var parentDirectory = Directory.GetParent(video.Path);
if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
return;
}
// The width has to be even, otherwise a lot of filters will not be able to sample it
var actualWidth = 2 * (width / 2);
// Force using the video width when the trickplay setting has a too large width
if (mediaSource.VideoStream.Width is not null && mediaSource.VideoStream.Width < width)
{
_logger.LogWarning("Video width {VideoWidth} is smaller than trickplay setting {TrickPlayWidth}, using video width for thumbnails", mediaSource.VideoStream.Width, width);
actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
}
var tileWidth = options.TileWidth;
var tileHeight = options.TileHeight;
var outputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia));
// Import existing trickplay tiles
if (!replace && outputDir.Exists)
{
var existingFiles = outputDir.GetFiles();
if (existingFiles.Length > 0)
{
var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureAwait(false);
if (hasTrickplayResolution)
{
_logger.LogDebug("Found existing trickplay files for {ItemId}.", video.Id);
return;
}
// Import tiles
var localTrickplayInfo = new TrickplayInfo
{
ItemId = video.Id,
Width = width,
Interval = options.Interval,
TileWidth = options.TileWidth,
TileHeight = options.TileHeight,
ThumbnailCount = existingFiles.Length,
Height = 0,
Bandwidth = 0
};
foreach (var tile in existingFiles)
{
var image = _imageEncoder.GetImageSize(tile.FullName);
localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)image.Height / localTrickplayInfo.TileHeight));
var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000));
localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
}
await SaveTrickplayInfo(localTrickplayInfo).ConfigureAwait(false);
_logger.LogDebug("Imported existing trickplay files for {ItemId}.", video.Id);
return;
}
}
// Generate trickplay tiles
var mediaStream = mediaSource.VideoStream;
var container = mediaSource.Container;
_logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWidth, mediaPath, video.Id);
imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
mediaPath,
container,
mediaSource,
mediaStream,
actualWidth,
TimeSpan.FromMilliseconds(options.Interval),
options.EnableHwAcceleration,
options.EnableHwEncoding,
options.ProcessThreads,
options.Qscale,
options.ProcessPriority,
options.EnableKeyFrameOnlyExtraction,
_encodingHelper,
cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(imgTempDir) || !Directory.Exists(imgTempDir))
{
throw new InvalidOperationException("Null or invalid directory from media encoder.");
}
var images = _fileSystem.GetFiles(imgTempDir, _trickplayImgExtensions, false, false)
.Select(i => i.FullName)
.OrderBy(i => i)
.ToList();
// Create tiles
var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir.FullName);
// Save tiles info
try
{
if (trickplayInfo is not null)
{
trickplayInfo.ItemId = video.Id;
await SaveTrickplayInfo(trickplayInfo).ConfigureAwait(false);
_logger.LogInformation("Finished creation of trickplay files for {0}", mediaPath);
}
else
{
throw new InvalidOperationException("Null trickplay tiles info from CreateTiles.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while saving trickplay tiles info.");
// Make sure no files stay in metadata folders on failure
// if tiles info wasn't saved.
outputDir.Delete(true);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating trickplay images.");
}
finally
{
if (!string.IsNullOrEmpty(imgTempDir))
{
Directory.Delete(imgTempDir, true);
}
}
}
}
/// <inheritdoc />
public TrickplayInfo CreateTiles(IReadOnlyList<string> images, int width, TrickplayOptions options, string outputDir)
{
if (images.Count == 0)
{
throw new ArgumentException("Can't create trickplay from 0 images.");
}
var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
var trickplayInfo = new TrickplayInfo
{
Width = width,
Interval = options.Interval,
TileWidth = options.TileWidth,
TileHeight = options.TileHeight,
ThumbnailCount = images.Count,
// Set during image generation
Height = 0,
Bandwidth = 0
};
/*
* Generate trickplay tiles from sets of thumbnails
*/
var imageOptions = new ImageCollageOptions
{
Width = trickplayInfo.TileWidth,
Height = trickplayInfo.TileHeight
};
var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
var requiredTiles = (int)Math.Ceiling((double)images.Count / thumbnailsPerTile);
for (int i = 0; i < requiredTiles; i++)
{
// Set output/input paths
var tilePath = Path.Combine(workDir, $"{i}.jpg");
imageOptions.OutputPath = tilePath;
imageOptions.InputPaths = images.Skip(i * thumbnailsPerTile).Take(Math.Min(thumbnailsPerTile, images.Count - (i * thumbnailsPerTile))).ToList();
// Generate image and use returned height for tiles info
var height = _imageEncoder.CreateTrickplayTile(imageOptions, options.JpegQuality, trickplayInfo.Width, trickplayInfo.Height != 0 ? trickplayInfo.Height : null);
if (trickplayInfo.Height == 0)
{
trickplayInfo.Height = height;
}
// Update bitrate
var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000m));
trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
}
/*
* Move trickplay tiles to output directory
*/
Directory.CreateDirectory(Directory.GetParent(outputDir)!.FullName);
// Replace existing tiles if they already exist
if (Directory.Exists(outputDir))
{
Directory.Delete(outputDir, true);
}
_fileSystem.MoveDirectory(workDir, outputDir);
return trickplayInfo;
}
private bool CanGenerateTrickplay(Video video, int interval)
{
var videoType = video.VideoType;
if (videoType == VideoType.Iso || videoType == VideoType.Dvd || videoType == VideoType.BluRay)
{
return false;
}
if (video.IsPlaceHolder)
{
return false;
}
if (video.IsShortcut)
{
return false;
}
if (!video.IsCompleteMedia)
{
return false;
}
if (!video.RunTimeTicks.HasValue || video.RunTimeTicks.Value < TimeSpan.FromMilliseconds(interval).Ticks)
{
return false;
}
// Can't extract images if there are no video streams
return video.GetMediaStreams().Count > 0;
}
/// <inheritdoc />
public async Task<Dictionary<int, TrickplayInfo>> GetTrickplayResolutions(Guid itemId)
{
var trickplayResolutions = new Dictionary<int, TrickplayInfo>();
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var trickplayInfos = await dbContext.TrickplayInfos
.AsNoTracking()
.Where(i => i.ItemId.Equals(itemId))
.ToListAsync()
.ConfigureAwait(false);
foreach (var info in trickplayInfos)
{
trickplayResolutions[info.Width] = info;
}
}
return trickplayResolutions;
}
/// <inheritdoc />
public async Task<IReadOnlyList<TrickplayInfo>> GetTrickplayItemsAsync(int limit, int offset)
{
IReadOnlyList<TrickplayInfo> trickplayItems;
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
trickplayItems = await dbContext.TrickplayInfos
.AsNoTracking()
.OrderBy(i => i.ItemId)
.Skip(offset)
.Take(limit)
.ToListAsync()
.ConfigureAwait(false);
}
return trickplayItems;
}
/// <inheritdoc />
public async Task SaveTrickplayInfo(TrickplayInfo info)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var oldInfo = await dbContext.TrickplayInfos.FindAsync(info.ItemId, info.Width).ConfigureAwait(false);
if (oldInfo is not null)
{
dbContext.TrickplayInfos.Remove(oldInfo);
}
dbContext.Add(info);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken)
{
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
{
var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
foreach (var mediaSource in item.GetMediaSources(false))
{
if (mediaSource.IsRemote || !Guid.TryParse(mediaSource.Id, out var mediaSourceId))
{
continue;
}
var trickplayResolutions = await GetTrickplayResolutions(mediaSourceId).ConfigureAwait(false);
if (trickplayResolutions.Count > 0)
{
trickplayManifest[mediaSource.Id] = trickplayResolutions;
}
}
return trickplayManifest;
}
/// <inheritdoc />
public async Task<string> GetTrickplayTilePathAsync(BaseItem item, int width, int index, bool saveWithMedia)
{
var trickplayResolutions = await GetTrickplayResolutions(item.Id).ConfigureAwait(false);
if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
{
return Path.Combine(GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, width, saveWithMedia), index + ".jpg");
}
return string.Empty;
}
/// <inheritdoc />
public async Task<string?> GetHlsPlaylist(Guid itemId, int width, string? apiKey)
{
var trickplayResolutions = await GetTrickplayResolutions(itemId).ConfigureAwait(false);
if (trickplayResolutions is not null && trickplayResolutions.TryGetValue(width, out var trickplayInfo))
{
var builder = new StringBuilder(128);
if (trickplayInfo.ThumbnailCount > 0)
{
const string urlFormat = "{0}.jpg?MediaSourceId={1}&ApiKey={2}";
const string decimalFormat = "{0:0.###}";
var resolution = $"{trickplayInfo.Width}x{trickplayInfo.Height}";
var layout = $"{trickplayInfo.TileWidth}x{trickplayInfo.TileHeight}";
var thumbnailsPerTile = trickplayInfo.TileWidth * trickplayInfo.TileHeight;
var thumbnailDuration = trickplayInfo.Interval / 1000d;
var infDuration = thumbnailDuration * thumbnailsPerTile;
var tileCount = (int)Math.Ceiling((decimal)trickplayInfo.ThumbnailCount / thumbnailsPerTile);
builder
.AppendLine("#EXTM3U")
.Append("#EXT-X-TARGETDURATION:")
.AppendLine(tileCount.ToString(CultureInfo.InvariantCulture))
.AppendLine("#EXT-X-VERSION:7")
.AppendLine("#EXT-X-MEDIA-SEQUENCE:1")
.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
.AppendLine("#EXT-X-IMAGES-ONLY");
for (int i = 0; i < tileCount; i++)
{
// All tiles prior to the last must contain full amount of thumbnails (no black).
if (i == tileCount - 1)
{
thumbnailsPerTile = trickplayInfo.ThumbnailCount - (i * thumbnailsPerTile);
infDuration = thumbnailDuration * thumbnailsPerTile;
}
// EXTINF
builder
.Append("#EXTINF:")
.AppendFormat(CultureInfo.InvariantCulture, decimalFormat, infDuration)
.AppendLine(",");
// EXT-X-TILES
builder
.Append("#EXT-X-TILES:RESOLUTION=")
.Append(resolution)
.Append(",LAYOUT=")
.Append(layout)
.Append(",DURATION=")
.AppendFormat(CultureInfo.InvariantCulture, decimalFormat, thumbnailDuration)
.AppendLine();
// URL
builder
.AppendFormat(
CultureInfo.InvariantCulture,
urlFormat,
i.ToString(CultureInfo.InvariantCulture),
itemId.ToString("N"),
apiKey)
.AppendLine();
}
builder.AppendLine("#EXT-X-ENDLIST");
return builder.ToString();
}
}
return null;
}
/// <inheritdoc />
public string GetTrickplayDirectory(BaseItem item, int tileWidth, int tileHeight, int width, bool saveWithMedia = false)
{
var path = _pathManager.GetTrickplayDirectory(item, saveWithMedia);
var subdirectory = string.Format(
CultureInfo.InvariantCulture,
"{0} - {1}x{2}",
width.ToString(CultureInfo.InvariantCulture),
tileWidth.ToString(CultureInfo.InvariantCulture),
tileHeight.ToString(CultureInfo.InvariantCulture));
return Path.Combine(path, subdirectory);
}
private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
return await dbContext.TrickplayInfos
.AsNoTracking()
.Where(i => i.ItemId.Equals(itemId))
.AnyAsync(i => i.Width == width)
.ConfigureAwait(false);
}
}
}
@@ -0,0 +1,111 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Model.Cryptography;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Implementations.Users
{
/// <summary>
/// The default authentication provider.
/// </summary>
public class DefaultAuthenticationProvider : IAuthenticationProvider, IRequiresResolvedUser
{
private readonly ILogger<DefaultAuthenticationProvider> _logger;
private readonly ICryptoProvider _cryptographyProvider;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultAuthenticationProvider"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="cryptographyProvider">The cryptography provider.</param>
public DefaultAuthenticationProvider(ILogger<DefaultAuthenticationProvider> logger, ICryptoProvider cryptographyProvider)
{
_logger = logger;
_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)
{
[DoesNotReturn]
static void ThrowAuthenticationException()
{
throw new AuthenticationException("Invalid username or password");
}
if (resolvedUser is null)
{
ThrowAuthenticationException();
}
// As long as jellyfin supports password-less users, we need this little block here to accommodate
if (string.IsNullOrEmpty(resolvedUser.Password) && 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 is null)
{
ThrowAuthenticationException();
}
PasswordHash readyHash = PasswordHash.Parse(resolvedUser.Password);
if (!_cryptographyProvider.Verify(readyHash, password))
{
ThrowAuthenticationException();
}
// Migrate old hashes to the new default
if (!string.Equals(readyHash.Id, _cryptographyProvider.DefaultHashMethod, StringComparison.Ordinal)
|| int.Parse(readyHash.Parameters["iterations"], CultureInfo.InvariantCulture) != Constants.DefaultIterations)
{
_logger.LogInformation("Migrating password hash of {User} to the latest default", username);
ChangePassword(resolvedUser, password);
}
return Task.FromResult(new ProviderAuthenticationResult
{
Username = username
});
}
/// <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;
}
}
}
@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Common;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.IO;
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;
var str = AsyncFile.OpenRead(resetFile);
await using (str.ConfigureAwait(false))
{
spr = await JsonSerializer.DeserializeAsync<SerializablePasswordReset>(str).ConfigureAwait(false)
?? throw new ResourceNotFoundException($"Provided path ({resetFile}) is not valid.");
}
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.Ordinal))
{
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, string enteredUsername, bool isInNetwork)
{
DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
var usernameHash = enteredUsername.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
var pinFile = _passwordResetFileBase + usernameHash + ".json";
if (user is not null && isInNetwork)
{
byte[] bytes = new byte[4];
RandomNumberGenerator.Fill(bytes);
string pin = BitConverter.ToString(bytes);
SerializablePasswordReset spr = new SerializablePasswordReset
{
ExpirationDate = expireTime,
Pin = pin,
PinFile = pinFile,
UserName = user.Username
};
FileStream fileStream = AsyncFile.Create(pinFile);
await using (fileStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
}
}
return new ForgotPasswordResult
{
Action = ForgotPasswordAction.PinCode,
PinExpirationDate = expireTime,
PinFile = pinFile
};
}
#nullable disable
private class SerializablePasswordReset : PasswordPinCreationResult
{
public string Pin { get; set; }
public string UserName { get; set; }
}
}
}
@@ -0,0 +1,77 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Data.Events;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Hosting;
namespace Jellyfin.Server.Implementations.Users;
/// <summary>
/// <see cref="IHostedService"/> responsible for managing user device permissions.
/// </summary>
public sealed class DeviceAccessHost : IHostedService
{
private readonly IUserManager _userManager;
private readonly IDeviceManager _deviceManager;
private readonly ISessionManager _sessionManager;
/// <summary>
/// Initializes a new instance of the <see cref="DeviceAccessHost"/> class.
/// </summary>
/// <param name="userManager">The <see cref="IUserManager"/>.</param>
/// <param name="deviceManager">The <see cref="IDeviceManager"/>.</param>
/// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
public DeviceAccessHost(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager)
{
_userManager = userManager;
_deviceManager = deviceManager;
_sessionManager = sessionManager;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_userManager.OnUserUpdated += OnUserUpdated;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userManager.OnUserUpdated -= OnUserUpdated;
return Task.CompletedTask;
}
private async void OnUserUpdated(object? sender, GenericEventArgs<User> e)
{
var user = e.Argument;
if (!user.HasPermission(PermissionKind.EnableAllDevices))
{
await UpdateDeviceAccess(user).ConfigureAwait(false);
}
}
private async Task UpdateDeviceAccess(User user)
{
var existing = _deviceManager.GetDevices(new DeviceQuery
{
UserId = user.Id
}).Items;
foreach (var device in existing)
{
if (!string.IsNullOrEmpty(device.DeviceId) && !_deviceManager.CanAccessDevice(user, device.DeviceId))
{
await _sessionManager.Logout(device).ConfigureAwait(false);
}
}
}
}
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.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 sealed class DisplayPreferencesManager : IDisplayPreferencesManager
{
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class.
/// </summary>
/// <param name="dbContextFactory">The database context factory.</param>
public DisplayPreferencesManager(IDbContextFactory<JellyfinDbContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc />
public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client)
{
using var dbContext = _dbContextFactory.CreateDbContext();
var prefs = dbContext.DisplayPreferences
.Include(pref => pref.HomeSections)
.FirstOrDefault(pref =>
pref.UserId.Equals(userId) && pref.Client == client && pref.ItemId.Equals(itemId));
if (prefs is null)
{
prefs = new DisplayPreferences(userId, itemId, client);
dbContext.DisplayPreferences.Add(prefs);
dbContext.SaveChanges();
}
return prefs;
}
/// <inheritdoc />
public ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client)
{
using var dbContext = _dbContextFactory.CreateDbContext();
var prefs = dbContext.ItemDisplayPreferences
.FirstOrDefault(pref => pref.UserId.Equals(userId) && pref.ItemId.Equals(itemId) && pref.Client == client);
if (prefs is null)
{
prefs = new ItemDisplayPreferences(userId, Guid.Empty, client);
dbContext.ItemDisplayPreferences.Add(prefs);
dbContext.SaveChanges();
}
return prefs;
}
/// <inheritdoc />
public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client)
{
using var dbContext = _dbContextFactory.CreateDbContext();
return dbContext.ItemDisplayPreferences
.Where(prefs => prefs.UserId.Equals(userId) && !prefs.ItemId.Equals(default) && prefs.Client == client)
.ToList();
}
/// <inheritdoc />
public Dictionary<string, string?> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client)
{
using var dbContext = _dbContextFactory.CreateDbContext();
return dbContext.CustomItemDisplayPreferences
.Where(prefs => prefs.UserId.Equals(userId)
&& prefs.ItemId.Equals(itemId)
&& prefs.Client == client)
.ToDictionary(prefs => prefs.Key, prefs => prefs.Value);
}
/// <inheritdoc />
public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences)
{
using var dbContext = _dbContextFactory.CreateDbContext();
dbContext.CustomItemDisplayPreferences.Where(prefs => prefs.UserId.Equals(userId)
&& prefs.ItemId.Equals(itemId)
&& prefs.Client == client)
.ExecuteDelete();
foreach (var (key, value) in customPreferences)
{
dbContext.CustomItemDisplayPreferences
.Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value));
}
dbContext.SaveChanges();
}
/// <inheritdoc/>
public void UpdateDisplayPreferences(DisplayPreferences displayPreferences)
{
using var dbContext = _dbContextFactory.CreateDbContext();
dbContext.DisplayPreferences.Attach(displayPreferences).State = EntityState.Modified;
dbContext.SaveChanges();
}
/// <inheritdoc/>
public void UpdateItemDisplayPreferences(ItemDisplayPreferences itemDisplayPreferences)
{
using var dbContext = _dbContextFactory.CreateDbContext();
dbContext.ItemDisplayPreferences.Attach(itemDisplayPreferences).State = EntityState.Modified;
dbContext.SaveChanges();
}
}
@@ -0,0 +1,30 @@
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.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 => false;
/// <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 Task ChangePassword(User user, string newPassword)
{
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,894 @@
#pragma warning disable CA1307
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
using Jellyfin.Data.Events.Users;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.Configuration;
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 partial class UserManager : IUserManager
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IEventManager _eventManager;
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;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IDictionary<Guid, User> _users;
/// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class.
/// </summary>
/// <param name="dbProvider">The database provider.</param>
/// <param name="eventManager">The event manager.</param>
/// <param name="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>
/// <param name="serverConfigurationManager">The system config manager.</param>
/// <param name="passwordResetProviders">The password reset providers.</param>
/// <param name="authenticationProviders">The authentication providers.</param>
public UserManager(
IDbContextFactory<JellyfinDbContext> dbProvider,
IEventManager eventManager,
INetworkManager networkManager,
IApplicationHost appHost,
IImageProcessor imageProcessor,
ILogger<UserManager> logger,
IServerConfigurationManager serverConfigurationManager,
IEnumerable<IPasswordResetProvider> passwordResetProviders,
IEnumerable<IAuthenticationProvider> authenticationProviders)
{
_dbProvider = dbProvider;
_eventManager = eventManager;
_networkManager = networkManager;
_appHost = appHost;
_imageProcessor = imageProcessor;
_logger = logger;
_serverConfigurationManager = serverConfigurationManager;
_passwordResetProviders = passwordResetProviders.ToList();
_authenticationProviders = authenticationProviders.ToList();
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
_users = new ConcurrentDictionary<Guid, User>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var user in dbContext.Users
.AsSplitQuery()
.Include(user => user.Permissions)
.Include(user => user.Preferences)
.Include(user => user.AccessSchedules)
.Include(user => user.ProfileImage)
.AsEnumerable())
{
_users.Add(user.Id, user);
}
}
/// <inheritdoc/>
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
/// <inheritdoc/>
public IEnumerable<User> Users => _users.Values;
/// <inheritdoc/>
public IEnumerable<Guid> UsersIds => _users.Keys;
// 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 ( )
[GeneratedRegex(@"^(?!\s)[\w\ \-'._@+]+(?<!\s)$")]
private static partial Regex ValidUsernameRegex();
/// <inheritdoc/>
public User? GetUserById(Guid id)
{
if (id.IsEmpty())
{
throw new ArgumentException("Guid can't be empty", nameof(id));
}
_users.TryGetValue(id, out var user);
return user;
}
/// <inheritdoc/>
public User? GetUserByName(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Invalid username", nameof(name));
}
return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
}
/// <inheritdoc/>
public async Task RenameUser(User user, string newName)
{
ArgumentNullException.ThrowIfNull(user);
ThrowIfInvalidUsername(newName);
if (user.Username.Equals(newName, StringComparison.Ordinal))
{
throw new ArgumentException("The new and old names must be different.");
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id))
.ConfigureAwait(false))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
newName));
}
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
user.Username = newName;
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
var eventArgs = new UserUpdatedEventArgs(user);
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
OnUserUpdated?.Invoke(this, eventArgs);
}
/// <inheritdoc/>
public async Task UpdateUserAsync(User user)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
}
internal async Task<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext)
{
// TODO: Remove after user item data is migrated.
var max = await dbContext.Users.AsQueryable().AnyAsync().ConfigureAwait(false)
? await dbContext.Users.AsQueryable().Select(u => u.InternalId).MaxAsync().ConfigureAwait(false)
: 0;
var user = new User(
name,
_defaultAuthenticationProvider.GetType().FullName!,
_defaultPasswordResetProvider.GetType().FullName!)
{
InternalId = max + 1
};
user.AddDefaultPermissions();
user.AddDefaultPreferences();
return user;
}
/// <inheritdoc/>
public async Task<User> CreateUserAsync(string name)
{
ThrowIfInvalidUsername(name);
if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
name));
}
User newUser;
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_users.Add(newUser.Id, newUser);
}
await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
return newUser;
}
/// <inheritdoc/>
public async Task DeleteUserAsync(Guid userId)
{
if (!_users.TryGetValue(userId, out var user))
{
throw new ResourceNotFoundException(nameof(userId));
}
if (_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));
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Users.Attach(user);
dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
_users.Remove(userId);
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task ResetPassword(User user)
{
return ChangePassword(user, string.Empty);
}
/// <inheritdoc/>
public async Task ChangePassword(User user, string newPassword)
{
ArgumentNullException.ThrowIfNull(user);
if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
{
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
}
await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
await UpdateUserAsync(user).ConfigureAwait(false);
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false);
}
/// <inheritdoc/>
public UserDto GetUserDto(User user, string? remoteEndPoint = null)
{
var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications;
return new UserDto
{
Name = user.Username,
Id = user.Id,
ServerId = _appHost.SystemId,
EnableAutoLogin = user.EnableAutoLogin,
LastLoginDate = user.LastLoginDate,
LastActivityDate = user.LastActivityDate,
PrimaryImageTag = user.ProfileImage is not 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.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews),
GroupedFolders = user.GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders),
MyMediaExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes),
LatestItemsExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes),
CastReceiverId = string.IsNullOrEmpty(user.CastReceiverId)
? castReceiverApplications.FirstOrDefault()?.Id
: castReceiverApplications.FirstOrDefault(c => string.Equals(c.Id, user.CastReceiverId, StringComparison.Ordinal))?.Id
?? castReceiverApplications.FirstOrDefault()?.Id
},
Policy = new UserPolicy
{
MaxParentalRating = user.MaxParentalRatingScore,
MaxParentalSubRating = user.MaxParentalRatingSubScore,
EnableUserPreferenceAccess = user.EnableUserPreferenceAccess,
RemoteClientBitrateLimit = user.RemoteClientBitrateLimit ?? 0,
AuthenticationProviderId = user.AuthenticationProviderId,
PasswordResetProviderId = user.PasswordResetProviderId,
InvalidLoginAttemptCount = user.InvalidLoginAttemptCount,
LoginAttemptsBeforeLockout = user.LoginAttemptsBeforeLockout ?? -1,
MaxActiveSessions = user.MaxActiveSessions,
IsAdministrator = user.HasPermission(PermissionKind.IsAdministrator),
IsHidden = user.HasPermission(PermissionKind.IsHidden),
IsDisabled = user.HasPermission(PermissionKind.IsDisabled),
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),
EnableCollectionManagement = user.HasPermission(PermissionKind.EnableCollectionManagement),
EnableSubtitleManagement = user.HasPermission(PermissionKind.EnableSubtitleManagement),
AccessSchedules = user.AccessSchedules.ToArray(),
BlockedTags = user.GetPreference(PreferenceKind.BlockedTags),
AllowedTags = user.GetPreference(PreferenceKind.AllowedTags),
EnabledChannels = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels),
EnabledDevices = user.GetPreference(PreferenceKind.EnabledDevices),
EnabledFolders = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders),
EnableContentDeletionFromFolders = user.GetPreference(PreferenceKind.EnableContentDeletionFromFolders),
SyncPlayAccess = user.SyncPlayAccess,
BlockedChannels = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedChannels),
BlockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders),
BlockUnratedItems = user.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems)
}
};
}
/// <inheritdoc/>
public async Task<User?> AuthenticateUser(
string username,
string password,
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));
var authResult = await AuthenticateLocalUser(username, password, user)
.ConfigureAwait(false);
var authenticationProvider = authResult.AuthenticationProvider;
var success = authResult.Success;
if (user is null)
{
string updatedUsername = authResult.Username;
if (success
&& authenticationProvider is not null
&& authenticationProvider is not 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 && user is not null)
{
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
}
}
}
if (success && user is not null && authenticationProvider is not null)
{
var providerId = authenticationProvider.GetType().FullName;
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
{
user.AuthenticationProviderId = providerId;
await UpdateUserAsync(user).ConfigureAwait(false);
}
}
if (user is 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);
var passwordResetProvider = GetPasswordResetProvider(user);
var result = await passwordResetProvider
.StartForgotPasswordProcess(user, enteredUsername, isInNetwork)
.ConfigureAwait(false);
if (user is not null && isInNetwork)
{
await UpdateUserAsync(user).ConfigureAwait(false);
}
return result;
}
/// <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();
}
/// <inheritdoc />
public async Task InitializeAsync()
{
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
if (_users.Any())
{
return;
}
var defaultName = Environment.UserName;
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
{
defaultName = "MyJellyfinUser";
}
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
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);
_users.Add(newUser.Id, newUser);
}
}
/// <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 async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.FirstOrDefault(u => u.Id.Equals(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;
// Only set cast receiver id if it is passed in and it exists in the server config.
if (!string.IsNullOrEmpty(config.CastReceiverId)
&& _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
{
user.CastReceiverId = config.CastReceiverId;
}
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);
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
/// <inheritdoc/>
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.FirstOrDefault(u => u.Id.Equals(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.MaxParentalRatingScore = policy.MaxParentalRating;
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
user.AuthenticationProviderId = policy.AuthenticationProviderId;
user.PasswordResetProviderId = policy.PasswordResetProviderId;
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
user.MaxActiveSessions = policy.MaxActiveSessions;
user.SyncPlayAccess = policy.SyncPlayAccess;
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
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.EnableCollectionManagement, policy.EnableCollectionManagement);
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
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 ?? Array.Empty<UnratedItem>());
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags);
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
dbContext.Update(user);
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
/// <inheritdoc/>
public async Task ClearProfileImageAsync(User user)
{
if (user.ProfileImage is null)
{
return;
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Remove(user.ProfileImage);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
user.ProfileImage = null;
_users[user.Id] = user;
}
internal static void ThrowIfInvalidUsername(string name)
{
if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name))
{
return;
}
throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)", nameof(name));
}
private IAuthenticationProvider GetAuthenticationProvider(User user)
{
return GetAuthenticationProviders(user)[0];
}
private IPasswordResetProvider GetPasswordResetProvider(User? user)
{
if (user is null)
{
return _defaultPasswordResetProvider;
}
return GetPasswordResetProviders(user)[0];
}
private List<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 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)
{
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;
}
}
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.LogDebug(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);
}
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{
dbContext.Users.Attach(user);
dbContext.Entry(user).State = EntityState.Modified;
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
}
@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v10.0", FrameworkDisplayName = ".NET 10.0")]
@@ -0,0 +1,27 @@
is_global = true
build_property.TargetFramework = net10.0
build_property.TargetFramework = net10.0
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkVersion = v10.0
build_property.TargetFrameworkVersion = v10.0
build_property.TargetPlatformMinVersion =
build_property.TargetPlatformMinVersion =
build_property.UsingMicrosoftNETSdkWeb =
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = Jellyfin.Server.Implementations
build_property.ProjectDir = /srv/common_drive/Projects/pgsql-jellyfin/Jellyfin.Server.Implementations/
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.EffectiveAnalysisLevelStyle = 10.0
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">/home/wjones/.nuget/packages/</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">/home/wjones/.nuget/packages/</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="/home/wjones/.nuget/packages/" />
</ItemGroup>
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Import Project="$(NuGetPackageRoot)microsoft.entityframeworkcore/10.0.3/buildTransitive/net10.0/Microsoft.EntityFrameworkCore.props" Condition="Exists('$(NuGetPackageRoot)microsoft.entityframeworkcore/10.0.3/buildTransitive/net10.0/Microsoft.EntityFrameworkCore.props')" />
<Import Project="$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.props" Condition="Exists('$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.props')" />
</ImportGroup>
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<PkgStyleCop_Analyzers_Unstable Condition=" '$(PkgStyleCop_Analyzers_Unstable)' == '' ">/home/wjones/.nuget/packages/stylecop.analyzers.unstable/1.2.0.556</PkgStyleCop_Analyzers_Unstable>
<PkgSmartAnalyzers_MultithreadingAnalyzer Condition=" '$(PkgSmartAnalyzers_MultithreadingAnalyzer)' == '' ">/home/wjones/.nuget/packages/smartanalyzers.multithreadinganalyzer/1.1.31</PkgSmartAnalyzers_MultithreadingAnalyzer>
<PkgSerilogAnalyzer Condition=" '$(PkgSerilogAnalyzer)' == '' ">/home/wjones/.nuget/packages/seriloganalyzer/0.15.0</PkgSerilogAnalyzer>
<PkgMicrosoft_CodeAnalysis_BannedApiAnalyzers Condition=" '$(PkgMicrosoft_CodeAnalysis_BannedApiAnalyzers)' == '' ">/home/wjones/.nuget/packages/microsoft.codeanalysis.bannedapianalyzers/4.14.0</PkgMicrosoft_CodeAnalysis_BannedApiAnalyzers>
<PkgIDisposableAnalyzers Condition=" '$(PkgIDisposableAnalyzers)' == '' ">/home/wjones/.nuget/packages/idisposableanalyzers/4.0.8</PkgIDisposableAnalyzers>
</PropertyGroup>
</Project>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Import Project="$(NuGetPackageRoot)sqlitepclraw.lib.e_sqlite3/2.1.11/buildTransitive/net9.0/SQLitePCLRaw.lib.e_sqlite3.targets" Condition="Exists('$(NuGetPackageRoot)sqlitepclraw.lib.e_sqlite3/2.1.11/buildTransitive/net9.0/SQLitePCLRaw.lib.e_sqlite3.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.extensions.options/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Options.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.options/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Options.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Logging.Abstractions.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/10.0.3/buildTransitive/net8.0/Microsoft.Extensions.Logging.Abstractions.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.extensions.configuration.binder/10.0.3/buildTransitive/netstandard2.0/Microsoft.Extensions.Configuration.Binder.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.configuration.binder/10.0.3/buildTransitive/netstandard2.0/Microsoft.Extensions.Configuration.Binder.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.codeanalysis.bannedapianalyzers/4.14.0/buildTransitive/Microsoft.CodeAnalysis.BannedApiAnalyzers.targets')" />
</ImportGroup>
</Project>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
{
"version": 2,
"dgSpecHash": "tuMKldGZn8g=",
"success": true,
"projectFilePath": "/srv/common_drive/Projects/pgsql-jellyfin/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj",
"expectedPackageFiles": [
"/home/wjones/.nuget/packages/asynckeyedlock/8.0.2/asynckeyedlock.8.0.2.nupkg.sha512",
"/home/wjones/.nuget/packages/bitfaster.caching/2.5.4/bitfaster.caching.2.5.4.nupkg.sha512",
"/home/wjones/.nuget/packages/diacritics/4.1.4/diacritics.4.1.4.nupkg.sha512",
"/home/wjones/.nuget/packages/icu4n/60.1.0-alpha.356/icu4n.60.1.0-alpha.356.nupkg.sha512",
"/home/wjones/.nuget/packages/icu4n.transliterator/60.1.0-alpha.356/icu4n.transliterator.60.1.0-alpha.356.nupkg.sha512",
"/home/wjones/.nuget/packages/idisposableanalyzers/4.0.8/idisposableanalyzers.4.0.8.nupkg.sha512",
"/home/wjones/.nuget/packages/j2n/2.0.0/j2n.2.0.0.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.codeanalysis.bannedapianalyzers/4.14.0/microsoft.codeanalysis.bannedapianalyzers.4.14.0.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.data.sqlite.core/10.0.3/microsoft.data.sqlite.core.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore/10.0.3/microsoft.entityframeworkcore.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.abstractions/10.0.3/microsoft.entityframeworkcore.abstractions.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.analyzers/10.0.3/microsoft.entityframeworkcore.analyzers.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.relational/10.0.3/microsoft.entityframeworkcore.relational.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.sqlite/10.0.3/microsoft.entityframeworkcore.sqlite.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.entityframeworkcore.sqlite.core/10.0.3/microsoft.entityframeworkcore.sqlite.core.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.caching.abstractions/10.0.3/microsoft.extensions.caching.abstractions.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.caching.memory/10.0.3/microsoft.extensions.caching.memory.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.configuration/10.0.3/microsoft.extensions.configuration.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.configuration.abstractions/10.0.3/microsoft.extensions.configuration.abstractions.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.configuration.binder/10.0.3/microsoft.extensions.configuration.binder.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.dependencyinjection/10.0.3/microsoft.extensions.dependencyinjection.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/10.0.3/microsoft.extensions.dependencyinjection.abstractions.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.dependencymodel/10.0.3/microsoft.extensions.dependencymodel.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.logging/10.0.3/microsoft.extensions.logging.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.logging.abstractions/10.0.3/microsoft.extensions.logging.abstractions.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.options/10.0.3/microsoft.extensions.options.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/microsoft.extensions.primitives/10.0.3/microsoft.extensions.primitives.10.0.3.nupkg.sha512",
"/home/wjones/.nuget/packages/nebml/1.1.0.5/nebml.1.1.0.5.nupkg.sha512",
"/home/wjones/.nuget/packages/polly/8.6.5/polly.8.6.5.nupkg.sha512",
"/home/wjones/.nuget/packages/polly.core/8.6.5/polly.core.8.6.5.nupkg.sha512",
"/home/wjones/.nuget/packages/seriloganalyzer/0.15.0/seriloganalyzer.0.15.0.nupkg.sha512",
"/home/wjones/.nuget/packages/smartanalyzers.multithreadinganalyzer/1.1.31/smartanalyzers.multithreadinganalyzer.1.1.31.nupkg.sha512",
"/home/wjones/.nuget/packages/sqlitepclraw.bundle_e_sqlite3/2.1.11/sqlitepclraw.bundle_e_sqlite3.2.1.11.nupkg.sha512",
"/home/wjones/.nuget/packages/sqlitepclraw.core/2.1.11/sqlitepclraw.core.2.1.11.nupkg.sha512",
"/home/wjones/.nuget/packages/sqlitepclraw.lib.e_sqlite3/2.1.11/sqlitepclraw.lib.e_sqlite3.2.1.11.nupkg.sha512",
"/home/wjones/.nuget/packages/sqlitepclraw.provider.e_sqlite3/2.1.11/sqlitepclraw.provider.e_sqlite3.2.1.11.nupkg.sha512",
"/home/wjones/.nuget/packages/stylecop.analyzers/1.2.0-beta.556/stylecop.analyzers.1.2.0-beta.556.nupkg.sha512",
"/home/wjones/.nuget/packages/stylecop.analyzers.unstable/1.2.0.556/stylecop.analyzers.unstable.1.2.0.556.nupkg.sha512"
],
"logs": []
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
17714532049100000
@@ -0,0 +1 @@
17715044199800000