diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 4d959905d9..5bb75e2b95 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -505,6 +505,7 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 51291ec62c..073b2a32d1 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1421,6 +1421,7 @@ public class DynamicHlsController : BaseJellyfinApiController .ConfigureAwait(false); var request = new CreateMainPlaylistRequest( + Guid.Parse(state.BaseRequest.MediaSourceId), state.MediaPath, state.SegmentLength * 1000, state.RunTimeTicks ?? 0, diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 08c024f437..c7cd54ed18 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -114,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(); diff --git a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs new file mode 100644 index 0000000000..a2267700fb --- /dev/null +++ b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs @@ -0,0 +1,64 @@ +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; + +/// +/// Repository for obtaining Keyframe data. +/// +public class KeyframeRepository : IKeyframeRepository +{ + private readonly IDbContextFactory _dbProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The EFCore db factory. + public KeyframeRepository(IDbContextFactory 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() + }; + } + + /// + public IReadOnlyList GetKeyframeData(Guid itemId) + { + using var context = _dbProvider.CreateDbContext(); + + return context.KeyframeData.AsNoTracking().Where(e => e.ItemId.Equals(itemId)).Select(e => Map(e)).ToList(); + } + + /// + 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); + } +} diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 68a3491b57..baeea2c14a 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -56,6 +56,7 @@ namespace Jellyfin.Server.Migrations typeof(Routines.MigrateLibraryDb), typeof(Routines.MigrateRatingLevels), typeof(Routines.MoveTrickplayFiles), + typeof(Routines.MigrateKeyframeData), }; /// diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs new file mode 100644 index 0000000000..bbc83099d6 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Migration to move extracted files to the new directories. +/// +public class MigrateKeyframeData : IDatabaseMigrationRoutine +{ + private readonly ILibraryManager _libraryManager; + private readonly ILogger _logger; + private readonly IApplicationPaths _appPaths; + private readonly IDbContextFactory _dbProvider; + private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// The logger. + /// Instance of the interface. + /// The EFCore db factory. + public MigrateKeyframeData( + ILibraryManager libraryManager, + ILogger logger, + IApplicationPaths appPaths, + IDbContextFactory dbProvider) + { + _libraryManager = libraryManager; + _logger = logger; + _appPaths = appPaths; + _dbProvider = dbProvider; + } + + private string KeyframeCachePath => Path.Combine(_appPaths.DataPath, "keyframes"); + + /// + public Guid Id => new("EA4bCAE1-09A4-428E-9B90-4B4FD2EA1B24"); + + /// + public string Name => "MigrateKeyframeData"; + + /// + public bool PerformOnNewInstall => false; + + /// + public void Perform() + { + const int Limit = 100; + int itemCount = 0, offset = 0, previousCount; + + var sw = Stopwatch.StartNew(); + var itemsQuery = new InternalItemsQuery + { + MediaTypes = [MediaType.Video], + SourceTypes = [SourceType.Library], + IsVirtualItem = false, + IsFolder = false + }; + + using var context = _dbProvider.CreateDbContext(); + using var transaction = context.Database.BeginTransaction(); + List keyframes = []; + + do + { + var result = _libraryManager.GetItemsResult(itemsQuery); + _logger.LogInformation("Importing keyframes for {Count} items", result.TotalRecordCount); + + var items = result.Items; + previousCount = items.Count; + offset += Limit; + foreach (var item in items) + { + if (TryGetKeyframeData(item, out var data)) + { + keyframes.Add(data); + } + + if (++itemCount % 10_000 == 0) + { + context.KeyframeData.AddRange(keyframes); + keyframes.Clear(); + _logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed); + } + } + } while (previousCount == Limit); + + context.KeyframeData.AddRange(keyframes); + context.SaveChanges(); + transaction.Commit(); + + _logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed); + + Directory.Delete(KeyframeCachePath, true); + } + + private bool TryGetKeyframeData(BaseItem item, [NotNullWhen(true)] out KeyframeData? data) + { + data = null; + var path = item.Path; + if (!string.IsNullOrEmpty(path)) + { + var cachePath = GetCachePath(KeyframeCachePath, path); + if (TryReadFromCache(cachePath, out var keyframeData)) + { + data = new() + { + ItemId = item.Id, + KeyframeTicks = keyframeData.KeyframeTicks.ToList(), + TotalDuration = keyframeData.TotalDuration + }; + + return true; + } + } + + return false; + } + + private string? GetCachePath(string keyframeCachePath, string filePath) + { + DateTime? lastWriteTimeUtc; + try + { + lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); + } + catch (IOException e) + { + _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message); + + return null; + } + + ReadOnlySpan filename = (filePath + "_" + lastWriteTimeUtc.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5() + ".json"; + var prefix = filename[..1]; + + return Path.Join(keyframeCachePath, prefix, filename); + } + + private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult) + { + if (File.Exists(cachePath)) + { + var bytes = File.ReadAllBytes(cachePath); + cachedResult = JsonSerializer.Deserialize(bytes, _jsonOptions); + + return cachedResult is not null; + } + + cachedResult = null; + + return false; + } +} diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index d8aaf5ba01..3353ad63f1 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -28,6 +28,7 @@ + diff --git a/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs b/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs new file mode 100644 index 0000000000..4930434a7f --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.MediaEncoding.Keyframes; + +namespace MediaBrowser.Controller.Persistence; + +/// +/// Provides methods for accessing keyframe data. +/// +public interface IKeyframeRepository +{ + /// + /// Gets the keyframe data. + /// + /// The item id. + /// The keyframe data. + IReadOnlyList GetKeyframeData(Guid itemId); + + /// + /// Saves the keyframe data. + /// + /// The item id. + /// The keyframe data. + /// The cancellation token. + /// The task object representing the asynchronous operation. + Task SaveKeyframeDataAsync(Guid itemId, KeyframeData data, CancellationToken cancellationToken); +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/KeyframeData.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/KeyframeData.cs new file mode 100644 index 0000000000..c34110c4f2 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/KeyframeData.cs @@ -0,0 +1,32 @@ +#pragma warning disable CA2227 // Collection properties should be read only + +using System; +using System.Collections.Generic; + +namespace Jellyfin.Database.Implementations.Entities; + +/// +/// Keyframe information for a specific file. +/// +public class KeyframeData +{ + /// + /// Gets or Sets the ItemId. + /// + public required Guid ItemId { get; set; } + + /// + /// Gets or sets the total duration of the stream in ticks. + /// + public long TotalDuration { get; set; } + + /// + /// Gets or sets the keyframes in ticks. + /// + public ICollection? KeyframeTicks { get; set; } + + /// + /// Gets or sets the item reference. + /// + public BaseItemEntity? Item { get; set; } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs index 9db70263d2..35ad461ec7 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs @@ -157,6 +157,11 @@ public class JellyfinDbContext(DbContextOptions options, ILog /// public DbSet BaseItemTrailerTypes => Set(); + /// + /// Gets the . + /// + public DbSet KeyframeData => Set(); + /*public DbSet Artwork => Set(); public DbSet Books => Set(); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/KeyframeDataConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/KeyframeDataConfiguration.cs new file mode 100644 index 0000000000..3f5d458ca2 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/KeyframeDataConfiguration.cs @@ -0,0 +1,18 @@ +using Jellyfin.Database.Implementations.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Database.Implementations.ModelConfiguration; + +/// +/// KeyframeData Configuration. +/// +public class KeyframeDataConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(e => e.ItemId); + builder.HasOne(e => e.Item).WithMany().HasForeignKey(e => e.ItemId); + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs new file mode 100644 index 0000000000..434ea820af --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.Designer.cs @@ -0,0 +1,1681 @@ +// +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20250327101120_AddKeyframeData")] + partial class AddKeyframeData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraIds") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ProviderValue", "ItemId"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamIndex"); + + b.HasIndex("StreamType"); + + b.HasIndex("StreamIndex", "StreamType"); + + b.HasIndex("StreamIndex", "StreamType", "Language"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("UserId"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Children") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("ParentAncestors") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("ParentAncestors"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs new file mode 100644 index 0000000000..c17b35b404 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250327101120_AddKeyframeData.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// + public partial class AddKeyframeData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "KeyframeData", + columns: table => new + { + ItemId = table.Column(type: "TEXT", nullable: false), + TotalDuration = table.Column(type: "INTEGER", nullable: false), + KeyframeTicks = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_KeyframeData", x => x.ItemId); + table.ForeignKey( + name: "FK_KeyframeData_BaseItems_ItemId", + column: x => x.ItemId, + principalTable: "BaseItems", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "KeyframeData"); + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs index 8b2b269345..0d570a6901 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -748,6 +748,24 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasAnnotation("Sqlite:UseSqlReturningClause", false); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => { b.Property("Id") @@ -1519,6 +1537,17 @@ namespace Jellyfin.Server.Implementations.Migrations b.Navigation("ItemValue"); }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => { b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") diff --git a/src/Jellyfin.MediaEncoding.Hls/Cache/CacheDecorator.cs b/src/Jellyfin.MediaEncoding.Hls/Cache/CacheDecorator.cs index 127f4079c5..8ca0e869a5 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Cache/CacheDecorator.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Cache/CacheDecorator.cs @@ -1,13 +1,12 @@ +#pragma warning disable CA1826 // Do not use Enumerable methods on indexable collections + using System; using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.IO; -using System.Text.Json; -using Jellyfin.Extensions.Json; +using System.Linq; +using System.Threading; using Jellyfin.MediaEncoding.Hls.Extractors; using Jellyfin.MediaEncoding.Keyframes; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Persistence; using Microsoft.Extensions.Logging; namespace Jellyfin.MediaEncoding.Hls.Cache; @@ -15,82 +14,48 @@ namespace Jellyfin.MediaEncoding.Hls.Cache; /// public class CacheDecorator : IKeyframeExtractor { + private readonly IKeyframeRepository _keyframeRepository; private readonly IKeyframeExtractor _keyframeExtractor; private readonly ILogger _logger; private readonly string _keyframeExtractorName; - private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private readonly string _keyframeCachePath; /// /// Initializes a new instance of the class. /// - /// An instance of the interface. + /// An instance of the interface. /// An instance of the interface. /// An instance of the interface. - public CacheDecorator(IApplicationPaths applicationPaths, IKeyframeExtractor keyframeExtractor, ILogger logger) + public CacheDecorator(IKeyframeRepository keyframeRepository, IKeyframeExtractor keyframeExtractor, ILogger logger) { - ArgumentNullException.ThrowIfNull(applicationPaths); + ArgumentNullException.ThrowIfNull(keyframeRepository); ArgumentNullException.ThrowIfNull(keyframeExtractor); + _keyframeRepository = keyframeRepository; _keyframeExtractor = keyframeExtractor; _logger = logger; _keyframeExtractorName = keyframeExtractor.GetType().Name; - // TODO make the dir configurable - _keyframeCachePath = Path.Combine(applicationPaths.DataPath, "keyframes"); } /// public bool IsMetadataBased => _keyframeExtractor.IsMetadataBased; /// - public bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) + public bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) { - keyframeData = null; - var cachePath = GetCachePath(_keyframeCachePath, filePath); - if (TryReadFromCache(cachePath, out var cachedResult)) + keyframeData = _keyframeRepository.GetKeyframeData(itemId).FirstOrDefault(); + if (keyframeData is null) { - keyframeData = cachedResult; - return true; + if (!_keyframeExtractor.TryExtractKeyframes(itemId, filePath, out var result)) + { + _logger.LogDebug("Failed to extract keyframes using {ExtractorName}", _keyframeExtractorName); + return false; + } + + _logger.LogDebug("Successfully extracted keyframes using {ExtractorName}", _keyframeExtractorName); + keyframeData = result; + _keyframeRepository.SaveKeyframeDataAsync(itemId, keyframeData, CancellationToken.None).GetAwaiter().GetResult(); } - if (!_keyframeExtractor.TryExtractKeyframes(filePath, out var result)) - { - _logger.LogDebug("Failed to extract keyframes using {ExtractorName}", _keyframeExtractorName); - return false; - } - - _logger.LogDebug("Successfully extracted keyframes using {ExtractorName}", _keyframeExtractorName); - keyframeData = result; - SaveToCache(cachePath, keyframeData); return true; } - - private static void SaveToCache(string cachePath, KeyframeData keyframeData) - { - var json = JsonSerializer.Serialize(keyframeData, _jsonOptions); - Directory.CreateDirectory(Path.GetDirectoryName(cachePath) ?? throw new ArgumentException($"Provided path ({cachePath}) is not valid.", nameof(cachePath))); - File.WriteAllText(cachePath, json); - } - - private static string GetCachePath(string keyframeCachePath, string filePath) - { - var lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); - ReadOnlySpan filename = (filePath + "_" + lastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5() + ".json"; - var prefix = filename[..1]; - - return Path.Join(keyframeCachePath, prefix, filename); - } - - private static bool TryReadFromCache(string cachePath, [NotNullWhen(true)] out KeyframeData? cachedResult) - { - if (File.Exists(cachePath)) - { - var bytes = File.ReadAllBytes(cachePath); - cachedResult = JsonSerializer.Deserialize(bytes, _jsonOptions); - return cachedResult is not null; - } - - cachedResult = null; - return false; - } } diff --git a/src/Jellyfin.MediaEncoding.Hls/Extractors/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Hls/Extractors/FfProbeKeyframeExtractor.cs index a8daeeb787..a69746fe0d 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Extractors/FfProbeKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Extractors/FfProbeKeyframeExtractor.cs @@ -34,7 +34,7 @@ public class FfProbeKeyframeExtractor : IKeyframeExtractor public bool IsMetadataBased => false; /// - public bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) + public bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) { if (!_namingOptions.VideoFileExtensions.Contains(Path.GetExtension(filePath.AsSpan()), StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Jellyfin.MediaEncoding.Hls/Extractors/IKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Hls/Extractors/IKeyframeExtractor.cs index 083e93de1a..84bccbc721 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Extractors/IKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Extractors/IKeyframeExtractor.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics.CodeAnalysis; using Jellyfin.MediaEncoding.Keyframes; @@ -16,8 +17,9 @@ public interface IKeyframeExtractor /// /// Attempt to extract keyframes. /// + /// The item id. /// The path to the file. /// The keyframes. /// A value indicating whether the keyframe extraction was successful. - bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData); + bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData); } diff --git a/src/Jellyfin.MediaEncoding.Hls/Extractors/MatroskaKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Hls/Extractors/MatroskaKeyframeExtractor.cs index 1100f8cd5d..c7758e9193 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Extractors/MatroskaKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Extractors/MatroskaKeyframeExtractor.cs @@ -24,7 +24,7 @@ public class MatroskaKeyframeExtractor : IKeyframeExtractor public bool IsMetadataBased => true; /// - public bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) + public bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) { if (!filePath.AsSpan().EndsWith(".mkv", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj index dc581724a5..80b5aa84e4 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj +++ b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Jellyfin.MediaEncoding.Hls/Playlist/CreateMainPlaylistRequest.cs b/src/Jellyfin.MediaEncoding.Hls/Playlist/CreateMainPlaylistRequest.cs index 21d9bb6588..ac9d30b33a 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Playlist/CreateMainPlaylistRequest.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Playlist/CreateMainPlaylistRequest.cs @@ -1,3 +1,5 @@ +using System; + namespace Jellyfin.MediaEncoding.Hls.Playlist; /// @@ -8,6 +10,7 @@ public class CreateMainPlaylistRequest /// /// Initializes a new instance of the class. /// + /// The media source id. /// The absolute file path to the file. /// The desired segment length in milliseconds. /// The total duration of the file in ticks. @@ -15,8 +18,9 @@ public class CreateMainPlaylistRequest /// The URI prefix for the relative URL in the playlist. /// The desired query string to append (must start with ?). /// Whether the video is being remuxed. - public CreateMainPlaylistRequest(string filePath, int desiredSegmentLengthMs, long totalRuntimeTicks, string segmentContainer, string endpointPrefix, string queryString, bool isRemuxingVideo) + public CreateMainPlaylistRequest(Guid mediaSourceId, string filePath, int desiredSegmentLengthMs, long totalRuntimeTicks, string segmentContainer, string endpointPrefix, string queryString, bool isRemuxingVideo) { + MediaSourceId = mediaSourceId; FilePath = filePath; DesiredSegmentLengthMs = desiredSegmentLengthMs; TotalRuntimeTicks = totalRuntimeTicks; @@ -26,6 +30,11 @@ public class CreateMainPlaylistRequest IsRemuxingVideo = isRemuxingVideo; } + /// + /// Gets the media source id. + /// + public Guid MediaSourceId { get; } + /// /// Gets the file path. /// diff --git a/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs b/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs index 1846ba26bf..343f3e562d 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs +++ b/src/Jellyfin.MediaEncoding.Hls/Playlist/DynamicHlsPlaylistGenerator.cs @@ -35,7 +35,7 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator { IReadOnlyList segments; // For video transcodes it is sufficient with equal length segments as ffmpeg will create new keyframes - if (request.IsRemuxingVideo && TryExtractKeyframes(request.FilePath, out var keyframeData)) + if (request.IsRemuxingVideo && TryExtractKeyframes(request.MediaSourceId, request.FilePath, out var keyframeData)) { segments = ComputeSegments(keyframeData, request.DesiredSegmentLengthMs); } @@ -104,7 +104,7 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator return builder.ToString(); } - private bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) + private bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData) { keyframeData = null; if (!IsExtractionAllowedForFile(filePath, _serverConfigurationManager.GetEncodingOptions().AllowOnDemandMetadataBasedKeyframeExtractionForExtensions)) @@ -116,7 +116,7 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator for (var i = 0; i < len; i++) { var extractor = _extractors[i]; - if (!extractor.TryExtractKeyframes(filePath, out var result)) + if (!extractor.TryExtractKeyframes(itemId, filePath, out var result)) { continue; } diff --git a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs index caf6a2aae3..d63ee67774 100644 --- a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs +++ b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs @@ -9,7 +9,6 @@ using Jellyfin.MediaEncoding.Hls.Extractors; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.Tasks; @@ -23,7 +22,7 @@ public class KeyframeExtractionScheduledTask : IScheduledTask private readonly ILocalizationManager _localizationManager; private readonly ILibraryManager _libraryManager; private readonly IKeyframeExtractor[] _keyframeExtractors; - private static readonly BaseItemKind[] _itemTypes = { BaseItemKind.Episode, BaseItemKind.Movie }; + private static readonly BaseItemKind[] _itemTypes = [BaseItemKind.Episode, BaseItemKind.Movie]; /// /// Initializes a new instance of the class. @@ -55,11 +54,11 @@ public class KeyframeExtractionScheduledTask : IScheduledTask { var query = new InternalItemsQuery { - MediaTypes = new[] { MediaType.Video }, + MediaTypes = [MediaType.Video], IsVirtualItem = false, IncludeItemTypes = _itemTypes, DtoOptions = new DtoOptions(true), - SourceTypes = new[] { SourceType.Library }, + SourceTypes = [SourceType.Library], Recursive = true, Limit = Pagesize }; @@ -74,19 +73,16 @@ public class KeyframeExtractionScheduledTask : IScheduledTask query.StartIndex = startIndex; var videos = _libraryManager.GetItemList(query); - var currentPageCount = videos.Count; - // TODO parallelize with Parallel.ForEach? - for (var i = 0; i < currentPageCount; i++) + foreach (var video in videos) { - var video = videos[i]; // Only local files supported - if (video.IsFileProtocol && File.Exists(video.Path)) + var path = video.Path; + if (File.Exists(path)) { - for (var j = 0; j < _keyframeExtractors.Length; j++) + foreach (var extractor in _keyframeExtractors) { - var extractor = _keyframeExtractors[j]; - // The cache decorator will make sure to save them in the data dir - if (extractor.TryExtractKeyframes(video.Path, out _)) + // The cache decorator will make sure to save the keyframes + if (extractor.TryExtractKeyframes(video.Id, path, out _)) { break; } @@ -107,5 +103,5 @@ public class KeyframeExtractionScheduledTask : IScheduledTask } /// - public IEnumerable GetDefaultTriggers() => Enumerable.Empty(); + public IEnumerable GetDefaultTriggers() => []; }