Update README.md with project details, features, requirements, architecture, and data management for PLib video library manager.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Infrastructure.Media;
|
||||
using PLib.Infrastructure.Persistence;
|
||||
using PLib.Infrastructure.Storage;
|
||||
|
||||
namespace PLib.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers everything the application layer declares as an abstraction. The composition
|
||||
/// root (the UI project) never sees EF Core or ffmpeg types directly.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPLibInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<LibraryOptions>()
|
||||
.Bind(configuration.GetSection(LibraryOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
// TryAdd so a composition root that already needed the paths (to locate the user
|
||||
// settings file before the container exists) can share its own instance.
|
||||
services.TryAddSingleton<IAppPaths, AppPaths>();
|
||||
|
||||
services.AddDbContext<LibraryDbContext>((provider, builder) =>
|
||||
{
|
||||
var paths = provider.GetRequiredService<IAppPaths>();
|
||||
builder.UseSqlite($"Data Source={paths.DatabaseFile}");
|
||||
});
|
||||
|
||||
services.AddScoped<IVideoRepository, EfVideoRepository>();
|
||||
services.AddSingleton<IVideoFileScanner, FileSystemVideoScanner>();
|
||||
services.AddSingleton<IMediaProbe, FfmpegMediaProbe>();
|
||||
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
|
||||
services.AddScoped<ILibraryService, LibraryService>();
|
||||
|
||||
services.AddHostedService<DatabaseInitializer>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using FFMpegCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Infrastructure.Media;
|
||||
|
||||
/// <inheritdoc cref="IMediaProbe"/>
|
||||
public sealed class FfmpegMediaProbe(ILogger<FfmpegMediaProbe> logger) : IMediaProbe
|
||||
{
|
||||
public async Task<VideoTechnicalInfo> ProbeAsync(string fullPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var analysis = await FFProbe.AnalyseAsync(fullPath, cancellationToken: cancellationToken);
|
||||
var video = analysis.PrimaryVideoStream;
|
||||
|
||||
return new VideoTechnicalInfo(
|
||||
analysis.Duration > TimeSpan.Zero ? analysis.Duration : null,
|
||||
video?.Width,
|
||||
video?.Height,
|
||||
video?.CodecName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A single unreadable file must not abort the scan.
|
||||
logger.LogWarning(ex, "Could not probe {Path}", fullPath);
|
||||
return VideoTechnicalInfo.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FFMpegCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Infrastructure.Storage;
|
||||
|
||||
namespace PLib.Infrastructure.Media;
|
||||
|
||||
/// <inheritdoc cref="IThumbnailGenerator"/>
|
||||
public sealed class FfmpegThumbnailGenerator(
|
||||
IAppPaths paths,
|
||||
IOptions<LibraryOptions> options,
|
||||
ILogger<FfmpegThumbnailGenerator> logger) : IThumbnailGenerator
|
||||
{
|
||||
/// <summary>Fallback capture position for files whose duration we could not read.</summary>
|
||||
private static readonly TimeSpan BlindCapturePosition = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly LibraryOptions _options = options.Value;
|
||||
|
||||
public async Task<string?> GetOrCreateAsync(
|
||||
string videoPath,
|
||||
TimeSpan? duration,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var file = new FileInfo(videoPath);
|
||||
|
||||
if (!file.Exists)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var target = Path.Combine(paths.ThumbnailDirectory, $"{BuildCacheKey(file)}.jpg");
|
||||
|
||||
if (File.Exists(target))
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
// Render to a private temp file first so a crash or cancellation can never leave a
|
||||
// truncated JPEG behind that later runs would happily treat as a valid cache hit.
|
||||
var staging = Path.Combine(paths.ThumbnailDirectory, $"{Guid.CreateVersion7()}.tmp");
|
||||
|
||||
try
|
||||
{
|
||||
var succeeded = await RenderAsync(videoPath, staging, CapturePositionFor(duration), cancellationToken);
|
||||
|
||||
if (!succeeded)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
File.Move(staging, target, overwrite: true);
|
||||
return target;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not create a thumbnail for {Path}", videoPath);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(staging))
|
||||
{
|
||||
File.Delete(staging);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> RenderAsync(
|
||||
string videoPath,
|
||||
string outputPath,
|
||||
TimeSpan capturePosition,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Seeking on the input (rather than the output) makes ffmpeg jump straight to the
|
||||
// keyframe instead of decoding everything before it — orders of magnitude faster.
|
||||
// Height -2 lets ffmpeg keep the aspect ratio while staying encoder friendly.
|
||||
return await FFMpegArguments
|
||||
.FromFileInput(videoPath, verifyExists: true, input => input.Seek(capturePosition))
|
||||
.OutputToFile(outputPath, overwrite: true, output => output
|
||||
.WithVideoFilters(filter => filter.Scale(_options.ThumbnailWidth, -2))
|
||||
.WithFrameOutputCount(1)
|
||||
.WithCustomArgument("-q:v 3")
|
||||
.ForceFormat("image2"))
|
||||
.CancellableThrough(cancellationToken)
|
||||
.ProcessAsynchronously(throwOnError: false);
|
||||
}
|
||||
|
||||
private TimeSpan CapturePositionFor(TimeSpan? duration) =>
|
||||
duration is { } value && value > TimeSpan.Zero
|
||||
? value * _options.ThumbnailPositionRatio
|
||||
: BlindCapturePosition;
|
||||
|
||||
/// <summary>
|
||||
/// Keys the cache by path plus size plus timestamp, so replacing a file on disk
|
||||
/// naturally produces a different key rather than a stale poster frame.
|
||||
/// </summary>
|
||||
private static string BuildCacheKey(FileInfo file)
|
||||
{
|
||||
var seed = $"{file.FullName}|{file.Length}|{file.LastWriteTimeUtc.Ticks}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return Convert.ToHexStringLower(hash)[..32];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
|
||||
namespace PLib.Infrastructure.Media;
|
||||
|
||||
/// <inheritdoc cref="IVideoFileScanner"/>
|
||||
public sealed class FileSystemVideoScanner(
|
||||
IOptions<LibraryOptions> options,
|
||||
ILogger<FileSystemVideoScanner> logger) : IVideoFileScanner
|
||||
{
|
||||
private readonly HashSet<string> _extensions =
|
||||
new(options.Value.VideoExtensions, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public async IAsyncEnumerable<DiscoveredVideoFile> ScanAsync(
|
||||
string rootFolder,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Walking a large tree blocks; hand the caller back its thread before we start.
|
||||
await Task.Yield();
|
||||
|
||||
if (!Directory.Exists(rootFolder))
|
||||
{
|
||||
logger.LogWarning("Library folder {Folder} does not exist and was skipped", rootFolder);
|
||||
yield break;
|
||||
}
|
||||
|
||||
// IgnoreInaccessible keeps a single protected subfolder from aborting the whole walk.
|
||||
var enumerationOptions = new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System,
|
||||
};
|
||||
|
||||
// Enumerating FileInfo (rather than paths) reuses the metadata the OS already
|
||||
// returned for each directory entry, so size and timestamp cost no extra syscall.
|
||||
foreach (var file in new DirectoryInfo(rootFolder).EnumerateFiles("*", enumerationOptions))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_extensions.Contains(file.Extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new DiscoveredVideoFile(file.FullName, file.Length, file.LastWriteTimeUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>PLib.Infrastructure</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FFMpegCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PLib.Application\PLib.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PLib.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Brings the local database up to date before the first window is shown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// While the schema is still moving we create it from the model. Once the shape settles this
|
||||
/// becomes <c>MigrateAsync</c> plus a checked-in migration history.
|
||||
/// </remarks>
|
||||
public sealed class DatabaseInitializer(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DatabaseInitializer> logger) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LibraryDbContext>();
|
||||
|
||||
var created = await dbContext.Database.EnsureCreatedAsync(cancellationToken);
|
||||
logger.LogInformation("Library database ready (created: {Created})", created);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Infrastructure.Persistence;
|
||||
|
||||
/// <inheritdoc cref="IVideoRepository"/>
|
||||
public sealed class EfVideoRepository(LibraryDbContext dbContext) : IVideoRepository
|
||||
{
|
||||
public async Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||
await dbContext.Videos
|
||||
.OrderByDescending(x => x.AddedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||
dbContext.Videos.FirstOrDefaultAsync(x => x.FullPath == fullPath, cancellationToken);
|
||||
|
||||
public async Task AddAsync(VideoItem item, CancellationToken cancellationToken = default) =>
|
||||
await dbContext.Videos.AddAsync(item, cancellationToken);
|
||||
|
||||
public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.Videos.Remove(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
|
||||
dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Infrastructure.Persistence;
|
||||
|
||||
public sealed class LibraryDbContext(DbContextOptions<LibraryDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<VideoItem> Videos => Set<VideoItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(LibraryDbContext).Assembly);
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Infrastructure.Persistence;
|
||||
|
||||
internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoItem>
|
||||
{
|
||||
/// <summary>
|
||||
/// SQLite refuses to ORDER BY a DateTimeOffset because its default TEXT representation
|
||||
/// carries an offset and therefore does not sort chronologically. Storing UTC ticks keeps
|
||||
/// the column both sortable and indexable.
|
||||
/// </summary>
|
||||
private static readonly ValueConverter<DateTimeOffset, long> UtcTicksConverter = new(
|
||||
value => value.UtcTicks,
|
||||
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
|
||||
|
||||
public void Configure(EntityTypeBuilder<VideoItem> builder)
|
||||
{
|
||||
builder.ToTable("Videos");
|
||||
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.AddedAt).HasConversion(UtcTicksConverter);
|
||||
builder.Property(x => x.FileModifiedAt).HasConversion(UtcTicksConverter);
|
||||
|
||||
builder.Property(x => x.FullPath)
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024);
|
||||
|
||||
builder.HasIndex(x => x.FullPath)
|
||||
.IsUnique();
|
||||
|
||||
builder.Property(x => x.Title)
|
||||
.IsRequired()
|
||||
.HasMaxLength(512);
|
||||
|
||||
builder.Property(x => x.VideoCodec)
|
||||
.HasMaxLength(64);
|
||||
|
||||
builder.Property(x => x.ThumbnailPath)
|
||||
.HasMaxLength(1024);
|
||||
|
||||
// Sorting the grid by "recently added" is the default view, so it gets an index.
|
||||
builder.HasIndex(x => x.AddedAt);
|
||||
|
||||
// IsIndexed is derived from other columns and must not become a table column.
|
||||
builder.Ignore(x => x.IsIndexed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace PLib.Infrastructure.Storage;
|
||||
|
||||
/// <inheritdoc cref="IAppPaths"/>
|
||||
public sealed class AppPaths : IAppPaths
|
||||
{
|
||||
public AppPaths()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolderOption.Create);
|
||||
|
||||
DataDirectory = Path.Combine(localAppData, "PLib");
|
||||
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
|
||||
DatabaseFile = Path.Combine(DataDirectory, "library.db");
|
||||
|
||||
Directory.CreateDirectory(DataDirectory);
|
||||
Directory.CreateDirectory(ThumbnailDirectory);
|
||||
}
|
||||
|
||||
public string DataDirectory { get; }
|
||||
|
||||
public string ThumbnailDirectory { get; }
|
||||
|
||||
public string DatabaseFile { get; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace PLib.Infrastructure.Storage;
|
||||
|
||||
/// <summary>Where the application keeps the data it owns on the local machine.</summary>
|
||||
public interface IAppPaths
|
||||
{
|
||||
/// <summary>Root of the per-user data directory; created on first access.</summary>
|
||||
string DataDirectory { get; }
|
||||
|
||||
/// <summary>Directory holding cached poster frames.</summary>
|
||||
string ThumbnailDirectory { get; }
|
||||
|
||||
/// <summary>Full path of the SQLite database file.</summary>
|
||||
string DatabaseFile { get; }
|
||||
}
|
||||
Reference in New Issue
Block a user