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,52 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = crlf
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{xml,axaml,xaml,csproj,props,targets,json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.cs]
|
||||
# Namespaces
|
||||
csharp_style_namespace_declarations = file_scoped:warning
|
||||
|
||||
# var
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
|
||||
# Modern language features
|
||||
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||
csharp_style_expression_bodied_properties = true:suggestion
|
||||
csharp_style_prefer_primary_constructors = true:suggestion
|
||||
csharp_style_prefer_pattern_matching = true:suggestion
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
csharp_prefer_braces = true:warning
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
dotnet_style_collection_initializer = true:suggestion
|
||||
dotnet_style_prefer_collection_expression = true:suggestion
|
||||
dotnet_style_readonly_field = true:warning
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
|
||||
|
||||
# Usings
|
||||
dotnet_sort_system_directives_first = true
|
||||
csharp_using_directive_placement = outside_namespace:warning
|
||||
|
||||
# Naming: private fields are _camelCase
|
||||
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
|
||||
dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
|
||||
dotnet_naming_rule.private_fields_underscore.severity = warning
|
||||
|
||||
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
||||
dotnet_naming_symbols.private_fields.required_modifiers =
|
||||
|
||||
dotnet_naming_style.underscore_prefix.capitalization = camel_case
|
||||
dotnet_naming_style.underscore_prefix.required_prefix = _
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Product>PLib</Product>
|
||||
<Company>PLib</Company>
|
||||
<Version>0.1.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Label="UI">
|
||||
<PackageVersion Include="Avalonia" Version="12.1.1" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageVersion Include="Avalonia.Controls.ItemsRepeater" Version="12.0.0" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
|
||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="MVVM / Composition">
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Persistence">
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<!-- Pinned above the version EF Core resolves: 2.1.11 carries GHSA-2m69-gcr7-jv3q. -->
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||
<PackageVersion Include="SQLitePCLRaw.core" Version="3.0.5" />
|
||||
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.53.3" />
|
||||
<PackageVersion Include="SQLitePCLRaw.provider.e_sqlite3" Version="3.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Media">
|
||||
<PackageVersion Include="FFMpegCore" Version="5.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Logging">
|
||||
<PackageVersion Include="Serilog" Version="4.4.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Testing">
|
||||
<PackageVersion Include="xunit.v3" Version="3.2.2" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
<PackageVersion Include="NSubstitute" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/PLib.Application/PLib.Application.csproj" />
|
||||
<Project Path="src/PLib.Desktop/PLib.Desktop.csproj" />
|
||||
<Project Path="src/PLib.Domain/PLib.Domain.csproj" />
|
||||
<Project Path="src/PLib.Infrastructure/PLib.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/PLib.Tests/PLib.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -1,2 +1,66 @@
|
||||
# PLib
|
||||
|
||||
Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и
|
||||
показывает всё сеткой карточек.
|
||||
|
||||
## Что уже работает
|
||||
|
||||
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
|
||||
- Метаданные (длительность, разрешение, кодек) через ffprobe.
|
||||
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
|
||||
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
||||
- Светлая и тёмная темы.
|
||||
- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню.
|
||||
|
||||
## Требования
|
||||
|
||||
- .NET 10 SDK
|
||||
- `ffmpeg` и `ffprobe` в `PATH`
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
dotnet run --project src/PLib.Desktop
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
## Архитектура
|
||||
|
||||
Четыре слоя, зависимости направлены только внутрь:
|
||||
|
||||
| Проект | Отвечает за | Знает о |
|
||||
| --- | --- | --- |
|
||||
| `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём |
|
||||
| `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain |
|
||||
| `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application |
|
||||
| `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure |
|
||||
|
||||
Ключевые решения:
|
||||
|
||||
- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает
|
||||
`IAsyncEnumerable<LibraryScanEvent>`: карточки появляются по мере находок, а не после
|
||||
завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через
|
||||
`Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям
|
||||
по одному — трекер изменений EF не потокобезопасен.
|
||||
- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает
|
||||
каждое событие в UI явно через `Dispatcher.UIThread`.
|
||||
- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в
|
||||
визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с
|
||||
декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки.
|
||||
- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт
|
||||
`IServiceScopeFactory` и создаёт scope на каждый вызов.
|
||||
|
||||
## Данные
|
||||
|
||||
Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`:
|
||||
|
||||
- `library.db` — SQLite с метаданными;
|
||||
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
|
||||
- `settings.json` — список папок, перечитывается на лету;
|
||||
- `logs/` — Serilog, ротация по дням.
|
||||
|
||||
Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на
|
||||
миграции EF Core (`DatabaseInitializer` — единственное место, которое надо будет тронуть).
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Application.Abstractions;
|
||||
|
||||
/// <summary>Reads duration, resolution and codec out of a media container.</summary>
|
||||
public interface IMediaProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Probes <paramref name="fullPath"/>. Returns <see cref="VideoTechnicalInfo.Unknown"/>
|
||||
/// when the file cannot be read or is not a media file — probing must never throw for
|
||||
/// a single bad file, otherwise one corrupt video would abort a whole library scan.
|
||||
/// </summary>
|
||||
Task<VideoTechnicalInfo> ProbeAsync(string fullPath, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace PLib.Application.Abstractions;
|
||||
|
||||
/// <summary>Produces (and caches on disk) a poster frame for a video file.</summary>
|
||||
public interface IThumbnailGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the absolute path of the poster frame for <paramref name="videoPath"/>,
|
||||
/// generating it if it is not cached yet. Returns <c>null</c> when no frame could be
|
||||
/// extracted; a missing thumbnail is a normal outcome, not an error.
|
||||
/// </summary>
|
||||
Task<string?> GetOrCreateAsync(
|
||||
string videoPath,
|
||||
TimeSpan? duration,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace PLib.Application.Abstractions;
|
||||
|
||||
/// <summary>A video file discovered on disk, before it is known to the library.</summary>
|
||||
/// <param name="FullPath">Absolute path of the file.</param>
|
||||
/// <param name="SizeInBytes">Size of the file on disk.</param>
|
||||
/// <param name="ModifiedAt">Last write time of the file.</param>
|
||||
public readonly record struct DiscoveredVideoFile(string FullPath, long SizeInBytes, DateTimeOffset ModifiedAt);
|
||||
|
||||
/// <summary>Walks a folder tree and yields every file that looks like a video.</summary>
|
||||
public interface IVideoFileScanner
|
||||
{
|
||||
IAsyncEnumerable<DiscoveredVideoFile> ScanAsync(string rootFolder, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence boundary for the library. The application layer only ever talks to this
|
||||
/// interface, which keeps EF Core (and SQLite) an implementation detail of the outer ring.
|
||||
/// </summary>
|
||||
public interface IVideoRepository
|
||||
{
|
||||
Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(VideoItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Flushes every pending change made to tracked entities.</summary>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Application.Library;
|
||||
|
||||
/// <summary>Use cases the UI needs in order to show and refresh the video library.</summary>
|
||||
public interface ILibraryService
|
||||
{
|
||||
/// <summary>Everything currently stored in the library, newest first.</summary>
|
||||
Task<IReadOnlyList<VideoItem>> GetLibraryAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles the library with the configured folders and then fills in metadata and
|
||||
/// poster frames for anything that is missing them, streaming progress as it goes.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<LibraryScanEvent> ScanAsync(
|
||||
IReadOnlyList<string> folders,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PLib.Application.Library;
|
||||
|
||||
/// <summary>User-tunable settings for how the library is discovered and indexed.</summary>
|
||||
public sealed class LibraryOptions
|
||||
{
|
||||
public const string SectionName = "Library";
|
||||
|
||||
/// <summary>Root folders that make up the library.</summary>
|
||||
public IList<string> Folders { get; init; } = [];
|
||||
|
||||
/// <summary>File extensions treated as video, lower case and including the leading dot.</summary>
|
||||
public IList<string> VideoExtensions { get; init; } =
|
||||
[
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".m4v", ".mpg", ".mpeg", ".flv", ".ts", ".m2ts",
|
||||
];
|
||||
|
||||
/// <summary>Width of generated poster frames in pixels; height follows the source aspect ratio.</summary>
|
||||
[Range(160, 1920)]
|
||||
public int ThumbnailWidth { get; init; } = 480;
|
||||
|
||||
/// <summary>
|
||||
/// Fraction of the duration at which the poster frame is captured. Grabbing the very
|
||||
/// first frame usually yields a black or logo frame, so we sample a bit into the file.
|
||||
/// </summary>
|
||||
[Range(0.0, 0.9)]
|
||||
public double ThumbnailPositionRatio { get; init; } = 0.15;
|
||||
|
||||
/// <summary>How many files may be probed / rendered concurrently.</summary>
|
||||
[Range(1, 32)]
|
||||
public int MaxIndexingConcurrency { get; init; } = 4;
|
||||
|
||||
/// <summary>Files smaller than this are skipped as they are almost certainly not real videos.</summary>
|
||||
[Range(0, long.MaxValue)]
|
||||
public long MinimumFileSizeInBytes { get; init; } = 64 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace PLib.Application.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Compares file system paths the way the host platform does: case-insensitively on
|
||||
/// Windows, byte-for-byte everywhere else.
|
||||
/// </summary>
|
||||
public static class LibraryPathComparer
|
||||
{
|
||||
public static StringComparer Instance { get; } = OperatingSystem.IsWindows()
|
||||
? StringComparer.OrdinalIgnoreCase
|
||||
: StringComparer.Ordinal;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Application.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Something that happened during a scan. The scan is exposed as a stream of these so the
|
||||
/// UI can render cards as soon as they are known instead of waiting for the whole pass.
|
||||
/// </summary>
|
||||
public abstract record LibraryScanEvent
|
||||
{
|
||||
private LibraryScanEvent()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>The file system walk finished and we know how much work there is.</summary>
|
||||
public sealed record DiscoveryCompleted(int FilesFound) : LibraryScanEvent;
|
||||
|
||||
/// <summary>A video is now part of the library (possibly still without a poster frame).</summary>
|
||||
public sealed record ItemAdded(VideoItem Item) : LibraryScanEvent;
|
||||
|
||||
/// <summary>A video that is already displayed gained metadata or a poster frame.</summary>
|
||||
public sealed record ItemUpdated(VideoItem Item) : LibraryScanEvent;
|
||||
|
||||
/// <summary>A video disappeared from disk and was dropped from the library.</summary>
|
||||
public sealed record ItemRemoved(Guid Id) : LibraryScanEvent;
|
||||
|
||||
/// <summary>Indexing progress, reported after every processed file.</summary>
|
||||
public sealed record IndexingProgress(int Processed, int Total) : LibraryScanEvent;
|
||||
|
||||
/// <summary>The scan finished successfully.</summary>
|
||||
public sealed record Completed(int LibrarySize) : LibraryScanEvent;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Application.Library;
|
||||
|
||||
/// <inheritdoc cref="ILibraryService"/>
|
||||
public sealed class LibraryService(
|
||||
IVideoRepository repository,
|
||||
IVideoFileScanner scanner,
|
||||
IMediaProbe mediaProbe,
|
||||
IThumbnailGenerator thumbnailGenerator,
|
||||
IOptions<LibraryOptions> options,
|
||||
ILogger<LibraryService> logger) : ILibraryService
|
||||
{
|
||||
/// <summary>How many indexed items to accumulate before flushing them to storage.</summary>
|
||||
private const int SaveBatchSize = 25;
|
||||
|
||||
private readonly LibraryOptions _options = options.Value;
|
||||
|
||||
public async Task<IReadOnlyList<VideoItem>> GetLibraryAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = await repository.GetAllAsync(cancellationToken);
|
||||
return [.. items.OrderByDescending(x => x.AddedAt)];
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<LibraryScanEvent> ScanAsync(
|
||||
IReadOnlyList<string> folders,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var known = (await repository.GetAllAsync(cancellationToken))
|
||||
.ToDictionary(x => x.FullPath, LibraryPathComparer.Instance);
|
||||
|
||||
var discovered = await DiscoverAsync(folders, cancellationToken);
|
||||
yield return new LibraryScanEvent.DiscoveryCompleted(discovered.Count);
|
||||
|
||||
var pending = new List<VideoItem>();
|
||||
|
||||
foreach (var file in discovered.Values)
|
||||
{
|
||||
if (known.TryGetValue(file.FullPath, out var existing))
|
||||
{
|
||||
existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing = new VideoItem(
|
||||
file.FullPath,
|
||||
Path.GetFileNameWithoutExtension(file.FullPath),
|
||||
file.SizeInBytes,
|
||||
file.ModifiedAt);
|
||||
|
||||
await repository.AddAsync(existing, cancellationToken);
|
||||
known.Add(existing.FullPath, existing);
|
||||
yield return new LibraryScanEvent.ItemAdded(existing);
|
||||
}
|
||||
|
||||
if (!existing.IsIndexed)
|
||||
{
|
||||
pending.Add(existing);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var orphan in known.Values.Where(x => !discovered.ContainsKey(x.FullPath)).ToList())
|
||||
{
|
||||
await repository.RemoveAsync(orphan, cancellationToken);
|
||||
known.Remove(orphan.FullPath);
|
||||
yield return new LibraryScanEvent.ItemRemoved(orphan.Id);
|
||||
}
|
||||
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await foreach (var indexed in IndexAsync(pending, cancellationToken))
|
||||
{
|
||||
yield return indexed;
|
||||
}
|
||||
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
yield return new LibraryScanEvent.Completed(known.Count);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, DiscoveredVideoFile>> DiscoverAsync(
|
||||
IReadOnlyList<string> folders,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var discovered = new Dictionary<string, DiscoveredVideoFile>(LibraryPathComparer.Instance);
|
||||
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
await foreach (var file in scanner.ScanAsync(folder, cancellationToken))
|
||||
{
|
||||
if (file.SizeInBytes < _options.MinimumFileSizeInBytes)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Overlapping roots are legal, so the first sighting of a path wins.
|
||||
discovered.TryAdd(file.FullPath, file);
|
||||
}
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probes and renders poster frames with bounded concurrency. The expensive work runs in
|
||||
/// parallel, but the results are applied to the entities one at a time by the consumer
|
||||
/// because change tracking is not thread safe.
|
||||
/// </summary>
|
||||
private async IAsyncEnumerable<LibraryScanEvent> IndexAsync(
|
||||
IReadOnlyList<VideoItem> pending,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var channel = Channel.CreateBounded<IndexResult>(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4)
|
||||
{
|
||||
SingleReader = true,
|
||||
});
|
||||
|
||||
var producer = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var parallelOptions = new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = _options.MaxIndexingConcurrency,
|
||||
CancellationToken = cancellationToken,
|
||||
};
|
||||
|
||||
await Parallel.ForEachAsync(
|
||||
pending,
|
||||
parallelOptions,
|
||||
async (item, token) =>
|
||||
{
|
||||
var info = await mediaProbe.ProbeAsync(item.FullPath, token);
|
||||
var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token);
|
||||
await channel.Writer.WriteAsync(new IndexResult(item, info, thumbnail), token);
|
||||
});
|
||||
|
||||
channel.Writer.Complete();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
channel.Writer.Complete(ex);
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var processed = 0;
|
||||
|
||||
await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
result.Item.ApplyTechnicalInfo(result.Info);
|
||||
|
||||
if (result.ThumbnailPath is not null)
|
||||
{
|
||||
result.Item.AttachThumbnail(result.ThumbnailPath);
|
||||
}
|
||||
|
||||
processed++;
|
||||
|
||||
yield return new LibraryScanEvent.ItemUpdated(result.Item);
|
||||
yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count);
|
||||
|
||||
if (processed % SaveBatchSize == 0)
|
||||
{
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await producer;
|
||||
logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count);
|
||||
}
|
||||
|
||||
private readonly record struct IndexResult(VideoItem Item, VideoTechnicalInfo Info, string? ThumbnailPath);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>PLib.Application</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PLib.Domain\PLib.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PLib.Desktop.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://PLib/Themes/Palette.axaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<StyleInclude Source="avares://Semi.Avalonia/Index.axaml" />
|
||||
<StyleInclude Source="avares://Material.Icons.Avalonia/MaterialIconStyles.axaml" />
|
||||
<StyleInclude Source="avares://PLib/Themes/LibraryStyles.axaml" />
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
@@ -0,0 +1,55 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PLib.Desktop.Controls;
|
||||
using PLib.Desktop.Imaging;
|
||||
using PLib.Desktop.ViewModels;
|
||||
using PLib.Desktop.Views;
|
||||
|
||||
namespace PLib.Desktop;
|
||||
|
||||
// Fully qualified: the PLib.Application namespace shadows the Application type in this assembly.
|
||||
public sealed class App : Avalonia.Application
|
||||
{
|
||||
private IHost? _host;
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
_host = AppHost.Create(desktop.Args ?? []);
|
||||
_host.Start();
|
||||
|
||||
AsyncImage.Loader = _host.Services.GetRequiredService<ThumbnailCache>();
|
||||
|
||||
var viewModel = _host.Services.GetRequiredService<MainWindowViewModel>();
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
desktop.Exit += OnExit;
|
||||
|
||||
// Kick the first load off once the dispatcher is running, so a failure surfaces
|
||||
// through the view model instead of disappearing into an unobserved task.
|
||||
Dispatcher.UIThread.Post(
|
||||
() => viewModel.InitializeCommand.Execute(null),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
|
||||
{
|
||||
if (_host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_host.StopAsync(TimeSpan.FromSeconds(3)).GetAwaiter().GetResult();
|
||||
_host.Dispose();
|
||||
_host = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Desktop.Imaging;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.ViewModels;
|
||||
using PLib.Infrastructure;
|
||||
using PLib.Infrastructure.Storage;
|
||||
using Serilog;
|
||||
|
||||
namespace PLib.Desktop;
|
||||
|
||||
/// <summary>
|
||||
/// Composition root. Everything the application is made of is wired up here and nowhere else.
|
||||
/// </summary>
|
||||
internal static class AppHost
|
||||
{
|
||||
public static IHost Create(string[] args)
|
||||
{
|
||||
// The paths are needed to locate the user settings file, which is itself a
|
||||
// configuration source — so they are built before the container exists and then
|
||||
// handed to it as an instance.
|
||||
var paths = new AppPaths();
|
||||
|
||||
// A desktop app is launched from arbitrary working directories, so the content root
|
||||
// has to be the folder the executable lives in rather than Environment.CurrentDirectory.
|
||||
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
|
||||
{
|
||||
Args = args,
|
||||
ContentRootPath = AppContext.BaseDirectory,
|
||||
});
|
||||
|
||||
builder.Configuration.AddJsonFile(
|
||||
Path.Combine(paths.DataDirectory, "settings.json"),
|
||||
optional: true,
|
||||
reloadOnChange: true);
|
||||
|
||||
ConfigureLogging(builder, paths);
|
||||
|
||||
builder.Services.AddSingleton<IAppPaths>(paths);
|
||||
builder.Services.AddPLibInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddSingleton<ThumbnailCache>();
|
||||
builder.Services.AddSingleton<IThumbnailLoader>(sp => sp.GetRequiredService<ThumbnailCache>());
|
||||
builder.Services.AddSingleton<ILibrarySettingsStore, JsonLibrarySettingsStore>();
|
||||
builder.Services.AddSingleton<IFolderPicker, StorageProviderFolderPicker>();
|
||||
builder.Services.AddSingleton<ISystemShell, SystemShell>();
|
||||
builder.Services.AddSingleton<IThemeService, ThemeService>();
|
||||
builder.Services.AddSingleton<MainWindowViewModel>();
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File(
|
||||
Path.Combine(paths.DataDirectory, "logs", "plib-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7)
|
||||
.CreateLogger();
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSerilog(Log.Logger, dispose: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using PLib.Desktop.Imaging;
|
||||
|
||||
namespace PLib.Desktop.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Draws a poster frame that is loaded only while the control is actually on screen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A virtualised grid recycles containers as the user scrolls, so binding a decoded
|
||||
/// <see cref="Bitmap"/> straight into the view model would keep every frame the user has ever
|
||||
/// passed alive. Instead the control asks the shared loader when it is attached to the visual
|
||||
/// tree and drops its reference when it is detached, which keeps memory proportional to what
|
||||
/// is visible rather than to the size of the library.
|
||||
/// </remarks>
|
||||
public sealed class AsyncImage : Control
|
||||
{
|
||||
public static readonly StyledProperty<string?> SourceProperty =
|
||||
AvaloniaProperty.Register<AsyncImage, string?>(nameof(Source));
|
||||
|
||||
public static readonly StyledProperty<int> DecodeWidthProperty =
|
||||
AvaloniaProperty.Register<AsyncImage, int>(nameof(DecodeWidth), defaultValue: 400);
|
||||
|
||||
public static readonly StyledProperty<IBrush?> PlaceholderBrushProperty =
|
||||
AvaloniaProperty.Register<AsyncImage, IBrush?>(nameof(PlaceholderBrush));
|
||||
|
||||
/// <summary>
|
||||
/// Shared loader, assigned once by the composition root. A control is created by the XAML
|
||||
/// runtime and therefore cannot take constructor dependencies.
|
||||
/// </summary>
|
||||
public static IThumbnailLoader? Loader { get; set; }
|
||||
|
||||
private CancellationTokenSource? _pending;
|
||||
private Bitmap? _bitmap;
|
||||
private bool _isAttached;
|
||||
|
||||
static AsyncImage()
|
||||
{
|
||||
AffectsRender<AsyncImage>(SourceProperty, PlaceholderBrushProperty);
|
||||
}
|
||||
|
||||
public AsyncImage()
|
||||
{
|
||||
Opacity = 0;
|
||||
Transitions =
|
||||
[
|
||||
new DoubleTransition
|
||||
{
|
||||
Property = OpacityProperty,
|
||||
Duration = TimeSpan.FromMilliseconds(220),
|
||||
Easing = new CubicEaseOut(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public string? Source
|
||||
{
|
||||
get => GetValue(SourceProperty);
|
||||
set => SetValue(SourceProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>Target width in pixels for decoding; smaller means less memory per card.</summary>
|
||||
public int DecodeWidth
|
||||
{
|
||||
get => GetValue(DecodeWidthProperty);
|
||||
set => SetValue(DecodeWidthProperty, value);
|
||||
}
|
||||
|
||||
public IBrush? PlaceholderBrush
|
||||
{
|
||||
get => GetValue(PlaceholderBrushProperty);
|
||||
set => SetValue(PlaceholderBrushProperty, value);
|
||||
}
|
||||
|
||||
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnAttachedToVisualTree(e);
|
||||
_isAttached = true;
|
||||
BeginLoad();
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
_isAttached = false;
|
||||
Release();
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
if (change.Property == SourceProperty || change.Property == DecodeWidthProperty)
|
||||
{
|
||||
Release();
|
||||
BeginLoad();
|
||||
}
|
||||
}
|
||||
|
||||
public override void Render(DrawingContext context)
|
||||
{
|
||||
var bounds = new Rect(Bounds.Size);
|
||||
|
||||
if (bounds.Width <= 0 || bounds.Height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_bitmap is null)
|
||||
{
|
||||
if (PlaceholderBrush is { } placeholder)
|
||||
{
|
||||
context.FillRectangle(placeholder, bounds);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
context.DrawImage(_bitmap, CoverSourceRect(_bitmap.PixelSize, bounds), bounds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the largest centred crop of the source that has the same aspect ratio as the
|
||||
/// destination, i.e. CSS <c>object-fit: cover</c>: fill the card, never letterbox.
|
||||
/// </summary>
|
||||
private static Rect CoverSourceRect(PixelSize source, Rect destination)
|
||||
{
|
||||
var scale = Math.Max(destination.Width / source.Width, destination.Height / source.Height);
|
||||
var width = destination.Width / scale;
|
||||
var height = destination.Height / scale;
|
||||
|
||||
return new Rect((source.Width - width) / 2, (source.Height - height) / 2, width, height);
|
||||
}
|
||||
|
||||
private void BeginLoad()
|
||||
{
|
||||
if (!_isAttached || Loader is not { } loader || string.IsNullOrEmpty(Source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_pending = cts;
|
||||
|
||||
_ = LoadAsync(loader, Source, DecodeWidth, cts);
|
||||
}
|
||||
|
||||
private async Task LoadAsync(IThumbnailLoader loader, string path, int decodeWidth, CancellationTokenSource cts)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bitmap = await loader.GetAsync(path, decodeWidth, cts.Token);
|
||||
|
||||
if (bitmap is null || cts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
// The container may have been recycled onto a different item while we decoded.
|
||||
if (cts.IsCancellationRequested || !ReferenceEquals(_pending, cts))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_bitmap = bitmap;
|
||||
Opacity = 1;
|
||||
InvalidateVisual();
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Scrolled away before the frame was ready; nothing to show and nothing to report.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels any in-flight decode and drops the reference to the bitmap. The bitmap itself
|
||||
/// belongs to the cache and is never disposed here.
|
||||
/// </summary>
|
||||
private void Release()
|
||||
{
|
||||
if (_pending is { } cts)
|
||||
{
|
||||
_pending = null;
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
_bitmap = null;
|
||||
Opacity = 0;
|
||||
InvalidateVisual();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace PLib.Desktop.Imaging;
|
||||
|
||||
/// <summary>The view-side contract for turning a poster frame path into a drawable bitmap.</summary>
|
||||
public interface IThumbnailLoader
|
||||
{
|
||||
Task<Bitmap?> GetAsync(string path, int decodeWidth, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PLib.Desktop.Imaging;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes poster frames off the UI thread and keeps the most recently used ones in memory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A library can hold thousands of files, so decoded bitmaps cannot all stay alive. Entries
|
||||
/// are evicted by least-recent use but never disposed: a card that is still on screen may be
|
||||
/// rendering the very bitmap we drop, and the garbage collector is the only party that knows
|
||||
/// when the last reference is gone.
|
||||
/// </remarks>
|
||||
public sealed class ThumbnailCache(ILogger<ThumbnailCache> logger) : IThumbnailLoader
|
||||
{
|
||||
private const int Capacity = 256;
|
||||
|
||||
/// <summary>Decoding is CPU bound; a couple of workers keeps scrolling smooth without thrashing.</summary>
|
||||
private readonly SemaphoreSlim _decodeSlots = new(Math.Max(2, Environment.ProcessorCount / 2));
|
||||
|
||||
private readonly Lock _gate = new();
|
||||
private readonly Dictionary<CacheKey, LinkedListNode<CacheEntry>> _entries = [];
|
||||
private readonly LinkedList<CacheEntry> _recency = [];
|
||||
|
||||
public async Task<Bitmap?> GetAsync(string path, int decodeWidth, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = new CacheKey(path, decodeWidth);
|
||||
|
||||
if (TryTouch(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
await _decodeSlots.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Another card may have decoded the same frame while we waited for a slot.
|
||||
if (TryTouch(key, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var bitmap = await Task.Run(() => Decode(path, decodeWidth), cancellationToken);
|
||||
|
||||
if (bitmap is not null)
|
||||
{
|
||||
Store(key, bitmap);
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_decodeSlots.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap? Decode(string path, int decodeWidth)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Bitmap.DecodeToWidth(stream, decodeWidth, BitmapInterpolationMode.HighQuality);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Could not decode thumbnail {Path}", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryTouch(CacheKey key, out Bitmap? bitmap)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var node))
|
||||
{
|
||||
_recency.Remove(node);
|
||||
_recency.AddFirst(node);
|
||||
bitmap = node.Value.Bitmap;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bitmap = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Store(CacheKey key, Bitmap bitmap)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.ContainsKey(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_entries[key] = _recency.AddFirst(new CacheEntry(key, bitmap));
|
||||
|
||||
while (_recency.Count > Capacity)
|
||||
{
|
||||
var evicted = _recency.Last!;
|
||||
_recency.RemoveLast();
|
||||
_entries.Remove(evicted.Value.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct CacheKey(string Path, int DecodeWidth);
|
||||
|
||||
private readonly record struct CacheEntry(CacheKey Key, Bitmap Bitmap);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>PLib.Desktop</RootNamespace>
|
||||
<AssemblyName>PLib</AssemblyName>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Semi.Avalonia" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PLib.Infrastructure\PLib.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace PLib.Desktop;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
// Avalonia must be initialised before anything touches its types, so keep Main free of
|
||||
// any other work and let App own the application host.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
/// <summary>Also used by the XAML previewer, which requires this exact signature.</summary>
|
||||
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Asks the user for a folder. Returns <c>null</c> when the dialog is dismissed.</summary>
|
||||
public interface IFolderPicker
|
||||
{
|
||||
Task<string?> PickFolderAsync(string title, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the parts of <see cref="Application.Library.LibraryOptions"/> the user can change
|
||||
/// at runtime. Writes land in a JSON file that is also a configuration source, so
|
||||
/// <c>IOptionsMonitor</c> picks the change up without a restart.
|
||||
/// </summary>
|
||||
public interface ILibrarySettingsStore
|
||||
{
|
||||
Task SaveFoldersAsync(IReadOnlyList<string> folders, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Infrastructure.Storage;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <inheritdoc cref="ILibrarySettingsStore"/>
|
||||
public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
|
||||
|
||||
public async Task SaveFoldersAsync(
|
||||
IReadOnlyList<string> folders,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var root = await ReadRootAsync(cancellationToken);
|
||||
|
||||
if (root[LibraryOptions.SectionName] is not JsonObject section)
|
||||
{
|
||||
section = [];
|
||||
root[LibraryOptions.SectionName] = section;
|
||||
}
|
||||
|
||||
section["Folders"] = new JsonArray([.. folders.Select(f => (JsonNode)JsonValue.Create(f))]);
|
||||
|
||||
// Write through a temp file so an interrupted save cannot corrupt the settings.
|
||||
var staging = SettingsFile + ".tmp";
|
||||
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
|
||||
File.Move(staging, SettingsFile, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(SettingsFile))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(SettingsFile, cancellationToken);
|
||||
return JsonNode.Parse(json) as JsonObject ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A hand-edited, broken settings file should not stop the app from saving.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <inheritdoc cref="IFolderPicker"/>
|
||||
public sealed class StorageProviderFolderPicker : IFolderPicker
|
||||
{
|
||||
public async Task<string?> PickFolderAsync(string title, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{
|
||||
MainWindow.StorageProvider: { } storageProvider,
|
||||
})
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = title,
|
||||
AllowMultiple = false,
|
||||
});
|
||||
|
||||
return folders.Count > 0 ? folders[0].TryGetLocalPath() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Hands a file over to whatever the operating system uses to open or show it.</summary>
|
||||
public interface ISystemShell
|
||||
{
|
||||
void OpenFile(string path);
|
||||
|
||||
void RevealInFileManager(string path);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISystemShell"/>
|
||||
public sealed class SystemShell(ILogger<SystemShell> logger) : ISystemShell
|
||||
{
|
||||
public void OpenFile(string path) => Start(new ProcessStartInfo(path) { UseShellExecute = true }, path);
|
||||
|
||||
public void RevealInFileManager(string path)
|
||||
{
|
||||
ProcessStartInfo startInfo;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
startInfo = new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"");
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
startInfo = new ProcessStartInfo("open", ["-R", path]);
|
||||
}
|
||||
else
|
||||
{
|
||||
var folder = Path.GetDirectoryName(path);
|
||||
|
||||
if (folder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
startInfo = new ProcessStartInfo("xdg-open", [folder]);
|
||||
}
|
||||
|
||||
Start(startInfo, path);
|
||||
}
|
||||
|
||||
private void Start(ProcessStartInfo startInfo, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Nothing actionable for the user here; a missing handler is not a crash.
|
||||
logger.LogWarning(ex, "Could not hand {Path} to the shell", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace PLib.Desktop.Services;
|
||||
|
||||
/// <summary>Switches the application between the light and dark variants.</summary>
|
||||
public interface IThemeService
|
||||
{
|
||||
void Toggle();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IThemeService"/>
|
||||
public sealed class ThemeService : IThemeService
|
||||
{
|
||||
public void Toggle()
|
||||
{
|
||||
if (Avalonia.Application.Current is not { } application)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// ActualThemeVariant resolves "follow the system" to whatever is on screen right now,
|
||||
// which is what the user is actually toggling away from.
|
||||
application.RequestedThemeVariant = application.ActualThemeVariant == ThemeVariant.Dark
|
||||
? ThemeVariant.Light
|
||||
: ThemeVariant.Dark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============================ Card ============================ -->
|
||||
|
||||
<!--
|
||||
The card is a Button so that keyboard focus, Enter/Space and the pointer all activate it
|
||||
for free. Its template is reduced to a single surface Border because the Semi button
|
||||
chrome would fight the artwork.
|
||||
-->
|
||||
<Style Selector="Button.card">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<!-- Without these the card keeps its desired size inside the grid cell the layout
|
||||
hands it, which leaves the cards small and the grid full of gaps. -->
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Name="PART_Surface"
|
||||
Background="{DynamicResource CardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
Padding="8,8,8,10">
|
||||
<Border.Transitions>
|
||||
<Transitions>
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.16" />
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalContentAlignment="Stretch" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover">
|
||||
<Setter Property="RenderTransform" Value="scale(1.025)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover /template/ Border#PART_Surface">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardHoverBorderBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pressed">
|
||||
<Setter Property="RenderTransform" Value="scale(0.99)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:focus-visible /template/ Border#PART_Surface">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Play affordance: invisible until the pointer is over the card. -->
|
||||
<Style Selector="Border.playOverlay">
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.16" Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.card:pointerover Border.playOverlay">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================ Text ============================ -->
|
||||
|
||||
<Style Selector="TextBlock.cardTitle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LineHeight" Value="18" />
|
||||
<Setter Property="MaxLines" Value="2" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.cardMeta">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextTertiaryBrush}" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.sectionTitle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="17" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.subtle">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================ Badge ============================ -->
|
||||
|
||||
<Style Selector="Border.badge">
|
||||
<Setter Property="Background" Value="{DynamicResource BadgeBackgroundBrush}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.badge > TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource BadgeForegroundBrush}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
@@ -0,0 +1,49 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Semantic brushes owned by this application. Semi.Avalonia styles the controls; the
|
||||
surfaces the library grid is built from are defined here so the card design does not
|
||||
depend on the internal resource keys of a third-party theme.
|
||||
-->
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<SolidColorBrush x:Key="PageBackgroundBrush" Color="#0E1014" />
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#14171D" />
|
||||
<SolidColorBrush x:Key="SurfaceBorderBrush" Color="#20252E" />
|
||||
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#171B22" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#242A34" />
|
||||
<SolidColorBrush x:Key="CardHoverBorderBrush" Color="#3F4B5E" />
|
||||
<SolidColorBrush x:Key="ThumbnailPlaceholderBrush" Color="#1C222B" />
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#E8EDF4" />
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#97A2B2" />
|
||||
<SolidColorBrush x:Key="TextTertiaryBrush" Color="#69737F" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#5B8CFF" />
|
||||
<SolidColorBrush x:Key="AccentSoftBrush" Color="#1E2A44" />
|
||||
<SolidColorBrush x:Key="BadgeBackgroundBrush" Color="#B3000000" />
|
||||
<SolidColorBrush x:Key="BadgeForegroundBrush" Color="#F2F5FA" />
|
||||
<SolidColorBrush x:Key="OverlayBrush" Color="#59000000" />
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<SolidColorBrush x:Key="PageBackgroundBrush" Color="#F4F6F9" />
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="SurfaceBorderBrush" Color="#E6E9EF" />
|
||||
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#E4E7EC" />
|
||||
<SolidColorBrush x:Key="CardHoverBorderBrush" Color="#B9C3D3" />
|
||||
<SolidColorBrush x:Key="ThumbnailPlaceholderBrush" Color="#E9EDF2" />
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#111621" />
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#586274" />
|
||||
<SolidColorBrush x:Key="TextTertiaryBrush" Color="#8A93A2" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#2F6BFF" />
|
||||
<SolidColorBrush x:Key="AccentSoftBrush" Color="#E5EDFF" />
|
||||
<SolidColorBrush x:Key="BadgeBackgroundBrush" Color="#B3101418" />
|
||||
<SolidColorBrush x:Key="BadgeForegroundBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="OverlayBrush" Color="#4D000000" />
|
||||
</ResourceDictionary>
|
||||
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>Turns raw numbers into the short strings the cards show.</summary>
|
||||
internal static class DisplayText
|
||||
{
|
||||
private static readonly string[] SizeUnits = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
|
||||
|
||||
public static string Duration(TimeSpan? duration) => duration switch
|
||||
{
|
||||
null or { TotalSeconds: < 1 } => "—",
|
||||
{ TotalHours: >= 1 } value => value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture),
|
||||
var value => value.Value.ToString(@"m\:ss", CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
public static string FileSize(long bytes)
|
||||
{
|
||||
double value = bytes;
|
||||
var unit = 0;
|
||||
|
||||
while (value >= 1024 && unit < SizeUnits.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
var precision = value < 10 && unit > 0 ? 1 : 0;
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{Math.Round(value, precision)} {SizeUnits[unit]}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Names the vertical resolution the way people talk about it, falling back to the raw
|
||||
/// dimensions for anything that does not match a familiar tier.
|
||||
/// </summary>
|
||||
public static string? Quality(int? width, int? height) => (width, height) switch
|
||||
{
|
||||
(null, _) or (_, null) => null,
|
||||
( >= 7000, _) => "8K",
|
||||
( >= 3500, _) => "4K",
|
||||
(_, >= 2000) => "1440p",
|
||||
(_, >= 1000) => "1080p",
|
||||
(_, >= 700) => "720p",
|
||||
(_, >= 460) => "480p",
|
||||
var (w, h) => $"{w}×{h}",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Desktop.Services;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
public sealed partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IOptionsMonitor<LibraryOptions> _options;
|
||||
private readonly ILibrarySettingsStore _settingsStore;
|
||||
private readonly IFolderPicker _folderPicker;
|
||||
private readonly ISystemShell _shell;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly ILogger<MainWindowViewModel> _logger;
|
||||
|
||||
/// <summary>Every card we know about; <see cref="Videos"/> is the filtered, sorted view of it.</summary>
|
||||
private readonly List<VideoCardViewModel> _all = [];
|
||||
private readonly Dictionary<Guid, VideoCardViewModel> _byId = [];
|
||||
|
||||
public MainWindowViewModel(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptionsMonitor<LibraryOptions> options,
|
||||
ILibrarySettingsStore settingsStore,
|
||||
IFolderPicker folderPicker,
|
||||
ISystemShell shell,
|
||||
IThemeService theme,
|
||||
ILogger<MainWindowViewModel> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = options;
|
||||
_settingsStore = settingsStore;
|
||||
_folderPicker = folderPicker;
|
||||
_shell = shell;
|
||||
_theme = theme;
|
||||
_logger = logger;
|
||||
|
||||
SelectedSort = SortOption.All[0];
|
||||
}
|
||||
|
||||
public ObservableCollection<VideoCardViewModel> Videos { get; } = [];
|
||||
|
||||
public IReadOnlyList<SortOption> SortOptions => SortOption.All;
|
||||
|
||||
public IReadOnlyList<string> Folders => [.. _options.CurrentValue.Folders];
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string SearchText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial SortOption SelectedSort { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsScanning { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string StatusText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial double ScanProgress { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsProgressIndeterminate { get; set; }
|
||||
|
||||
/// <summary>True when the library is empty and there is nothing to show but the call to action.</summary>
|
||||
public bool IsEmpty => Videos.Count == 0 && !IsScanning;
|
||||
|
||||
public bool HasFolders => _options.CurrentValue.Folders.Count > 0;
|
||||
|
||||
partial void OnSearchTextChanged(string value) => RebuildView();
|
||||
|
||||
partial void OnSelectedSortChanged(SortOption value) => RebuildView();
|
||||
|
||||
partial void OnIsScanningChanged(bool value) => OnPropertyChanged(nameof(IsEmpty));
|
||||
|
||||
/// <summary>Loads whatever is already in the database, then refreshes it against disk.</summary>
|
||||
[RelayCommand]
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
foreach (var item in await library.GetLibraryAsync())
|
||||
{
|
||||
var card = new VideoCardViewModel(item, _shell);
|
||||
_all.Add(card);
|
||||
_byId[card.Id] = card;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// An unobserved failure here would tear the process down through the command's
|
||||
// task; the user gets a message instead and can still pick a folder.
|
||||
_logger.LogError(ex, "Could not load the stored library");
|
||||
StatusText = "Не удалось открыть базу библиотеки — подробности в журнале";
|
||||
return;
|
||||
}
|
||||
|
||||
RebuildView();
|
||||
|
||||
if (HasFolders)
|
||||
{
|
||||
await ScanCommand.ExecuteAsync(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusText = "Библиотека пуста — добавьте папку с видео";
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(IncludeCancelCommand = true)]
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var folders = _options.CurrentValue.Folders.ToArray();
|
||||
|
||||
if (folders.Length == 0)
|
||||
{
|
||||
StatusText = "Не выбрано ни одной папки";
|
||||
return;
|
||||
}
|
||||
|
||||
IsScanning = true;
|
||||
IsProgressIndeterminate = true;
|
||||
ScanProgress = 0;
|
||||
StatusText = "Поиск файлов…";
|
||||
|
||||
try
|
||||
{
|
||||
// Task.Run detaches the whole pipeline from the UI synchronisation context, so
|
||||
// scanning, probing and database work never touch the render thread. Every event
|
||||
// is marshalled back explicitly.
|
||||
await Task.Run(
|
||||
async () =>
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await foreach (var scanEvent in library.ScanAsync(folders, cancellationToken))
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() => Handle(scanEvent));
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
StatusText = "Сканирование отменено";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Library scan failed");
|
||||
StatusText = "Не удалось просканировать библиотеку — подробности в журнале";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsScanning = false;
|
||||
IsProgressIndeterminate = false;
|
||||
RebuildView();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddFolderAsync()
|
||||
{
|
||||
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
|
||||
|
||||
if (folder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var folders = _options.CurrentValue.Folders.ToList();
|
||||
|
||||
if (folders.Contains(folder, LibraryPathComparer.Instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
folders.Add(folder);
|
||||
await _settingsStore.SaveFoldersAsync(folders);
|
||||
|
||||
// IOptionsMonitor reloads from the file asynchronously; wait for it so the scan below
|
||||
// sees the folder we just added instead of racing the file watcher.
|
||||
await WaitForFolderAsync(folder);
|
||||
|
||||
OnPropertyChanged(nameof(Folders));
|
||||
OnPropertyChanged(nameof(HasFolders));
|
||||
|
||||
await ScanCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleTheme() => _theme.Toggle();
|
||||
|
||||
private async Task WaitForFolderAsync(string folder)
|
||||
{
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
{
|
||||
if (_options.CurrentValue.Folders.Contains(folder, LibraryPathComparer.Instance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Configuration did not pick up the new folder {Folder} in time", folder);
|
||||
}
|
||||
|
||||
private void Handle(LibraryScanEvent scanEvent)
|
||||
{
|
||||
switch (scanEvent)
|
||||
{
|
||||
case LibraryScanEvent.DiscoveryCompleted discovery:
|
||||
StatusText = $"Найдено файлов: {discovery.FilesFound}";
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.ItemAdded added:
|
||||
{
|
||||
var card = new VideoCardViewModel(added.Item, _shell);
|
||||
_all.Add(card);
|
||||
_byId[card.Id] = card;
|
||||
InsertIntoView(card);
|
||||
break;
|
||||
}
|
||||
|
||||
case LibraryScanEvent.ItemUpdated updated
|
||||
when _byId.TryGetValue(updated.Item.Id, out var existing):
|
||||
existing.Apply(updated.Item);
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.ItemRemoved removed
|
||||
when _byId.Remove(removed.Id, out var dropped):
|
||||
_all.Remove(dropped);
|
||||
Videos.Remove(dropped);
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.IndexingProgress progress:
|
||||
IsProgressIndeterminate = false;
|
||||
ScanProgress = progress.Total == 0 ? 100 : progress.Processed * 100.0 / progress.Total;
|
||||
StatusText = $"Обработка превью: {progress.Processed} из {progress.Total}";
|
||||
break;
|
||||
|
||||
case LibraryScanEvent.Completed completed:
|
||||
ScanProgress = 100;
|
||||
StatusText = completed.LibrarySize == 0
|
||||
? "В выбранных папках не нашлось видео"
|
||||
: $"В библиотеке {completed.LibrarySize} видео";
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void InsertIntoView(VideoCardViewModel card)
|
||||
{
|
||||
if (!PassesFilter(card))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Appending keeps the grid stable while a scan streams in; the final RebuildView
|
||||
// puts everything in the requested order once the scan settles.
|
||||
Videos.Add(card);
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
}
|
||||
|
||||
private bool PassesFilter(VideoCardViewModel card) =>
|
||||
string.IsNullOrWhiteSpace(SearchText) || card.Matches(SearchText.Trim());
|
||||
|
||||
private void RebuildView()
|
||||
{
|
||||
var visible = _all.Where(PassesFilter);
|
||||
|
||||
visible = SelectedSort.Sort switch
|
||||
{
|
||||
LibrarySort.TitleAscending => visible.OrderBy(x => x.Title, StringComparer.CurrentCultureIgnoreCase),
|
||||
LibrarySort.LongestFirst => visible.OrderByDescending(x => x.RawDuration ?? TimeSpan.Zero),
|
||||
LibrarySort.LargestFirst => visible.OrderByDescending(x => x.RawSizeInBytes),
|
||||
_ => visible.OrderByDescending(x => x.AddedAt),
|
||||
};
|
||||
|
||||
Videos.Clear();
|
||||
|
||||
foreach (var card in visible)
|
||||
{
|
||||
Videos.Add(card);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(IsEmpty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
public enum LibrarySort
|
||||
{
|
||||
RecentlyAdded,
|
||||
TitleAscending,
|
||||
LongestFirst,
|
||||
LargestFirst,
|
||||
}
|
||||
|
||||
/// <summary>A sort order together with the label the combo box shows for it.</summary>
|
||||
public sealed record SortOption(LibrarySort Sort, string Label)
|
||||
{
|
||||
public static IReadOnlyList<SortOption> All { get; } =
|
||||
[
|
||||
new(LibrarySort.RecentlyAdded, "Недавно добавленные"),
|
||||
new(LibrarySort.TitleAscending, "По названию"),
|
||||
new(LibrarySort.LongestFirst, "Сначала длинные"),
|
||||
new(LibrarySort.LargestFirst, "Сначала большие"),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>One card in the library grid.</summary>
|
||||
public sealed partial class VideoCardViewModel : ObservableObject
|
||||
{
|
||||
private readonly ISystemShell _shell;
|
||||
|
||||
public VideoCardViewModel(VideoItem item, ISystemShell shell)
|
||||
{
|
||||
_shell = shell;
|
||||
Id = item.Id;
|
||||
FullPath = item.FullPath;
|
||||
Title = item.Title;
|
||||
Apply(item);
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
|
||||
public string FullPath { get; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; private set; }
|
||||
|
||||
public TimeSpan? RawDuration { get; private set; }
|
||||
|
||||
public long RawSizeInBytes { get; private set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Title { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? ThumbnailPath { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string DurationText { get; set; } = "—";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string SizeText { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? QualityText { get; set; }
|
||||
|
||||
/// <summary>True while the poster frame has not been produced yet.</summary>
|
||||
[ObservableProperty]
|
||||
public partial bool IsPending { get; set; } = true;
|
||||
|
||||
/// <summary>Copies the current state of the entity into the card.</summary>
|
||||
public void Apply(VideoItem item)
|
||||
{
|
||||
Title = item.Title;
|
||||
ThumbnailPath = item.ThumbnailPath;
|
||||
DurationText = DisplayText.Duration(item.Duration);
|
||||
SizeText = DisplayText.FileSize(item.SizeInBytes);
|
||||
QualityText = DisplayText.Quality(item.Width, item.Height);
|
||||
AddedAt = item.AddedAt;
|
||||
RawDuration = item.Duration;
|
||||
RawSizeInBytes = item.SizeInBytes;
|
||||
IsPending = item.ThumbnailPath is null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Play() => _shell.OpenFile(FullPath);
|
||||
|
||||
[RelayCommand]
|
||||
private void Reveal() => _shell.RevealInFileManager(FullPath);
|
||||
|
||||
public bool Matches(string term) =>
|
||||
Title.Contains(term, StringComparison.CurrentCultureIgnoreCase) ||
|
||||
FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:PLib.Desktop.Controls"
|
||||
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
|
||||
x:Class="PLib.Desktop.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Title="PLib — видеотека"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="640"
|
||||
MinHeight="420"
|
||||
Background="{DynamicResource PageBackgroundBrush}"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<Window.Resources>
|
||||
|
||||
<!-- ======================= Video card ======================= -->
|
||||
<DataTemplate x:Key="VideoCardTemplate" DataType="vm:VideoCardViewModel">
|
||||
<Button Classes="card" Command="{Binding PlayCommand}" ToolTip.Tip="{Binding FullPath}">
|
||||
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Воспроизвести" Command="{Binding PlayCommand}" />
|
||||
<MenuItem Header="Показать в папке" Command="{Binding RevealCommand}" />
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
|
||||
<!-- Poster frame -->
|
||||
<Border Grid.Row="0"
|
||||
CornerRadius="9"
|
||||
ClipToBounds="True"
|
||||
Background="{DynamicResource ThumbnailPlaceholderBrush}">
|
||||
<Panel>
|
||||
<controls:AsyncImage Source="{Binding ThumbnailPath}"
|
||||
DecodeWidth="480"
|
||||
PlaceholderBrush="{DynamicResource ThumbnailPlaceholderBrush}" />
|
||||
|
||||
<!-- Shown until ffmpeg has produced a frame for this file. -->
|
||||
<icons:MaterialIcon Kind="FilmstripBoxMultiple"
|
||||
Width="30"
|
||||
Height="30"
|
||||
Opacity="0.35"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsPending}" />
|
||||
|
||||
<Border Classes="badge"
|
||||
Margin="8"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
IsVisible="{Binding QualityText, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding QualityText}" />
|
||||
</Border>
|
||||
|
||||
<Border Classes="badge"
|
||||
Margin="8"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom">
|
||||
<TextBlock Text="{Binding DurationText}" />
|
||||
</Border>
|
||||
|
||||
<Border Classes="playOverlay" Background="{DynamicResource OverlayBrush}">
|
||||
<Border Width="46"
|
||||
Height="46"
|
||||
CornerRadius="23"
|
||||
Background="{DynamicResource AccentBrush}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<icons:MaterialIcon Kind="Play" Width="24" Height="24" Foreground="White" />
|
||||
</Border>
|
||||
</Border>
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<!-- Caption -->
|
||||
<StackPanel Grid.Row="1" Margin="4,10,4,2" Spacing="3">
|
||||
<TextBlock Classes="cardTitle" Text="{Binding Title}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="cardMeta" Text="{Binding SizeText}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
|
||||
</Window.Resources>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ======================= Header ======================= -->
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource SurfaceBorderBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="20,14">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="20">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<Border Width="34"
|
||||
Height="34"
|
||||
CornerRadius="9"
|
||||
Background="{DynamicResource AccentSoftBrush}">
|
||||
<icons:MaterialIcon Kind="VideoVintage"
|
||||
Width="19"
|
||||
Height="19"
|
||||
Foreground="{DynamicResource AccentBrush}" />
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Classes="sectionTitle" Text="Видеотека" />
|
||||
<TextBlock Classes="cardMeta" Text="{Binding Videos.Count, StringFormat='{}{0} видео'}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Grid.Column="1"
|
||||
MaxWidth="420"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
PlaceholderText="Поиск по названию…"
|
||||
Text="{Binding SearchText}" />
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
|
||||
<ComboBox MinWidth="180"
|
||||
ItemsSource="{Binding SortOptions}"
|
||||
SelectedItem="{Binding SelectedSort}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SortOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<Button Command="{Binding ScanCommand}"
|
||||
IsEnabled="{Binding !IsScanning}"
|
||||
ToolTip.Tip="Пересканировать библиотеку">
|
||||
<icons:MaterialIcon Kind="Refresh" Width="17" Height="17" />
|
||||
</Button>
|
||||
|
||||
<Button Command="{Binding ToggleThemeCommand}" ToolTip.Tip="Сменить тему">
|
||||
<icons:MaterialIcon Kind="ThemeLightDark" Width="17" Height="17" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="Primary" Command="{Binding AddFolderCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<icons:MaterialIcon Kind="FolderPlusOutline" Width="17" Height="17" />
|
||||
<TextBlock Text="Добавить папку" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ======================= Grid ======================= -->
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<ScrollViewer Padding="20,18" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsRepeater ItemsSource="{Binding Videos}" ItemTemplate="{StaticResource VideoCardTemplate}">
|
||||
<ItemsRepeater.Layout>
|
||||
<UniformGridLayout ItemsStretch="Fill"
|
||||
MinItemWidth="230"
|
||||
MinItemHeight="212"
|
||||
MinColumnSpacing="16"
|
||||
MinRowSpacing="16" />
|
||||
</ItemsRepeater.Layout>
|
||||
</ItemsRepeater>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Empty state -->
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="14"
|
||||
MaxWidth="420"
|
||||
IsVisible="{Binding IsEmpty}">
|
||||
<icons:MaterialIcon Kind="VideoBoxOff"
|
||||
Width="52"
|
||||
Height="52"
|
||||
Opacity="0.35"
|
||||
Foreground="{DynamicResource TextTertiaryBrush}"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Classes="sectionTitle"
|
||||
HorizontalAlignment="Center"
|
||||
Text="Здесь пока пусто" />
|
||||
<TextBlock Classes="subtle"
|
||||
HorizontalAlignment="Center"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
Text="Укажите папку с видеофайлами — PLib пройдётся по ней, соберёт превью и покажет всё карточками." />
|
||||
<Button Classes="Primary"
|
||||
HorizontalAlignment="Center"
|
||||
Command="{Binding AddFolderCommand}"
|
||||
Content="Выбрать папку" />
|
||||
</StackPanel>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!-- ======================= Status bar ======================= -->
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource SurfaceBorderBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="20,9">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="14">
|
||||
|
||||
<ProgressBar Grid.Column="0"
|
||||
Width="160"
|
||||
VerticalAlignment="Center"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="{Binding ScanProgress}"
|
||||
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||
IsVisible="{Binding IsScanning}" />
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="subtle"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding StatusText}" />
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Content="Отмена"
|
||||
Command="{Binding ScanCancelCommand}"
|
||||
IsVisible="{Binding IsScanning}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace PLib.Desktop.Views;
|
||||
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="0.1.0.0" name="PLib.Desktop" />
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Library": {
|
||||
"Folders": [],
|
||||
"ThumbnailWidth": 480,
|
||||
"ThumbnailPositionRatio": 0.15,
|
||||
"MaxIndexingConcurrency": 4,
|
||||
"MinimumFileSizeInBytes": 65536
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>PLib.Domain</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace PLib.Domain.Videos;
|
||||
|
||||
/// <summary>
|
||||
/// A single video file that belongs to the library.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The absolute path is the natural identity of a video: the library is a view over the
|
||||
/// file system, so two entries pointing at the same path are the same video. Mutation goes
|
||||
/// through explicit methods so that the entity can never end up half-updated.
|
||||
/// </remarks>
|
||||
public sealed class VideoItem
|
||||
{
|
||||
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
||||
private VideoItem()
|
||||
{
|
||||
FullPath = null!;
|
||||
Title = null!;
|
||||
}
|
||||
|
||||
public VideoItem(string fullPath, string title, long sizeInBytes, DateTimeOffset fileModifiedAt)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
|
||||
|
||||
Id = Guid.CreateVersion7();
|
||||
FullPath = fullPath;
|
||||
Title = title;
|
||||
SizeInBytes = sizeInBytes;
|
||||
FileModifiedAt = fileModifiedAt;
|
||||
AddedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public Guid Id { get; private set; }
|
||||
|
||||
/// <summary>Absolute path of the file on disk. Unique within the library.</summary>
|
||||
public string FullPath { get; private set; }
|
||||
|
||||
/// <summary>Human readable name; defaults to the file name without extension.</summary>
|
||||
public string Title { get; private set; }
|
||||
|
||||
public long SizeInBytes { get; private set; }
|
||||
|
||||
public TimeSpan? Duration { get; private set; }
|
||||
|
||||
public int? Width { get; private set; }
|
||||
|
||||
public int? Height { get; private set; }
|
||||
|
||||
public string? VideoCodec { get; private set; }
|
||||
|
||||
/// <summary>Absolute path of the generated poster frame, or <c>null</c> if none exists yet.</summary>
|
||||
public string? ThumbnailPath { get; private set; }
|
||||
|
||||
/// <summary>Last write time of the file when it was last indexed.</summary>
|
||||
public DateTimeOffset FileModifiedAt { get; private set; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; private set; }
|
||||
|
||||
/// <summary>True once the file has been probed and a poster frame produced.</summary>
|
||||
public bool IsIndexed => Duration is not null && ThumbnailPath is not null;
|
||||
|
||||
public void Rename(string title)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
Title = title;
|
||||
}
|
||||
|
||||
public void ApplyTechnicalInfo(VideoTechnicalInfo info)
|
||||
{
|
||||
Duration = info.Duration;
|
||||
Width = info.Width;
|
||||
Height = info.Height;
|
||||
VideoCodec = info.VideoCodec;
|
||||
}
|
||||
|
||||
public void AttachThumbnail(string thumbnailPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath);
|
||||
ThumbnailPath = thumbnailPath;
|
||||
}
|
||||
|
||||
public void DetachThumbnail() => ThumbnailPath = null;
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the file system facts after the file changed on disk, and invalidates
|
||||
/// everything that was derived from the previous revision of the file.
|
||||
/// </summary>
|
||||
public void RefreshFileFacts(long sizeInBytes, DateTimeOffset fileModifiedAt)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
|
||||
|
||||
if (SizeInBytes == sizeInBytes && FileModifiedAt == fileModifiedAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SizeInBytes = sizeInBytes;
|
||||
FileModifiedAt = fileModifiedAt;
|
||||
ApplyTechnicalInfo(VideoTechnicalInfo.Unknown);
|
||||
DetachThumbnail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace PLib.Domain.Videos;
|
||||
|
||||
/// <summary>
|
||||
/// Technical facts about a media file that are discovered by probing it,
|
||||
/// as opposed to facts we already know from the file system entry itself.
|
||||
/// </summary>
|
||||
/// <param name="Duration">Playback duration, or <c>null</c> if the container did not report one.</param>
|
||||
/// <param name="Width">Width of the primary video stream in pixels.</param>
|
||||
/// <param name="Height">Height of the primary video stream in pixels.</param>
|
||||
/// <param name="VideoCodec">Short codec name of the primary video stream, e.g. <c>h264</c>.</param>
|
||||
public readonly record struct VideoTechnicalInfo(
|
||||
TimeSpan? Duration,
|
||||
int? Width,
|
||||
int? Height,
|
||||
string? VideoCodec)
|
||||
{
|
||||
public static VideoTechnicalInfo Unknown => default;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using PLib.Domain.Videos;
|
||||
using Shouldly;
|
||||
|
||||
namespace PLib.Tests.Domain;
|
||||
|
||||
public sealed class VideoItemTests
|
||||
{
|
||||
private static VideoItem CreateIndexedItem()
|
||||
{
|
||||
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
|
||||
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(3), 1920, 1080, "h264"));
|
||||
item.AttachThumbnail(@"C:\cache\clip.jpg");
|
||||
return item;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_item_is_indexed_only_once_it_has_both_a_duration_and_a_thumbnail()
|
||||
{
|
||||
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
|
||||
item.IsIndexed.ShouldBeFalse();
|
||||
|
||||
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
||||
item.IsIndexed.ShouldBeFalse();
|
||||
|
||||
item.AttachThumbnail(@"C:\cache\clip.jpg");
|
||||
item.IsIndexed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refreshing_an_unchanged_file_keeps_everything_that_was_derived_from_it()
|
||||
{
|
||||
var item = CreateIndexedItem();
|
||||
|
||||
item.RefreshFileFacts(1_000, DateTimeOffset.UnixEpoch);
|
||||
|
||||
item.IsIndexed.ShouldBeTrue();
|
||||
item.ThumbnailPath.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refreshing_a_changed_file_invalidates_the_metadata_and_the_thumbnail()
|
||||
{
|
||||
var item = CreateIndexedItem();
|
||||
|
||||
item.RefreshFileFacts(2_000, DateTimeOffset.UnixEpoch.AddDays(1));
|
||||
|
||||
item.SizeInBytes.ShouldBe(2_000);
|
||||
item.Duration.ShouldBeNull();
|
||||
item.ThumbnailPath.ShouldBeNull();
|
||||
item.IsIndexed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "title")]
|
||||
[InlineData(" ", "title")]
|
||||
[InlineData(@"C:\videos\clip.mp4", "")]
|
||||
public void An_item_cannot_be_created_without_a_path_and_a_title(string path, string title) =>
|
||||
Should.Throw<ArgumentException>(() => new VideoItem(path, title, 1, DateTimeOffset.UnixEpoch));
|
||||
|
||||
[Fact]
|
||||
public void An_item_cannot_have_a_negative_size() =>
|
||||
Should.Throw<ArgumentOutOfRangeException>(
|
||||
() => new VideoItem(@"C:\videos\clip.mp4", "clip", -1, DateTimeOffset.UnixEpoch));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,50 @@
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Tests.Library;
|
||||
|
||||
/// <summary>
|
||||
/// A hand-written double rather than a mock: the scan logic is all about what ends up in the
|
||||
/// repository, so the tests read better when they can just look at the resulting list.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryVideoRepository : IVideoRepository
|
||||
{
|
||||
private readonly Dictionary<string, VideoItem> _items = new(LibraryPathComparer.Instance);
|
||||
|
||||
public int SaveCount { get; private set; }
|
||||
|
||||
public IReadOnlyCollection<VideoItem> Items => _items.Values;
|
||||
|
||||
public void Seed(params VideoItem[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
_items[item.FullPath] = item;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<VideoItem>>([.. _items.Values]);
|
||||
|
||||
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_items.GetValueOrDefault(fullPath));
|
||||
|
||||
public Task AddAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_items[item.FullPath] = item;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_items.Remove(item.FullPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
SaveCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Domain.Videos;
|
||||
using Shouldly;
|
||||
|
||||
namespace PLib.Tests.Library;
|
||||
|
||||
public sealed class LibraryServiceTests
|
||||
{
|
||||
private const string Root = @"C:\videos";
|
||||
|
||||
private readonly InMemoryVideoRepository _repository = new();
|
||||
private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>();
|
||||
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
|
||||
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
|
||||
|
||||
public LibraryServiceTests()
|
||||
{
|
||||
_probe.ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new VideoTechnicalInfo(TimeSpan.FromMinutes(2), 1920, 1080, "h264"));
|
||||
|
||||
_thumbnails.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(callInfo => $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg<string>())}.jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Files_that_are_new_on_disk_are_added_probed_and_given_a_thumbnail()
|
||||
{
|
||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4"), File(@"C:\videos\b.mkv"));
|
||||
|
||||
var events = await CollectAsync(CreateService());
|
||||
|
||||
_repository.Items.Count.ShouldBe(2);
|
||||
_repository.Items.ShouldAllBe(x => x.IsIndexed);
|
||||
|
||||
events.OfType<LibraryScanEvent.ItemAdded>().Count().ShouldBe(2);
|
||||
events.OfType<LibraryScanEvent.ItemUpdated>().Count().ShouldBe(2);
|
||||
events.OfType<LibraryScanEvent.Completed>().Single().LibrarySize.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Entries_whose_file_is_gone_are_dropped_from_the_library()
|
||||
{
|
||||
_repository.Seed(new VideoItem(@"C:\videos\stale.mp4", "stale", 5_000, DateTimeOffset.UnixEpoch));
|
||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
||||
|
||||
var events = await CollectAsync(CreateService());
|
||||
|
||||
_repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\a.mp4"]);
|
||||
events.OfType<LibraryScanEvent.ItemRemoved>().Count().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Files_below_the_minimum_size_are_not_part_of_the_library()
|
||||
{
|
||||
GivenFilesOnDisk(File(@"C:\videos\tiny.mp4", sizeInBytes: 128), File(@"C:\videos\real.mp4"));
|
||||
|
||||
await CollectAsync(CreateService(new LibraryOptions { MinimumFileSizeInBytes = 1_024 }));
|
||||
|
||||
_repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\real.mp4"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_item_that_is_already_indexed_is_not_probed_again()
|
||||
{
|
||||
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
||||
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
||||
indexed.AttachThumbnail(@"C:\cache\a.jpg");
|
||||
_repository.Seed(indexed);
|
||||
|
||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
|
||||
|
||||
await CollectAsync(CreateService());
|
||||
|
||||
await _probe.DidNotReceive().ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_item_whose_file_changed_on_disk_is_indexed_again()
|
||||
{
|
||||
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
||||
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
||||
indexed.AttachThumbnail(@"C:\cache\old.jpg");
|
||||
_repository.Seed(indexed);
|
||||
|
||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 9_999));
|
||||
|
||||
await CollectAsync(CreateService());
|
||||
|
||||
await _probe.Received(1).ProbeAsync(@"C:\videos\a.mp4", Arg.Any<CancellationToken>());
|
||||
_repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_same_file_reached_through_two_overlapping_roots_is_only_added_once()
|
||||
{
|
||||
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
||||
|
||||
var events = await CollectAsync(CreateService(), Root, Root);
|
||||
|
||||
_repository.Items.Count.ShouldBe(1);
|
||||
events.OfType<LibraryScanEvent.DiscoveryCompleted>().Single().FilesFound.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static DiscoveredVideoFile File(string path, long sizeInBytes = 10_000) =>
|
||||
new(path, sizeInBytes, DateTimeOffset.UnixEpoch);
|
||||
|
||||
private void GivenFilesOnDisk(params DiscoveredVideoFile[] files) =>
|
||||
_scanner.ScanAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_ => files.ToAsyncEnumerable());
|
||||
|
||||
private LibraryService CreateService(LibraryOptions? options = null) => new(
|
||||
_repository,
|
||||
_scanner,
|
||||
_probe,
|
||||
_thumbnails,
|
||||
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
||||
NullLogger<LibraryService>.Instance);
|
||||
|
||||
private static async Task<List<LibraryScanEvent>> CollectAsync(
|
||||
LibraryService service,
|
||||
params string[] folders)
|
||||
{
|
||||
var events = new List<LibraryScanEvent>();
|
||||
|
||||
await foreach (var scanEvent in service.ScanAsync(folders.Length == 0 ? [Root] : folders))
|
||||
{
|
||||
events.Add(scanEvent);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AsyncEnumerableExtensions
|
||||
{
|
||||
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
|
||||
{
|
||||
foreach (var item in source)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>PLib.Tests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<!-- xunit.v3 runs on Microsoft.Testing.Platform; no VSTest adapter needed. -->
|
||||
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\PLib.Application\PLib.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\PLib.Domain\PLib.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user