Update README.md with project details, features, requirements, architecture, and data management for PLib video library manager.

This commit is contained in:
Leonid Pershin
2026-08-08 07:08:43 +03:00
parent e767e4a48c
commit ac05ea6b7f
59 changed files with 3010 additions and 0 deletions
@@ -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; }
}