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
|
# 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