diff options
Diffstat (limited to 'Jellyfin.Server.Implementations')
15 files changed, 1385 insertions, 338 deletions
diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs new file mode 100644 index 000000000..d70ac672f --- /dev/null +++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs @@ -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)); + } +} diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs index fbbb5bca7..63c80634f 100644 --- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -1,8 +1,11 @@ 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; @@ -41,6 +44,28 @@ public static class ServiceCollectionExtensions 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> @@ -54,7 +79,6 @@ public static class ServiceCollectionExtensions IConfiguration configuration) { var efCoreConfiguration = configurationManager.GetConfiguration<DatabaseConfigurationOptions>("database"); - var providers = GetSupportedDbProviders(); JellyfinDbProviderFactory? providerFactory = null; if (efCoreConfiguration?.DatabaseType is null) @@ -73,22 +97,51 @@ public static class ServiceCollectionExtensions efCoreConfiguration = new DatabaseConfigurationOptions() { DatabaseType = "Jellyfin-SQLite", + LockingBehavior = DatabaseLockingBehaviorTypes.NoLock }; configurationManager.SaveConfiguration("database", efCoreConfiguration); } } - if (!providers.TryGetValue(efCoreConfiguration.DatabaseType.ToUpperInvariant(), out providerFactory!)) + 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 { - throw new InvalidOperationException($"Jellyfin cannot find the database provider of type '{efCoreConfiguration.DatabaseType}'. Supported types are {string.Join(", ", providers.Keys)}"); + 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); + var lockingBehavior = serviceProvider.GetRequiredService<IEntityFrameworkCoreLockingBehavior>(); + lockingBehavior.Initialise(opt); }); return serviceCollection; diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs new file mode 100644 index 000000000..77a49b2b5 --- /dev/null +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs @@ -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; } +} diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs new file mode 100644 index 000000000..8bd108c44 --- /dev/null +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs @@ -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; } +} diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs new file mode 100644 index 000000000..e266d5a3b --- /dev/null +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -0,0 +1,512 @@ +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 = Version.Parse("0.1.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 = zipArchiveEntry.Open(); + 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) + { + source = Path.GetFullPath(source); + Directory.CreateDirectory(source); + + foreach (var item in zipArchive.Entries) + { + var sanitizedSourcePath = Path.GetFullPath(item.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar); + if (!sanitizedSourcePath.StartsWith(target, StringComparison.Ordinal)) + { + continue; + } + + var targetPath = Path.Combine(source, sanitizedSourcePath[target.Length..].Trim('/')); + _logger.LogInformation("Restore and override {File}", targetPath); + item.ExtractToFile(targetPath); + } + } + + CopyDirectory(_applicationPaths.ConfigurationDirectoryPath, "Config/"); + CopyDirectory(_applicationPaths.DataPath, "Data/"); + CopyDirectory(_applicationPaths.RootFolderPath, "Root/"); + + 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($"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 = historyEntry.Open(); + 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 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($"Database\\{entityType.Type.Name}.json"); + if (zipEntry is null) + { + _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name); + continue; + } + + var zipEntryStream = zipEntry.Open(); + 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} continue 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) + }; + + 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"); + _logger.LogInformation("Attempt 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("Start 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, Type type) + { + 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, 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, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!, e.PropertyType)))), + (Type: typeof(HistoryRow), ValueFactory: new Func<IAsyncEnumerable<object>>(() => 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.Type.Name); + var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.Type.Name}.json"); + var entities = 0; + var zipEntryStream = zipEntry.Open(); + 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 + { + JsonSerializer.SerializeToDocument(item, _serializerSettings).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.Type.Name, 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))) + { + zipArchive.CreateEntryFromFile(item, Path.Combine("Config", Path.GetFileName(item))); + } + + 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)) + { + zipArchive.CreateEntryFromFile(item, Path.Combine(target, item[..source.Length].Trim('\\'))); + } + } + + 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 = zipArchive.CreateEntry(ManifestEntryName).Open(); + await using (manifestStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false); + } + } + + _logger.LogInformation("Backup created"); + return Map(manifest, backupPath); + } + + /// <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.LogError(ex, "Tried to load archive from {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.LogError(ex, "Could not load {BackupArchive} path.", 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 = manifestEntry.Open(); + 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 + }; + } +} diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index b0a36b3ae..3efcb6dd3 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -9,6 +9,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Linq.Expressions; using System.Reflection; using System.Text; using System.Text.Json; @@ -19,6 +20,7 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; +using Jellyfin.Server.Implementations.Extensions; using MediaBrowser.Common; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; @@ -66,7 +68,7 @@ public sealed class BaseItemRepository private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist]; private static readonly IReadOnlyList<ItemValueType> _getAlbumArtistValueTypes = [ItemValueType.AlbumArtist]; private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios]; - private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Studios]; + private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre]; /// <summary> /// Initializes a new instance of the <see cref="BaseItemRepository"/> class. @@ -112,6 +114,7 @@ public sealed class BaseItemRepository context.ItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete(); context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete(); context.ItemValuesMap.Where(e => e.ItemId == id).ExecuteDelete(); + context.KeyframeData.Where(e => e.ItemId == id).ExecuteDelete(); context.MediaSegments.Where(e => e.ItemId == id).ExecuteDelete(); context.MediaStreamInfos.Where(e => e.ItemId == id).ExecuteDelete(); context.PeopleBaseItemMap.Where(e => e.ItemId == id).ExecuteDelete(); @@ -145,37 +148,37 @@ public sealed class BaseItemRepository } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAllArtists(InternalItemsQuery filter) { return GetItemValues(filter, _getAllArtistsValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetArtists(InternalItemsQuery filter) { return GetItemValues(filter, _getArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAlbumArtists(InternalItemsQuery filter) { return GetItemValues(filter, _getAlbumArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetStudios(InternalItemsQuery filter) { return GetItemValues(filter, _getStudiosValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Studio]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetGenres(InternalItemsQuery filter) { return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetMusicGenres(InternalItemsQuery filter) { return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicGenre]); } @@ -399,7 +402,8 @@ public sealed class BaseItemRepository private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter) { - IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking().AsSplitQuery() + IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking(); + dbQuery = dbQuery.AsSingleQuery() .Include(e => e.TrailerTypes) .Include(e => e.Provider) .Include(e => e.LockedFields); @@ -450,11 +454,9 @@ public sealed class BaseItemRepository var images = item.ImageInfos.Select(e => Map(item.Id, e)); using var context = _dbProvider.CreateDbContext(); - using var transaction = context.Database.BeginTransaction(); context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete(); context.BaseItemImageInfos.AddRange(images); context.SaveChanges(); - transaction.Commit(); } /// <inheritdoc /> @@ -484,17 +486,19 @@ public sealed class BaseItemRepository tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags)); } - var localItemValueCache = new Dictionary<(int MagicNumber, string Value), Guid>(); - using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); + + var ids = tuples.Select(f => f.Item.Id).ToArray(); + var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray(); + foreach (var item in tuples) { var entity = Map(item.Item); // TODO: refactor this "inconsistency" entity.TopParentId = item.TopParent?.Id; - if (!context.BaseItems.Any(e => e.Id == entity.Id)) + if (!existingItems.Any(e => e == entity.Id)) { context.BaseItems.Add(entity); } @@ -503,59 +507,98 @@ public sealed class BaseItemRepository context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete(); context.BaseItems.Attach(entity).State = EntityState.Modified; } + } - context.AncestorIds.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - if (item.Item.SupportsAncestors && item.AncestorIds != null) + context.SaveChanges(); + + var itemValueMaps = tuples + .Select(e => (Item: e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) + .ToArray(); + var allListedItemValues = itemValueMaps + .SelectMany(f => f.Values) + .Distinct() + .ToArray(); + var existingValues = context.ItemValues + .Select(e => new { - foreach (var ancestorId in item.AncestorIds) - { - if (!context.BaseItems.Any(f => f.Id == ancestorId)) - { - continue; - } + item = e, + Key = e.Type + "+" + e.Value + }) + .Where(f => allListedItemValues.Select(e => $"{(int)e.MagicNumber}+{e.Value}").Contains(f.Key)) + .Select(e => e.item) + .ToArray(); + var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).Select(f => new ItemValue() + { + CleanValue = GetCleanValue(f.Value), + ItemValueId = Guid.NewGuid(), + Type = f.MagicNumber, + Value = f.Value + }).ToArray(); + context.ItemValues.AddRange(missingItemValues); + context.SaveChanges(); + + var itemValuesStore = existingValues.Concat(missingItemValues).ToArray(); + var valueMap = itemValueMaps + .Select(f => (Item: f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).ToArray())) + .ToArray(); - context.AncestorIds.Add(new AncestorId() + var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList(); + + foreach (var item in valueMap) + { + var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList(); + foreach (var itemValue in item.Values) + { + var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId); + if (existingItem is null) + { + context.ItemValuesMap.Add(new ItemValueMap() { - ParentItemId = ancestorId, - ItemId = entity.Id, Item = null!, - ParentItem = null! + ItemId = item.Item.Id, + ItemValue = null!, + ItemValueId = itemValue.ItemValueId }); } - } - - // Never save duplicate itemValues as they are now mapped anyway. - var itemValuesToSave = GetItemValuesToSave(item.Item, item.InheritedTags).DistinctBy(e => (GetCleanValue(e.Value), e.MagicNumber)); - context.ItemValuesMap.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - foreach (var itemValue in itemValuesToSave) - { - if (!localItemValueCache.TryGetValue(itemValue, out var refValue)) + else { - refValue = context.ItemValues - .Where(f => f.CleanValue == GetCleanValue(itemValue.Value) && (int)f.Type == itemValue.MagicNumber) - .Select(e => e.ItemValueId) - .FirstOrDefault(); + // map exists, remove from list so its been handled. + itemMappedValues.Remove(existingItem); } + } + + // all still listed values are not in the new list so remove them. + context.ItemValuesMap.RemoveRange(itemMappedValues); + } + + context.SaveChanges(); - if (refValue.IsEmpty()) + foreach (var item in tuples) + { + if (item.Item.SupportsAncestors && item.AncestorIds != null) + { + var existingAncestorIds = context.AncestorIds.Where(e => e.ItemId == item.Item.Id).ToList(); + var validAncestorIds = context.BaseItems.Where(e => item.AncestorIds.Contains(e.Id)).Select(f => f.Id).ToArray(); + foreach (var ancestorId in validAncestorIds) { - context.ItemValues.Add(new ItemValue() + var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId); + if (existingAncestorId is null) { - CleanValue = GetCleanValue(itemValue.Value), - Type = (ItemValueType)itemValue.MagicNumber, - ItemValueId = refValue = Guid.NewGuid(), - Value = itemValue.Value - }); - localItemValueCache[itemValue] = refValue; + context.AncestorIds.Add(new AncestorId() + { + ParentItemId = ancestorId, + ItemId = item.Item.Id, + Item = null!, + ParentItem = null! + }); + } + else + { + existingAncestorIds.Remove(existingAncestorId); + } } - context.ItemValuesMap.Add(new ItemValueMap() - { - Item = null!, - ItemId = entity.Id, - ItemValue = null!, - ItemValueId = refValue - }); + context.AncestorIds.RemoveRange(existingAncestorIds); } } @@ -617,6 +660,7 @@ public sealed class BaseItemRepository dto.PreferredMetadataCountryCode = entity.PreferredMetadataCountryCode; dto.IsInMixedFolder = entity.IsInMixedFolder; dto.InheritedParentalRatingValue = entity.InheritedParentalRatingValue; + dto.InheritedParentalRatingSubValue = entity.InheritedParentalRatingSubValue; dto.CriticRating = entity.CriticRating; dto.PresentationUniqueKey = entity.PresentationUniqueKey; dto.OriginalTitle = entity.OriginalTitle; @@ -781,6 +825,7 @@ public sealed class BaseItemRepository entity.PreferredMetadataCountryCode = dto.PreferredMetadataCountryCode; entity.IsInMixedFolder = dto.IsInMixedFolder; entity.InheritedParentalRatingValue = dto.InheritedParentalRatingValue; + entity.InheritedParentalRatingSubValue = dto.InheritedParentalRatingSubValue; entity.CriticRating = dto.CriticRating; entity.PresentationUniqueKey = dto.PresentationUniqueKey; entity.OriginalTitle = dto.OriginalTitle; @@ -993,7 +1038,7 @@ public sealed class BaseItemRepository return Map(baseItemEntity, dto, appHost); } - private QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType) + private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType) { ArgumentNullException.ThrowIfNull(filter); @@ -1004,20 +1049,59 @@ public sealed class BaseItemRepository using var context = _dbProvider.CreateDbContext(); - var query = TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); + var innerQueryFilter = TranslateQuery(context.BaseItems, context, new InternalItemsQuery(filter.User) + { + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsAiring = filter.IsAiring, + IsMovie = filter.IsMovie, + IsSports = filter.IsSports, + IsKids = filter.IsKids, + IsNews = filter.IsNews, + IsSeries = filter.IsSeries + }); - query = query.Where(e => e.Type == returnType); - // this does not seem to be nesseary but it does not make any sense why this isn't working. - // && e.ItemValues!.Any(f => e.CleanName == f.ItemValue.CleanValue && itemValueTypes.Any(w => (ItemValueType)w == f.ItemValue.Type))); + var innerQuery = PrepareItemQuery(context, filter) + .Where(e => e.Type == returnType) + .Where(e => context.ItemValues! + .Where(f => itemValueTypes.Contains(f.Type)) + .Where(f => innerQueryFilter.Any(g => f.BaseItemsMap!.Any(w => w.ItemId == g.Id))) + .Select(f => f.CleanValue) + .Contains(e.CleanName)); + + var outerQueryFilter = new InternalItemsQuery(filter.User) + { + IsPlayed = filter.IsPlayed, + IsFavorite = filter.IsFavorite, + IsFavoriteOrLiked = filter.IsFavoriteOrLiked, + IsLiked = filter.IsLiked, + IsLocked = filter.IsLocked, + NameLessThan = filter.NameLessThan, + NameStartsWith = filter.NameStartsWith, + NameStartsWithOrGreater = filter.NameStartsWithOrGreater, + Tags = filter.Tags, + OfficialRatings = filter.OfficialRatings, + StudioIds = filter.StudioIds, + GenreIds = filter.GenreIds, + Genres = filter.Genres, + Years = filter.Years, + NameContains = filter.NameContains, + SearchTerm = filter.SearchTerm, + ExcludeItemIds = filter.ExcludeItemIds + }; - if (filter.OrderBy.Count != 0 - || !string.IsNullOrEmpty(filter.SearchTerm)) - { - query = ApplyOrder(query, filter); - } - else + var query = TranslateQuery(innerQuery, context, outerQueryFilter) + .GroupBy(e => e.PresentationUniqueKey); + + var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); + if (filter.EnableTotalRecordCount) { - query = query.OrderBy(e => e.SortName); + result.TotalRecordCount = query.Count(); } if (filter.Limit.HasValue || filter.StartIndex.HasValue) @@ -1035,41 +1119,84 @@ public sealed class BaseItemRepository } } - var result = new QueryResult<(BaseItemDto, ItemCounts)>(); - if (filter.EnableTotalRecordCount) - { - result.TotalRecordCount = query.GroupBy(e => e.PresentationUniqueKey).Select(e => e.First()).Count(); - } - - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; - var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; - var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; - var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; - var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; + IQueryable<BaseItemEntity>? itemCountQuery = null; - var resultQuery = query.Select(e => new + if (filter.IncludeItemTypes.Length > 0) { - item = e, - // TODO: This is bad refactor! - itemCount = new ItemCounts() + // if we are to include more then one type, sub query those items beforehand. + + var typeSubQuery = new InternalItemsQuery(filter.User) { - SeriesCount = e.ItemValues!.Count(f => f.Item.Type == seriesTypeName), - EpisodeCount = e.ItemValues!.Count(f => f.Item.Type == episodeTypeName), - MovieCount = e.ItemValues!.Count(f => f.Item.Type == movieTypeName), - AlbumCount = e.ItemValues!.Count(f => f.Item.Type == musicAlbumTypeName), - ArtistCount = e.ItemValues!.Count(f => f.Item.Type == musicArtistTypeName), - SongCount = e.ItemValues!.Count(f => f.Item.Type == audioTypeName), - TrailerCount = e.ItemValues!.Count(f => f.Item.Type == trailerTypeName), - } - }); + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ExcludeItemIds = filter.ExcludeItemIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsPlayed = filter.IsPlayed + }; - result.StartIndex = filter.StartIndex ?? 0; - result.Items = resultQuery.ToArray().Where(e => e is not null).Select(e => + itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking(), context, typeSubQuery) + .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; + var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; + + var resultQuery = query.Select(e => new + { + item = e.AsQueryable() + .Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields) + .Include(e => e.Images) + .AsSingleQuery().First(), + // TODO: This is bad refactor! + itemCount = new ItemCounts() + { + SeriesCount = itemCountQuery!.Count(f => f.Type == seriesTypeName), + EpisodeCount = itemCountQuery!.Count(f => f.Type == episodeTypeName), + MovieCount = itemCountQuery!.Count(f => f.Type == movieTypeName), + AlbumCount = itemCountQuery!.Count(f => f.Type == musicAlbumTypeName), + ArtistCount = itemCountQuery!.Count(f => f.Type == musicArtistTypeName), + SongCount = itemCountQuery!.Count(f => f.Type == audioTypeName), + TrailerCount = itemCountQuery!.Count(f => f.Type == trailerTypeName), + } + }); + + result.StartIndex = filter.StartIndex ?? 0; + result.Items = + [ + .. resultQuery + .AsEnumerable() + .Where(e => e is not null) + .Select(e => + { + return (DeserialiseBaseItem(e.item, filter.SkipDeserialization), e.itemCount); + }) + ]; + } + else { - return (DeserialiseBaseItem(e.item, filter.SkipDeserialization), e.itemCount); - }).ToArray(); + result.StartIndex = filter.StartIndex ?? 0; + result.Items = + [ + .. query + .Select(e => e.First()) + .AsEnumerable() + .Where(e => e is not null) + .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e => + { + return (DeserialiseBaseItem(e, filter.SkipDeserialization), null); + }) + ]; + } return result; } @@ -1097,27 +1224,27 @@ public sealed class BaseItemRepository return value.RemoveDiacritics().ToLowerInvariant(); } - private List<(int MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags) + private List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags) { - var list = new List<(int, string)>(); + var list = new List<(ItemValueType, string)>(); if (item is IHasArtist hasArtist) { - list.AddRange(hasArtist.Artists.Select(i => (0, i))); + list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i))); } if (item is IHasAlbumArtist hasAlbumArtist) { - list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (1, i))); + list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i))); } - list.AddRange(item.Genres.Select(i => (2, i))); - list.AddRange(item.Studios.Select(i => (3, i))); - list.AddRange(item.Tags.Select(i => (4, i))); + list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i))); + list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i))); + list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i))); // keywords was 5 - list.AddRange(inheritedTags.Select(i => (6, i))); + list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, i))); // Remove all invalid values. list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2)); @@ -1252,7 +1379,7 @@ public sealed class BaseItemRepository } else if (orderBy.Count == 0) { - return query; + return query.OrderBy(e => e.SortName); } IOrderedQueryable<BaseItemEntity>? orderedQuery = null; @@ -1304,34 +1431,39 @@ public sealed class BaseItemRepository JellyfinDbContext context, InternalItemsQuery filter) { + const int HDWidth = 1200; + const int UHDWidth = 3800; + const int UHDHeight = 2100; + var minWidth = filter.MinWidth; var maxWidth = filter.MaxWidth; var now = DateTime.UtcNow; - if (filter.IsHD.HasValue) + if (filter.IsHD.HasValue || filter.Is4K.HasValue) { - const int Threshold = 1200; - if (filter.IsHD.Value) - { - minWidth = Threshold; - } - else + bool includeSD = false; + bool includeHD = false; + bool include4K = false; + + if (filter.IsHD.HasValue && !filter.IsHD.Value) { - maxWidth = Threshold - 1; + includeSD = true; } - } - if (filter.Is4K.HasValue) - { - const int Threshold = 3800; - if (filter.Is4K.Value) + if (filter.IsHD.HasValue && filter.IsHD.Value) { - minWidth = Threshold; + includeHD = true; } - else + + if (filter.Is4K.HasValue && filter.Is4K.Value) { - maxWidth = Threshold - 1; + include4K = true; } + + baseQuery = baseQuery.Where(e => + (includeSD && e.Width < HDWidth) || + (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight)) || + (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))); } if (minWidth.HasValue) @@ -1346,7 +1478,7 @@ public sealed class BaseItemRepository if (maxWidth.HasValue) { - baseQuery = baseQuery.Where(e => e.Width >= maxWidth); + baseQuery = baseQuery.Where(e => e.Width <= maxWidth); } if (filter.MaxHeight.HasValue) @@ -1429,6 +1561,7 @@ public sealed class BaseItemRepository } var includeTypes = filter.IncludeItemTypes; + // Only specify excluded types if no included types are specified if (filter.IncludeItemTypes.Length == 0) { @@ -1454,25 +1587,10 @@ public sealed class BaseItemRepository baseQuery = baseQuery.Where(e => !excludeTypeName.Contains(e.Type)); } } - else if (includeTypes.Length == 1) - { - if (_itemTypeLookup.BaseItemKindNames.TryGetValue(includeTypes[0], out var includeTypeName)) - { - baseQuery = baseQuery.Where(e => e.Type == includeTypeName); - } - } - else if (includeTypes.Length > 1) + else { - var includeTypeName = new List<string>(); - foreach (var includeType in includeTypes) - { - if (_itemTypeLookup.BaseItemKindNames.TryGetValue(includeType, out var baseItemKindName)) - { - includeTypeName.Add(baseItemKindName!); - } - } - - baseQuery = baseQuery.Where(e => includeTypeName.Contains(e.Type)); + string[] types = includeTypes.Select(f => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(f)).Where(e => e != null).ToArray()!; + baseQuery = baseQuery.WhereOneOrMany(types, f => f.Type); } if (filter.ChannelIds.Count > 0) @@ -1578,7 +1696,7 @@ public sealed class BaseItemRepository if (filter.MinPremiereDate.HasValue) { - baseQuery = baseQuery.Where(e => e.PremiereDate <= filter.MinPremiereDate.Value); + baseQuery = baseQuery.Where(e => e.PremiereDate >= filter.MinPremiereDate.Value); } if (filter.MaxPremiereDate.HasValue) @@ -1730,64 +1848,59 @@ public sealed class BaseItemRepository if (filter.ArtistIds.Length > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type <= ItemValueType.Artist && filter.ArtistIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ArtistIds); } if (filter.AlbumArtistIds.Length > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.AlbumArtistIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.AlbumArtistIds); } if (filter.ContributingArtistIds.Length > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.ContributingArtistIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ContributingArtistIds); } if (filter.AlbumIds.Length > 0) { - baseQuery = baseQuery.Where(e => context.BaseItems.Where(f => filter.AlbumIds.Contains(f.Id)).Any(f => f.Name == e.Album)); + var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id); + baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album)); } if (filter.ExcludeArtistIds.Length > 0) { - baseQuery = baseQuery - .Where(e => !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.ExcludeArtistIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ExcludeArtistIds, true); } if (filter.GenreIds.Count > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Genre && filter.GenreIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray()); } if (filter.Genres.Count > 0) { - var cleanGenres = filter.Genres.Select(e => GetCleanValue(e)).ToArray(); + var cleanGenres = filter.Genres.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Genre && cleanGenres.Contains(f.ItemValue.CleanValue))); + .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Genre).Any(cleanGenres)); } if (tags.Count > 0) { - var cleanValues = tags.Select(e => GetCleanValue(e)).ToArray(); + var cleanValues = tags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && cleanValues.Contains(f.ItemValue.CleanValue))); + .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); } if (excludeTags.Count > 0) { - var cleanValues = excludeTags.Select(e => GetCleanValue(e)).ToArray(); + var cleanValues = excludeTags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); baseQuery = baseQuery - .Where(e => !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && cleanValues.Contains(f.ItemValue.CleanValue))); + .Where(e => !e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); } if (filter.StudioIds.Length > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Studios && filter.StudioIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray()); } if (filter.OfficialRatings.Length > 0) @@ -1796,61 +1909,73 @@ public sealed class BaseItemRepository .Where(e => filter.OfficialRatings.Contains(e.OfficialRating)); } - if (filter.HasParentalRating ?? false) + Expression<Func<BaseItemEntity, bool>>? minParentalRatingFilter = null; + if (filter.MinParentalRating != null) { - if (filter.MinParentalRating.HasValue) + var min = filter.MinParentalRating; + minParentalRatingFilter = e => e.InheritedParentalRatingValue >= min.Score || e.InheritedParentalRatingValue == null; + if (min.SubScore != null) { - baseQuery = baseQuery - .Where(e => e.InheritedParentalRatingValue >= filter.MinParentalRating.Value); + minParentalRatingFilter = minParentalRatingFilter.And(e => e.InheritedParentalRatingValue >= min.SubScore || e.InheritedParentalRatingValue == null); } + } - if (filter.MaxParentalRating.HasValue) + Expression<Func<BaseItemEntity, bool>>? maxParentalRatingFilter = null; + if (filter.MaxParentalRating != null) + { + var max = filter.MaxParentalRating; + maxParentalRatingFilter = e => e.InheritedParentalRatingValue <= max.Score || e.InheritedParentalRatingValue == null; + if (max.SubScore != null) { - baseQuery = baseQuery - .Where(e => e.InheritedParentalRatingValue < filter.MaxParentalRating.Value); + maxParentalRatingFilter = maxParentalRatingFilter.And(e => e.InheritedParentalRatingValue <= max.SubScore || e.InheritedParentalRatingValue == null); } } - else if (filter.BlockUnratedItems.Length > 0) + + if (filter.HasParentalRating ?? false) { - var unratedItems = filter.BlockUnratedItems.Select(f => f.ToString()).ToArray(); - if (filter.MinParentalRating.HasValue) + if (minParentalRatingFilter != null) { - if (filter.MaxParentalRating.HasValue) - { - baseQuery = baseQuery - .Where(e => (e.InheritedParentalRatingValue == null && !unratedItems.Contains(e.UnratedType)) - || (e.InheritedParentalRatingValue >= filter.MinParentalRating && e.InheritedParentalRatingValue <= filter.MaxParentalRating)); - } - else - { - baseQuery = baseQuery - .Where(e => (e.InheritedParentalRatingValue == null && !unratedItems.Contains(e.UnratedType)) - || e.InheritedParentalRatingValue >= filter.MinParentalRating); - } + baseQuery = baseQuery.Where(minParentalRatingFilter); } - else + + if (maxParentalRatingFilter != null) { - baseQuery = baseQuery - .Where(e => e.InheritedParentalRatingValue != null && !unratedItems.Contains(e.UnratedType)); + baseQuery = baseQuery.Where(maxParentalRatingFilter); } } - else if (filter.MinParentalRating.HasValue) + else if (filter.BlockUnratedItems.Length > 0) { - if (filter.MaxParentalRating.HasValue) + var unratedItemTypes = filter.BlockUnratedItems.Select(f => f.ToString()).ToArray(); + Expression<Func<BaseItemEntity, bool>> unratedItemFilter = e => e.InheritedParentalRatingValue != null || !unratedItemTypes.Contains(e.UnratedType); + + if (minParentalRatingFilter != null && maxParentalRatingFilter != null) { - baseQuery = baseQuery - .Where(e => e.InheritedParentalRatingValue != null && e.InheritedParentalRatingValue >= filter.MinParentalRating.Value && e.InheritedParentalRatingValue <= filter.MaxParentalRating.Value); + baseQuery = baseQuery.Where(unratedItemFilter.And(minParentalRatingFilter.And(maxParentalRatingFilter))); + } + else if (minParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(unratedItemFilter.And(minParentalRatingFilter)); + } + else if (maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(unratedItemFilter.And(maxParentalRatingFilter)); } else { - baseQuery = baseQuery - .Where(e => e.InheritedParentalRatingValue != null && e.InheritedParentalRatingValue >= filter.MinParentalRating.Value); + baseQuery = baseQuery.Where(unratedItemFilter); } } - else if (filter.MaxParentalRating.HasValue) + else if (minParentalRatingFilter != null || maxParentalRatingFilter != null) { - baseQuery = baseQuery - .Where(e => e.InheritedParentalRatingValue != null && e.InheritedParentalRatingValue >= filter.MaxParentalRating.Value); + if (minParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(minParentalRatingFilter); + } + + if (maxParentalRatingFilter != null) + { + baseQuery = baseQuery.Where(maxParentalRatingFilter); + } } else if (!filter.HasParentalRating ?? false) { @@ -1945,30 +2070,30 @@ public sealed class BaseItemRepository if (filter.IsDeadArtist.HasValue && filter.IsDeadArtist.Value) { baseQuery = baseQuery - .Where(e => e.ItemValues!.Count(f => f.ItemValue.Type == ItemValueType.Artist || f.ItemValue.Type == ItemValueType.AlbumArtist) == 1); + .Where(e => !context.ItemValues.Where(f => _getAllArtistsValueTypes.Contains(f.Type)).Any(f => f.Value == e.Name)); } if (filter.IsDeadStudio.HasValue && filter.IsDeadStudio.Value) { baseQuery = baseQuery - .Where(e => e.ItemValues!.Count(f => f.ItemValue.Type == ItemValueType.Studios) == 1); + .Where(e => !context.ItemValues.Where(f => _getStudiosValueTypes.Contains(f.Type)).Any(f => f.Value == e.Name)); } - if (filter.IsDeadPerson.HasValue && filter.IsDeadPerson.Value) + if (filter.IsDeadGenre.HasValue && filter.IsDeadGenre.Value) { baseQuery = baseQuery - .Where(e => !context.Peoples.Any(f => f.Name == e.Name)); + .Where(e => !context.ItemValues.Where(f => _getGenreValueTypes.Contains(f.Type)).Any(f => f.Value == e.Name)); } - if (filter.Years.Length == 1) + if (filter.IsDeadPerson.HasValue && filter.IsDeadPerson.Value) { baseQuery = baseQuery - .Where(e => e.ProductionYear == filter.Years[0]); + .Where(e => !context.Peoples.Any(f => f.Name == e.Name)); } - else if (filter.Years.Length > 1) + + if (filter.Years.Length > 0) { - baseQuery = baseQuery - .Where(e => filter.Years.Any(f => f == e.ProductionYear)); + baseQuery = baseQuery.WhereOneOrMany(filter.Years, e => e.ProductionYear!.Value); } var isVirtualItem = filter.IsVirtualItem ?? filter.IsMissing; @@ -2009,14 +2134,12 @@ public sealed class BaseItemRepository if (filter.MediaTypes.Length > 0) { var mediaTypes = filter.MediaTypes.Select(f => f.ToString()).ToArray(); - baseQuery = baseQuery - .Where(e => mediaTypes.Contains(e.MediaType)); + baseQuery = baseQuery.WhereOneOrMany(mediaTypes, e => e.MediaType); } if (filter.ItemIds.Length > 0) { - baseQuery = baseQuery - .Where(e => filter.ItemIds.Contains(e.Id)); + baseQuery = baseQuery.WhereOneOrMany(filter.ItemIds, e => e.Id); } if (filter.ExcludeItemIds.Length > 0) @@ -2062,19 +2185,19 @@ public sealed class BaseItemRepository } else { - baseQuery = baseQuery.Where(e => queryTopParentIds.Contains(e.TopParentId!.Value)); + baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); } } if (filter.AncestorIds.Length > 0) { - baseQuery = baseQuery.Where(e => e.Children!.Any(f => filter.AncestorIds.Contains(f.ParentItemId))); + baseQuery = baseQuery.Where(e => e.Parents!.Any(f => filter.AncestorIds.Contains(f.ParentItemId))); } if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) { baseQuery = baseQuery - .Where(e => context.BaseItems.Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.ParentAncestors!.Any(w => w.ItemId == f.Id))); + .Where(e => context.BaseItems.Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id))); } if (!string.IsNullOrWhiteSpace(filter.SeriesPresentationUniqueKey)) @@ -2109,7 +2232,7 @@ public sealed class BaseItemRepository { baseQuery = baseQuery .Where(e => - e.ParentAncestors! + e.Parents! .Any(f => f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)) || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); @@ -2118,7 +2241,7 @@ public sealed class BaseItemRepository else { baseQuery = baseQuery - .Where(e => e.ParentAncestors!.Any(f => f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)))); + .Where(e => e.Parents!.Any(f => f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)))); } } diff --git a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs index 93e15735c..e0d23a261 100644 --- a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs +++ b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs @@ -1,12 +1,10 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; -using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Drawing; -using MediaBrowser.Model.Dto; +using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Entities; using Microsoft.EntityFrameworkCore; @@ -31,19 +29,7 @@ public class ChapterRepository : IChapterRepository _imageProcessor = imageProcessor; } - /// <inheritdoc cref="IChapterRepository"/> - public ChapterInfo? GetChapter(BaseItemDto baseItem, int index) - { - return GetChapter(baseItem.Id, index); - } - - /// <inheritdoc cref="IChapterRepository"/> - public IReadOnlyList<ChapterInfo> GetChapters(BaseItemDto baseItem) - { - return GetChapters(baseItem.Id); - } - - /// <inheritdoc cref="IChapterRepository"/> + /// <inheritdoc /> public ChapterInfo? GetChapter(Guid baseItemId, int index) { using var context = _dbProvider.CreateDbContext(); @@ -62,7 +48,7 @@ public class ChapterRepository : IChapterRepository return null; } - /// <inheritdoc cref="IChapterRepository"/> + /// <inheritdoc /> public IReadOnlyList<ChapterInfo> GetChapters(Guid baseItemId) { using var context = _dbProvider.CreateDbContext(); @@ -77,7 +63,7 @@ public class ChapterRepository : IChapterRepository .ToArray(); } - /// <inheritdoc cref="IChapterRepository"/> + /// <inheritdoc /> public void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters) { using var context = _dbProvider.CreateDbContext(); @@ -95,6 +81,14 @@ public class ChapterRepository : IChapterRepository } } + /// <inheritdoc /> + public void DeleteChapters(Guid itemId) + { + using var context = _dbProvider.CreateDbContext(); + context.Chapters.Where(c => c.ItemId.Equals(itemId)).ExecuteDelete(); + context.SaveChanges(); + } + private Chapter Map(ChapterInfo chapterInfo, int index, Guid itemId) { return new Chapter() @@ -118,7 +112,12 @@ public class ChapterRepository : IChapterRepository ImagePath = chapterInfo.ImagePath, Name = chapterInfo.Name, }; - chapterEntity.ImageTag = _imageProcessor.GetImageCacheTag(baseItemPath, chapterEntity.ImageDateModified); + + if (!string.IsNullOrEmpty(chapterInfo.ImagePath)) + { + chapterEntity.ImageTag = _imageProcessor.GetImageCacheTag(baseItemPath, chapterEntity.ImageDateModified); + } + return chapterEntity; } } diff --git a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs new file mode 100644 index 000000000..93c6f472e --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs @@ -0,0 +1,72 @@ +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(); + using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).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); + } +} diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index 36c3b9e56..7eb13b740 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -100,7 +100,18 @@ public class MediaStreamRepository : IMediaStreamRepository dto.IsAVC = entity.IsAvc; dto.Codec = entity.Codec; - dto.Language = entity.Language; + + 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; @@ -140,6 +151,7 @@ public class MediaStreamRepository : IMediaStreamRepository 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) { @@ -207,7 +219,8 @@ public class MediaStreamRepository : IMediaStreamRepository BlPresentFlag = dto.BlPresentFlag, DvBlSignalCompatibilityId = dto.DvBlSignalCompatibilityId, IsHearingImpaired = dto.IsHearingImpaired, - Rotation = dto.Rotation + Rotation = dto.Rotation, + Hdr10PlusPresentFlag = dto.Hdr10PlusPresentFlag, }; return entity; } diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index 03249b927..a0c127031 100644 --- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs +++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs @@ -44,7 +44,7 @@ public static class OrderMapper 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.Name, + ItemSortBy.Name => e => e.CleanName, ItemSortBy.CommunityRating => e => e.CommunityRating, ItemSortBy.ProductionYear => e => e.ProductionYear, ItemSortBy.CriticRating => e => e.CriticRating, diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 77877835e..be58e2a52 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -5,6 +5,7 @@ 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; @@ -53,7 +54,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter) { using var context = _dbProvider.CreateDbContext(); - var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter); + var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct(); // dbQuery = dbQuery.OrderBy(e => e.ListOrder); if (filter.Limit > 0) @@ -61,41 +62,48 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I dbQuery = dbQuery.Take(filter.Limit); } - return dbQuery.Select(e => e.Name).ToArray(); + return dbQuery.ToArray(); } /// <inheritdoc /> public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people) { using var context = _dbProvider.CreateDbContext(); - using var transaction = context.Database.BeginTransaction(); - context.PeopleBaseItemMap.Where(e => e.ItemId == itemId).ExecuteDelete(); // TODO: yes for __SOME__ reason there can be duplicates. - foreach (var item in people.DistinctBy(e => e.Id)) + people = people.DistinctBy(e => e.Id).ToArray(); + var personids = people.Select(f => f.Id); + var existingPersons = context.Peoples.Where(p => personids.Contains(p.Id)).Select(f => f.Id).ToArray(); + context.Peoples.AddRange(people.Where(e => !existingPersons.Contains(e.Id)).Select(Map)); + context.SaveChanges(); + + var maps = context.PeopleBaseItemMap.Where(e => e.ItemId == itemId).ToList(); + foreach (var person in people) { - var personEntity = Map(item); - var existingEntity = context.Peoples.FirstOrDefault(e => e.Id == personEntity.Id); - if (existingEntity is null) + var existingMap = maps.FirstOrDefault(e => e.PeopleId == person.Id); + if (existingMap is null) { - context.Peoples.Add(personEntity); - existingEntity = personEntity; + context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() + { + Item = null!, + ItemId = itemId, + People = null!, + PeopleId = person.Id, + ListOrder = person.SortOrder, + SortOrder = person.SortOrder, + Role = person.Role + }); } - - context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() + else { - Item = null!, - ItemId = itemId, - People = existingEntity, - PeopleId = existingEntity.Id, - ListOrder = item.SortOrder, - SortOrder = item.SortOrder, - Role = item.Role - }); + // person mapping already exists so remove from list + maps.Remove(existingMap); + } } + context.PeopleBaseItemMap.RemoveRange(maps); + context.SaveChanges(); - transaction.Commit(); } private PersonInfo Map(People people) @@ -133,9 +141,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (filter.User is not null && filter.IsFavorite.HasValue) { var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; - query = query.Where(e => e.PersonType == personType) - .Where(e => context.BaseItems.Where(d => d.UserData!.Any(w => w.IsFavorite == filter.IsFavorite && w.UserId.Equals(filter.User.Id))) - .Select(f => f.Name).Contains(e.Name)); + 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()) diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs index d6eeafacc..28b6890b0 100644 --- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs +++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs @@ -10,12 +10,12 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Library; +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; @@ -30,7 +30,6 @@ public class MediaSegmentManager : IMediaSegmentManager private readonly ILogger<MediaSegmentManager> _logger; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IMediaSegmentProvider[] _segmentProviders; - private readonly ILibraryManager _libraryManager; /// <summary> /// Initializes a new instance of the <see cref="MediaSegmentManager"/> class. @@ -38,12 +37,10 @@ public class MediaSegmentManager : IMediaSegmentManager /// <param name="logger">Logger.</param> /// <param name="dbProvider">EFCore Database factory.</param> /// <param name="segmentProviders">List of all media segment providers.</param> - /// <param name="libraryManager">Library manager.</param> public MediaSegmentManager( ILogger<MediaSegmentManager> logger, IDbContextFactory<JellyfinDbContext> dbProvider, - IEnumerable<IMediaSegmentProvider> segmentProviders, - ILibraryManager libraryManager) + IEnumerable<IMediaSegmentProvider> segmentProviders) { _logger = logger; _dbProvider = dbProvider; @@ -51,13 +48,11 @@ public class MediaSegmentManager : IMediaSegmentManager _segmentProviders = segmentProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); - _libraryManager = libraryManager; } /// <inheritdoc/> - public async Task RunSegmentPluginProviders(BaseItem baseItem, bool overwrite, CancellationToken cancellationToken) + public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool overwrite, CancellationToken cancellationToken) { - var libraryOptions = _libraryManager.GetLibraryOptions(baseItem); var providers = _segmentProviders .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) .OrderBy(i => @@ -140,22 +135,21 @@ public class MediaSegmentManager : IMediaSegmentManager } /// <inheritdoc /> - public async Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(Guid itemId, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true) + public async Task DeleteSegmentsAsync(Guid itemId, CancellationToken cancellationToken) { - var baseItem = _libraryManager.GetItemById(itemId); + using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await db.MediaSegments.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } - if (baseItem is null) + /// <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 []; } - return await GetSegmentsAsync(baseItem, typeFilter, filterByProvider).ConfigureAwait(false); - } - - /// <inheritdoc /> - public async Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem item, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true) - { using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var query = db.MediaSegments @@ -168,7 +162,6 @@ public class MediaSegmentManager : IMediaSegmentManager if (filterByProvider) { - var libraryOptions = _libraryManager.GetLibraryOptions(item); var providerIds = _segmentProviders .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name))) .Select(f => GetProviderId(f.Name)) diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs new file mode 100644 index 000000000..b2f54be7e --- /dev/null +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -0,0 +1,111 @@ +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 const long FiveHundredAndTwelveMegaByte = 536_870_911L; + private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + + /// <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.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte); + TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte); + TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte); + TestDataDirectorySize(applicationPaths.TempDirectory, 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. Required: at least {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]; + } +} diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index bf39f13a7..6f2d2a107 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -7,6 +7,7 @@ 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; @@ -14,7 +15,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Configuration; @@ -34,7 +34,6 @@ public class TrickplayManager : ITrickplayManager private readonly IMediaEncoder _mediaEncoder; private readonly IFileSystem _fileSystem; private readonly EncodingHelper _encodingHelper; - private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _config; private readonly IImageEncoder _imageEncoder; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; @@ -51,7 +50,6 @@ public class TrickplayManager : ITrickplayManager /// <param name="mediaEncoder">The media encoder.</param> /// <param name="fileSystem">The file system.</param> /// <param name="encodingHelper">The encoding helper.</param> - /// <param name="libraryManager">The library manager.</param> /// <param name="config">The server configuration manager.</param> /// <param name="imageEncoder">The image encoder.</param> /// <param name="dbProvider">The database provider.</param> @@ -62,7 +60,6 @@ public class TrickplayManager : ITrickplayManager IMediaEncoder mediaEncoder, IFileSystem fileSystem, EncodingHelper encodingHelper, - ILibraryManager libraryManager, IServerConfigurationManager config, IImageEncoder imageEncoder, IDbContextFactory<JellyfinDbContext> dbProvider, @@ -73,7 +70,6 @@ public class TrickplayManager : ITrickplayManager _mediaEncoder = mediaEncoder; _fileSystem = fileSystem; _encodingHelper = encodingHelper; - _libraryManager = libraryManager; _config = config; _imageEncoder = imageEncoder; _dbProvider = dbProvider; @@ -82,10 +78,10 @@ public class TrickplayManager : ITrickplayManager } /// <inheritdoc /> - public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken) + public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions libraryOptions, CancellationToken cancellationToken) { var options = _config.Configuration.TrickplayOptions; - if (!CanGenerateTrickplay(video, options.Interval)) + if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction || !CanGenerateTrickplay(video, options.Interval)) { return; } @@ -97,28 +93,28 @@ public class TrickplayManager : ITrickplayManager var existingResolution = resolution.Key; var tileWidth = resolution.Value.TileWidth; var tileHeight = resolution.Value.TileHeight; - var shouldBeSavedWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia; - var localOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false); - var mediaOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true); - if (shouldBeSavedWithMedia && Directory.Exists(localOutputDir)) + 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 = Directory.GetFiles(localOutputDir); - var mediaDirExists = Directory.Exists(mediaOutputDir); - if (localDirFiles.Length > 0 && ((mediaDirExists && Directory.GetFiles(mediaOutputDir).Length == 0) || !mediaDirExists)) + 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, mediaOutputDir); + MoveContent(localOutputDir.FullName, mediaOutputDir.FullName); _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutputDir); } } - else if (!shouldBeSavedWithMedia && Directory.Exists(mediaOutputDir)) + else if (!shouldBeSavedWithMedia && mediaOutputDir.Exists) { - var mediaDirFiles = Directory.GetFiles(mediaOutputDir); - var localDirExists = Directory.Exists(localOutputDir); - if (mediaDirFiles.Length > 0 && ((localDirExists && Directory.GetFiles(localOutputDir).Length == 0) || !localDirExists)) + 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, localOutputDir); + MoveContent(mediaOutputDir.FullName, localOutputDir.FullName); _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutputDir); } } @@ -131,36 +127,98 @@ public class TrickplayManager : ITrickplayManager var parent = Directory.GetParent(sourceFolder); if (parent is not null) { - var parentContent = Directory.GetDirectories(parent.FullName); - if (parentContent.Length == 0) + var parentContent = parent.EnumerateDirectories(); + if (!parentContent.Any()) { - Directory.Delete(parent.FullName); + parent.Delete(); } } } /// <inheritdoc /> - public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken) + public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken) { - _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace); - var options = _config.Configuration.TrickplayOptions; - if (options.Interval < 1000) + if (!CanGenerateTrickplay(video, options.Interval) || libraryOptions is null) { - _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value of 1000", options.Interval); - options.Interval = 1000; + return; } - foreach (var width in options.WidthResolutions) + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - cancellationToken.ThrowIfCancellationRequested(); - await RefreshTrickplayDataInternal( - video, - replace, - width, - options, - libraryOptions, - cancellationToken).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); + } + } + } } } @@ -169,14 +227,9 @@ public class TrickplayManager : ITrickplayManager bool replace, int width, TrickplayOptions options, - LibraryOptions? libraryOptions, + bool saveWithMedia, CancellationToken cancellationToken) { - if (!CanGenerateTrickplay(video, options.Interval)) - { - return; - } - var imgTempDir = string.Empty; using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false)) @@ -220,13 +273,12 @@ public class TrickplayManager : ITrickplayManager var tileWidth = options.TileWidth; var tileHeight = options.TileHeight; - var saveWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia; - var outputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia); + var outputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia)); // Import existing trickplay tiles - if (!replace && Directory.Exists(outputDir)) + if (!replace && outputDir.Exists) { - var existingFiles = Directory.GetFiles(outputDir); + var existingFiles = outputDir.GetFiles(); if (existingFiles.Length > 0) { var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureAwait(false); @@ -251,9 +303,9 @@ public class TrickplayManager : ITrickplayManager foreach (var tile in existingFiles) { - var image = _imageEncoder.GetImageSize(tile); + 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)new FileInfo(tile).Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000)); + var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000)); localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate); } @@ -296,7 +348,7 @@ public class TrickplayManager : ITrickplayManager .ToList(); // Create tiles - var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir); + var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir.FullName); // Save tiles info try @@ -319,7 +371,7 @@ public class TrickplayManager : ITrickplayManager // Make sure no files stay in metadata folders on failure // if tiles info wasn't saved. - Directory.Delete(outputDir, true); + outputDir.Delete(true); } } catch (Exception ex) @@ -435,12 +487,6 @@ public class TrickplayManager : ITrickplayManager return false; } - var libraryOptions = _libraryManager.GetLibraryOptions(video); - if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction) - { - return false; - } - // Can't extract images if there are no video streams return video.GetMediaStreams().Count > 0; } @@ -507,6 +553,13 @@ public class TrickplayManager : ITrickplayManager } /// <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>>(); diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 3c39e5503..3dfb14d71 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -342,7 +342,8 @@ namespace Jellyfin.Server.Implementations.Users }, Policy = new UserPolicy { - MaxParentalRating = user.MaxParentalAgeRating, + MaxParentalRating = user.MaxParentalRatingScore, + MaxParentalSubRating = user.MaxParentalRatingSubScore, EnableUserPreferenceAccess = user.EnableUserPreferenceAccess, RemoteClientBitrateLimit = user.RemoteClientBitrateLimit ?? 0, AuthenticationProviderId = user.AuthenticationProviderId, @@ -668,7 +669,8 @@ namespace Jellyfin.Server.Implementations.Users _ => policy.LoginAttemptsBeforeLockout }; - user.MaxParentalAgeRating = policy.MaxParentalRating; + user.MaxParentalRatingScore = policy.MaxParentalRating; + user.MaxParentalRatingSubScore = policy.MaxParentalSubRating; user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; user.AuthenticationProviderId = policy.AuthenticationProviderId; |
