mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-05-24 02:02:29 -04:00
Import Keyframes into database (#13771)
* Migrate keyframe data into database * Clear database table before import to handle failed migrations
This commit is contained in:
parent
49ac705867
commit
0573999d5e
@ -505,6 +505,7 @@ namespace Emby.Server.Implementations
|
||||
serviceCollection.AddSingleton<IChapterRepository, ChapterRepository>();
|
||||
serviceCollection.AddSingleton<IMediaAttachmentRepository, MediaAttachmentRepository>();
|
||||
serviceCollection.AddSingleton<IMediaStreamRepository, MediaStreamRepository>();
|
||||
serviceCollection.AddSingleton<IKeyframeRepository, KeyframeRepository>();
|
||||
serviceCollection.AddSingleton<IItemTypeLookup, ItemTypeLookup>();
|
||||
|
||||
serviceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>();
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
64
Jellyfin.Server.Implementations/Item/KeyframeRepository.cs
Normal file
64
Jellyfin.Server.Implementations/Item/KeyframeRepository.cs
Normal file
@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
@ -56,6 +56,7 @@ namespace Jellyfin.Server.Migrations
|
||||
typeof(Routines.MigrateLibraryDb),
|
||||
typeof(Routines.MigrateRatingLevels),
|
||||
typeof(Routines.MoveTrickplayFiles),
|
||||
typeof(Routines.MigrateKeyframeData),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
173
Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
Normal file
173
Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
Normal file
@ -0,0 +1,173 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to move extracted files to the new directories.
|
||||
/// </summary>
|
||||
public class MigrateKeyframeData : IDatabaseMigrationRoutine
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<MoveTrickplayFiles> _logger;
|
||||
private readonly IApplicationPaths _appPaths;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MigrateKeyframeData"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="dbProvider">The EFCore db factory.</param>
|
||||
public MigrateKeyframeData(
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<MoveTrickplayFiles> logger,
|
||||
IApplicationPaths appPaths,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_appPaths = appPaths;
|
||||
_dbProvider = dbProvider;
|
||||
}
|
||||
|
||||
private string KeyframeCachePath => Path.Combine(_appPaths.DataPath, "keyframes");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid Id => new("EA4bCAE1-09A4-428E-9B90-4B4FD2EA1B24");
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "MigrateKeyframeData";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool PerformOnNewInstall => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
context.KeyframeData.ExecuteDelete();
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
List<KeyframeData> 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<char> 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<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
|
||||
|
||||
return cachedResult is not null;
|
||||
}
|
||||
|
||||
cachedResult = null;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
@ -28,6 +28,7 @@
|
||||
<ProjectReference Include="../Emby.Naming/Emby.Naming.csproj" />
|
||||
<ProjectReference Include="../MediaBrowser.Model/MediaBrowser.Model.csproj" />
|
||||
<ProjectReference Include="../MediaBrowser.Common/MediaBrowser.Common.csproj" />
|
||||
<ProjectReference Include="../src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
29
MediaBrowser.Controller/Persistence/IKeyframeRepository.cs
Normal file
29
MediaBrowser.Controller/Persistence/IKeyframeRepository.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for accessing keyframe data.
|
||||
/// </summary>
|
||||
public interface IKeyframeRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the keyframe data.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <returns>The keyframe data.</returns>
|
||||
IReadOnlyList<KeyframeData> GetKeyframeData(Guid itemId);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the keyframe data.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="data">The keyframe data.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The task object representing the asynchronous operation.</returns>
|
||||
Task SaveKeyframeDataAsync(Guid itemId, KeyframeData data, CancellationToken cancellationToken);
|
||||
}
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Keyframe information for a specific file.
|
||||
/// </summary>
|
||||
public class KeyframeData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or Sets the ItemId.
|
||||
/// </summary>
|
||||
public required Guid ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total duration of the stream in ticks.
|
||||
/// </summary>
|
||||
public long TotalDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the keyframes in ticks.
|
||||
/// </summary>
|
||||
public ICollection<long>? KeyframeTicks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item reference.
|
||||
/// </summary>
|
||||
public BaseItemEntity? Item { get; set; }
|
||||
}
|
@ -157,6 +157,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
|
||||
/// </summary>
|
||||
public DbSet<BaseItemTrailerType> BaseItemTrailerTypes => Set<BaseItemTrailerType>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="DbSet{TEntity}"/>.
|
||||
/// </summary>
|
||||
public DbSet<KeyframeData> KeyframeData => Set<KeyframeData>();
|
||||
|
||||
/*public DbSet<Artwork> Artwork => Set<Artwork>();
|
||||
|
||||
public DbSet<Book> Books => Set<Book>();
|
||||
|
@ -0,0 +1,18 @@
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Jellyfin.Database.Implementations.ModelConfiguration;
|
||||
|
||||
/// <summary>
|
||||
/// KeyframeData Configuration.
|
||||
/// </summary>
|
||||
public class KeyframeDataConfiguration : IEntityTypeConfiguration<KeyframeData>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public void Configure(EntityTypeBuilder<KeyframeData> builder)
|
||||
{
|
||||
builder.HasKey(e => e.ItemId);
|
||||
builder.HasOne(e => e.Item).WithMany().HasForeignKey(e => e.ItemId);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddKeyframeData : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "KeyframeData",
|
||||
columns: table => new
|
||||
{
|
||||
ItemId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
TotalDuration = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
KeyframeTicks = table.Column<string>(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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "KeyframeData");
|
||||
}
|
||||
}
|
||||
}
|
@ -748,6 +748,24 @@ namespace Jellyfin.Server.Implementations.Migrations
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
|
||||
{
|
||||
b.Property<Guid>("ItemId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.PrimitiveCollection<string>("KeyframeTicks")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("TotalDuration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("ItemId");
|
||||
|
||||
b.ToTable("KeyframeData");
|
||||
|
||||
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -1522,6 +1540,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")
|
||||
|
@ -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,45 +14,38 @@ namespace Jellyfin.MediaEncoding.Hls.Cache;
|
||||
/// <inheritdoc />
|
||||
public class CacheDecorator : IKeyframeExtractor
|
||||
{
|
||||
private readonly IKeyframeRepository _keyframeRepository;
|
||||
private readonly IKeyframeExtractor _keyframeExtractor;
|
||||
private readonly ILogger<CacheDecorator> _logger;
|
||||
private readonly string _keyframeExtractorName;
|
||||
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
private readonly string _keyframeCachePath;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CacheDecorator"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">An instance of the <see cref="IApplicationPaths"/> interface.</param>
|
||||
/// <param name="keyframeRepository">An instance of the <see cref="IKeyframeRepository"/> interface.</param>
|
||||
/// <param name="keyframeExtractor">An instance of the <see cref="IKeyframeExtractor"/> interface.</param>
|
||||
/// <param name="logger">An instance of the <see cref="ILogger{CacheDecorator}"/> interface.</param>
|
||||
public CacheDecorator(IApplicationPaths applicationPaths, IKeyframeExtractor keyframeExtractor, ILogger<CacheDecorator> logger)
|
||||
public CacheDecorator(IKeyframeRepository keyframeRepository, IKeyframeExtractor keyframeExtractor, ILogger<CacheDecorator> 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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsMetadataBased => _keyframeExtractor.IsMetadataBased;
|
||||
|
||||
/// <inheritdoc />
|
||||
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(filePath, out var result))
|
||||
if (!_keyframeExtractor.TryExtractKeyframes(itemId, filePath, out var result))
|
||||
{
|
||||
_logger.LogDebug("Failed to extract keyframes using {ExtractorName}", _keyframeExtractorName);
|
||||
return false;
|
||||
@ -61,36 +53,9 @@ public class CacheDecorator : IKeyframeExtractor
|
||||
|
||||
_logger.LogDebug("Successfully extracted keyframes using {ExtractorName}", _keyframeExtractorName);
|
||||
keyframeData = result;
|
||||
SaveToCache(cachePath, keyframeData);
|
||||
_keyframeRepository.SaveKeyframeDataAsync(itemId, keyframeData, CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
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<char> 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<KeyframeData>(bytes, _jsonOptions);
|
||||
return cachedResult is not null;
|
||||
}
|
||||
|
||||
cachedResult = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ public class FfProbeKeyframeExtractor : IKeyframeExtractor
|
||||
public bool IsMetadataBased => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
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))
|
||||
{
|
||||
|
@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Jellyfin.MediaEncoding.Keyframes;
|
||||
|
||||
@ -16,8 +17,9 @@ public interface IKeyframeExtractor
|
||||
/// <summary>
|
||||
/// Attempt to extract keyframes.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item id.</param>
|
||||
/// <param name="filePath">The path to the file.</param>
|
||||
/// <param name="keyframeData">The keyframes.</param>
|
||||
/// <returns>A value indicating whether the keyframe extraction was successful.</returns>
|
||||
bool TryExtractKeyframes(string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData);
|
||||
bool TryExtractKeyframes(Guid itemId, string filePath, [NotNullWhen(true)] out KeyframeData? keyframeData);
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ public class MatroskaKeyframeExtractor : IKeyframeExtractor
|
||||
public bool IsMetadataBased => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
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))
|
||||
{
|
||||
|
@ -7,6 +7,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../MediaBrowser.Common/MediaBrowser.Common.csproj" />
|
||||
<ProjectReference Include="../../MediaBrowser.Model/MediaBrowser.Model.csproj" />
|
||||
<ProjectReference Include="../../MediaBrowser.Controller/MediaBrowser.Controller.csproj" />
|
||||
<ProjectReference Include="../Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj" />
|
||||
</ItemGroup>
|
||||
|
@ -1,3 +1,5 @@
|
||||
using System;
|
||||
|
||||
namespace Jellyfin.MediaEncoding.Hls.Playlist;
|
||||
|
||||
/// <summary>
|
||||
@ -8,6 +10,7 @@ public class CreateMainPlaylistRequest
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CreateMainPlaylistRequest"/> class.
|
||||
/// </summary>
|
||||
/// <param name="mediaSourceId">The media source id.</param>
|
||||
/// <param name="filePath">The absolute file path to the file.</param>
|
||||
/// <param name="desiredSegmentLengthMs">The desired segment length in milliseconds.</param>
|
||||
/// <param name="totalRuntimeTicks">The total duration of the file in ticks.</param>
|
||||
@ -15,8 +18,9 @@ public class CreateMainPlaylistRequest
|
||||
/// <param name="endpointPrefix">The URI prefix for the relative URL in the playlist.</param>
|
||||
/// <param name="queryString">The desired query string to append (must start with ?).</param>
|
||||
/// <param name="isRemuxingVideo">Whether the video is being remuxed.</param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the media source id.
|
||||
/// </summary>
|
||||
public Guid MediaSourceId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file path.
|
||||
/// </summary>
|
||||
|
@ -35,7 +35,7 @@ public class DynamicHlsPlaylistGenerator : IDynamicHlsPlaylistGenerator
|
||||
{
|
||||
IReadOnlyList<double> 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;
|
||||
}
|
||||
|
@ -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];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="KeyframeExtractionScheduledTask"/> 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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => Enumerable.Empty<TaskTriggerInfo>();
|
||||
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => [];
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user