repo creation with initial code after cloning public repo
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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;
|
||||
}
|
||||
}
|
||||
+102
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v10.0", FrameworkDisplayName = ".NET 10.0")]
|
||||
+27
@@ -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 =
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+5260
File diff suppressed because it is too large
Load Diff
+26
@@ -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>
|
||||
+10
@@ -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
|
||||
Reference in New Issue
Block a user