diff options
Diffstat (limited to 'Jellyfin.Server.Implementations')
4 files changed, 152 insertions, 39 deletions
diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs index 63c80634f..932f9d625 100644 --- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -139,7 +139,7 @@ public static class ServiceCollectionExtensions serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) => { var provider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>(); - provider.Initialise(opt); + provider.Initialise(opt, efCoreConfiguration); var lockingBehavior = serviceProvider.GetRequiredService<IEntityFrameworkCoreLockingBehavior>(); lockingBehavior.Initialise(opt); }); diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs index d439fcb18..74d99455d 100644 --- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -39,7 +39,7 @@ public class BackupService : IBackupService ReferenceHandler = ReferenceHandler.IgnoreCycles, }; - private readonly Version _backupEngineVersion = Version.Parse("0.1.0"); + private readonly Version _backupEngineVersion = Version.Parse("0.2.0"); /// <summary> /// Initializes a new instance of the <see cref="BackupService"/> class. @@ -120,26 +120,29 @@ public class BackupService : IBackupService void CopyDirectory(string source, string target) { - source = Path.GetFullPath(source); - Directory.CreateDirectory(source); - + var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar); + var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar; foreach (var item in zipArchive.Entries) { - var sanitizedSourcePath = Path.GetFullPath(item.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar); - if (!sanitizedSourcePath.StartsWith(target, StringComparison.Ordinal)) + var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName)); + var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName))); + + if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal) + || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)) { continue; } - var targetPath = Path.Combine(source, sanitizedSourcePath[target.Length..].Trim('/')); _logger.LogInformation("Restore and override {File}", targetPath); - item.ExtractToFile(targetPath); + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + item.ExtractToFile(targetPath, overwrite: true); } } - CopyDirectory(_applicationPaths.ConfigurationDirectoryPath, "Config/"); - CopyDirectory(_applicationPaths.DataPath, "Data/"); - CopyDirectory(_applicationPaths.RootFolderPath, "Root/"); + CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath); + CopyDirectory("Data", _applicationPaths.DataPath); + CopyDirectory("Root", _applicationPaths.RootFolderPath); if (manifest.Options.Database) { @@ -148,7 +151,7 @@ public class BackupService : IBackupService await using (dbContext.ConfigureAwait(false)) { // restore migration history manually - var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json"); + var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json"))); if (historyEntry is null) { _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation"); @@ -165,6 +168,13 @@ public class BackupService : IBackupService var historyRepository = dbContext.GetService<IHistoryRepository>(); await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false); + + foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false)) + { + var insertScript = historyRepository.GetDeleteScript(item.MigrationId); + await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false); + } + foreach (var item in historyEntries) { var insertScript = historyRepository.GetInsertScript(item); @@ -186,7 +196,7 @@ public class BackupService : IBackupService { _logger.LogInformation("Read backup of {Table}", entityType.Type.Name); - var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json"); + var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json"))); if (zipEntry is null) { _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name); @@ -198,7 +208,7 @@ public class BackupService : IBackupService { _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name); var records = 0; - await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false)!) + 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) @@ -281,7 +291,7 @@ public class BackupService : IBackupService await using (dbContext.ConfigureAwait(false)) { dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - static IAsyncEnumerable<object> GetValues(IQueryable dbSet, Type type) + static IAsyncEnumerable<object> GetValues(IQueryable dbSet) { var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!; var enumerable = method.Invoke(dbSet, null)!; @@ -296,8 +306,8 @@ public class BackupService : IBackupService .. typeof(JellyfinDbContext) .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) - .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!, e.PropertyType)))), - (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: new Func<IAsyncEnumerable<object>>(() => migrations.ToAsyncEnumerable())) + .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))), + (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable()) ]; manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray(); var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false); @@ -309,7 +319,7 @@ public class BackupService : IBackupService foreach (var entityType in entityTypes) { _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName); - var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.SourceName}.json"); + var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json"))); var entities = 0; var zipEntryStream = zipEntry.Open(); await using (zipEntryStream.ConfigureAwait(false)) @@ -347,7 +357,7 @@ public class BackupService : IBackupService 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))); + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))); } void CopyDirectory(string source, string target, string filter = "*") @@ -361,7 +371,7 @@ public class BackupService : IBackupService foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) { - zipArchive.CreateEntryFromFile(item, Path.Combine(target, item[..source.Length].Trim('\\'))); + zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item)))); } } @@ -509,4 +519,14 @@ public class BackupService : IBackupService Database = options.Database }; } + + /// <summary> + /// Windows is able to handle '/' as a path seperator in zip files + /// but linux isn't able to handle '\' as a path seperator in zip files, + /// So normalize to '/'. + /// </summary> + /// <param name="path">The path to normalize.</param> + /// <returns>The normalized path. </returns> + private static string NormalizePathSeparator(string path) + => path.Replace('\\', '/'); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 52585c996..cf4f405ee 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -110,6 +110,20 @@ public sealed class BaseItemRepository using var transaction = context.Database.BeginTransaction(); var date = (DateTime?)DateTime.UtcNow; + + // Remove any UserData entries for the placeholder item that would conflict with the UserData + // being detached from the item being deleted. This is necessary because, during an update, + // UserData may be reattached to a new entry, but some entries can be left behind. + // Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder. + context.UserData + .Join( + context.UserData.Where(e => e.ItemId == id), + placeholder => new { placeholder.UserId, placeholder.CustomDataKey }, + userData => new { userData.UserId, userData.CustomDataKey }, + (placeholder, userData) => placeholder) + .Where(e => e.ItemId == PlaceholderId) + .ExecuteDelete(); + // Detach all user watch data context.UserData.Where(e => e.ItemId == id) .ExecuteUpdate(e => e @@ -443,6 +457,66 @@ public sealed class BaseItemRepository return dbQuery.Count(); } + /// <inheritdoc /> + public ItemCounts GetItemCounts(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + // Hack for right now since we currently don't support filtering out these duplicates within a query + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + var dbQuery = TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); + + var counts = dbQuery + .GroupBy(x => x.Type) + .Select(x => new { x.Key, Count = x.Count() }) + .AsEnumerable(); + + var lookup = _itemTypeLookup.BaseItemKindNames; + var result = new ItemCounts(); + foreach (var count in counts) + { + if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) + { + result.AlbumCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) + { + result.ArtistCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) + { + result.EpisodeCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) + { + result.MovieCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) + { + result.MusicVideoCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) + { + result.ProgramCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal)) + { + result.SeriesCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) + { + result.SongCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) + { + result.TrailerCount = count.Count; + } + } + + return result; + } + #pragma warning disable CA1307 // Specify StringComparison for clarity /// <summary> /// Gets the type. @@ -468,6 +542,13 @@ public sealed class BaseItemRepository var images = item.ImageInfos.Select(e => Map(item.Id, e)); using var context = _dbProvider.CreateDbContext(); + + if (!context.BaseItems.Any(bi => bi.Id == item.Id)) + { + _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem"); + return; + } + context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete(); context.BaseItemImageInfos.AddRange(images); context.SaveChanges(); @@ -567,7 +648,7 @@ public sealed class BaseItemRepository var itemValuesStore = existingValues.Concat(missingItemValues).ToArray(); var valueMap = itemValueMaps - .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).ToArray())) + .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray())) .ToArray(); var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList(); @@ -1094,13 +1175,18 @@ public sealed class BaseItemRepository IsSeries = filter.IsSeries }); + var itemValuesQuery = context.ItemValues + .Where(f => itemValueTypes.Contains(f.Type)) + .SelectMany(f => f.BaseItemsMap!, (f, w) => new { f, w }) + .Join( + innerQueryFilter, + fw => fw.w.ItemId, + g => g.Id, + (fw, g) => fw.f.CleanValue); + 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)); + .Where(e => itemValuesQuery.Contains(e.CleanName)); var outerQueryFilter = new InternalItemsQuery(filter.User) { @@ -1881,7 +1967,7 @@ public sealed class BaseItemRepository if (filter.AlbumArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.AlbumArtistIds); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.AlbumArtist, filter.AlbumArtistIds); } if (filter.ContributingArtistIds.Length > 0) @@ -2239,8 +2325,8 @@ public sealed class BaseItemRepository if (filter.ExcludeInheritedTags.Length > 0) { baseQuery = baseQuery - .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags) - .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))); + .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags) + .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))); } if (filter.IncludeInheritedTags.Length > 0) @@ -2250,10 +2336,10 @@ public sealed class BaseItemRepository if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode) { baseQuery = baseQuery - .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags) + .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) || - (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value)!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags) + (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)) .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)))); } @@ -2261,17 +2347,16 @@ public sealed class BaseItemRepository else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist) { baseQuery = baseQuery - .Where(e => - 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}\""))); + .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) + .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) + || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")); // d ^^ this is stupid it hate this. } else { baseQuery = baseQuery - .Where(e => e.Parents!.Any(f => f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)))); + .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) + .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))); } } diff --git a/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs b/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs index 3ae6dbd70..e75dda439 100644 --- a/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs @@ -25,8 +25,16 @@ public class MediaAttachmentRepository(IDbContextFactory<JellyfinDbContext> dbPr { using var context = dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); + + // Users may replace a media with a version that includes attachments to one without them. + // So when saving attachments is triggered by a library scan, we always unconditionally + // clear the old ones, and then add the new ones if given. context.AttachmentStreamInfos.Where(e => e.ItemId.Equals(id)).ExecuteDelete(); - context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id))); + if (attachments.Any()) + { + context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id))); + } + context.SaveChanges(); transaction.Commit(); } |
